diff --git a/.github/auto-approve.yml b/.github/auto-approve.yml deleted file mode 100644 index 311ebbb853..0000000000 --- a/.github/auto-approve.yml +++ /dev/null @@ -1,3 +0,0 @@ -# https://github.com/googleapis/repo-automation-bots/tree/main/packages/auto-approve -processes: - - "OwlBotTemplateChanges" diff --git a/.github/release-please.yml b/.github/release-please.yml deleted file mode 100644 index 466597e5b1..0000000000 --- a/.github/release-please.yml +++ /dev/null @@ -1,2 +0,0 @@ -releaseType: python -handleGHRelease: true diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml deleted file mode 100644 index 4fbd4aa427..0000000000 --- a/.github/release-trigger.yml +++ /dev/null @@ -1,2 +0,0 @@ -enabled: true -multiScmName: python-bigquery-dataframes diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml deleted file mode 100644 index c2f3673fcc..0000000000 --- a/.github/sync-repo-settings.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# https://github.com/googleapis/repo-automation-bots/tree/main/packages/sync-repo-settings -# Rules for main branch protection -branchProtectionRules: -# Identifies the protection rule pattern. Name of the branch to be protected. -# Defaults to `main` -- pattern: main - requiresCodeOwnerReviews: true - requiresStrictStatusChecks: false - requiredStatusCheckContexts: - - 'OwlBot Post Processor' - - 'conventionalcommits.org' - - 'cla/google' - - 'docs' - - 'lint' - - 'unit (3.9)' - - 'unit (3.10)' - - 'unit (3.11)' - - 'unit (3.12)' - - 'cover' - - 'Kokoro presubmit' - - 'Kokoro windows' -permissionRules: - - team: actools-python - permission: admin - - team: actools - permission: admin - - team: api-bigquery-dataframe - permission: push - - team: yoshi-python - permission: push - - team: python-samples-owners - permission: push - - team: python-samples-reviewers - permission: push diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000000..13d4d87263 --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,57 @@ +name: Deploy docs to GitHub Pages + +on: + # Runs on pushes targeting the default branch + # TODO(tswast): Update this to only be releases once we confirm it's working. + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install nox + run: | + python -m pip install --upgrade setuptools pip wheel + python -m pip install nox + - name: Run docs + run: | + nox -s docs + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html/ + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2833fe98ff..6773aef7c2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,6 +2,9 @@ on: pull_request: branches: - main + push: + branches: + - main name: docs jobs: docs: @@ -12,7 +15,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.13" - name: Install nox run: | python -m pip install --upgrade setuptools pip wheel diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml new file mode 100644 index 0000000000..588aa854f3 --- /dev/null +++ b/.github/workflows/js-tests.yml @@ -0,0 +1,20 @@ +name: js-tests +on: + pull_request: + branches: + - main + push: + branches: + - main +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install modules + working-directory: ./tests/js + run: npm install + - name: Run tests + working-directory: ./tests/js + run: npm test diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1051da0bdd..7914b72651 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,9 @@ on: pull_request: branches: - main + push: + branches: + - main name: lint jobs: lint: diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 0000000000..fc9e970946 --- /dev/null +++ b/.github/workflows/mypy.yml @@ -0,0 +1,25 @@ +on: + pull_request: + branches: + - main + push: + branches: + - main +name: mypy +jobs: + mypy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install nox + run: | + python -m pip install --upgrade setuptools pip wheel + python -m pip install nox + - name: Run mypy + run: | + nox -s mypy diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index a7805de447..518cec6312 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -2,6 +2,9 @@ on: pull_request: branches: - main + push: + branches: + - main name: unittest jobs: unit: diff --git a/.gitignore b/.gitignore index d083ea1ddc..0ff74ef528 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,9 @@ coverage.xml # System test environment variables. system_tests/local_test_setup +tests/js/node_modules/ # Make sure a generated file isn't accidentally committed. pylintrc pylintrc.test +dummy.pkl diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 58eaa7fedf..6cc03455da 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -50,3 +50,6 @@ if [[ -n "${NOX_SESSION:-}" ]]; then else python3 -m nox --stop-on-first-error fi + +# Prevent kokoro from trying to collect many mb of artifacts, wasting several minutes +sudo rm -rf "${KOKORO_ARTIFACTS_DIR?}"/* diff --git a/.kokoro/continuous/doctest.cfg b/.kokoro/continuous/doctest.cfg index 6016700408..2aad95beed 100644 --- a/.kokoro/continuous/doctest.cfg +++ b/.kokoro/continuous/doctest.cfg @@ -3,7 +3,7 @@ # Only run this nox session. env_vars: { key: "NOX_SESSION" - value: "doctest cleanup" + value: "cleanup doctest" } env_vars: { diff --git a/.kokoro/docker/docs/Dockerfile b/.kokoro/docker/docs/Dockerfile deleted file mode 100644 index e5410e296b..0000000000 --- a/.kokoro/docker/docs/Dockerfile +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from ubuntu:24.04 - -ENV DEBIAN_FRONTEND noninteractive - -# Ensure local Python is preferred over distribution Python. -ENV PATH /usr/local/bin:$PATH - -# Install dependencies. -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - apt-transport-https \ - build-essential \ - ca-certificates \ - curl \ - dirmngr \ - git \ - gpg-agent \ - graphviz \ - libbz2-dev \ - libdb5.3-dev \ - libexpat1-dev \ - libffi-dev \ - liblzma-dev \ - libreadline-dev \ - libsnappy-dev \ - libssl-dev \ - libsqlite3-dev \ - portaudio19-dev \ - redis-server \ - software-properties-common \ - ssh \ - sudo \ - tcl \ - tcl-dev \ - tk \ - tk-dev \ - uuid-dev \ - wget \ - zlib1g-dev \ - && add-apt-repository universe \ - && apt-get update \ - && apt-get -y install jq \ - && apt-get clean autoclean \ - && apt-get autoremove -y \ - && rm -rf /var/lib/apt/lists/* \ - && rm -f /var/cache/apt/archives/*.deb - - -###################### Install python 3.10.14 for docs/docfx session - -# Download python 3.10.14 -RUN wget https://www.python.org/ftp/python/3.10.14/Python-3.10.14.tgz - -# Extract files -RUN tar -xvf Python-3.10.14.tgz - -# Install python 3.10.14 -RUN ./Python-3.10.14/configure --enable-optimizations -RUN make altinstall - -ENV PATH /usr/local/bin/python3.10:$PATH - -###################### Install pip -RUN wget -O /tmp/get-pip.py 'https://bootstrap.pypa.io/get-pip.py' \ - && python3.10 /tmp/get-pip.py \ - && rm /tmp/get-pip.py - -# Test pip -RUN python3.10 -m pip - -# Install build requirements -COPY requirements.txt /requirements.txt -RUN python3.10 -m pip install --require-hashes -r requirements.txt - -CMD ["python3.10"] diff --git a/.kokoro/docker/docs/fetch_gpg_keys.sh b/.kokoro/docker/docs/fetch_gpg_keys.sh deleted file mode 100644 index c4a92a33ea..0000000000 --- a/.kokoro/docker/docs/fetch_gpg_keys.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# A script to fetch gpg keys with retry. -# Avoid jinja parsing the file. -# - -function retry { - if [[ "${#}" -le 1 ]]; then - echo "Usage: ${0} retry_count commands.." - exit 1 - fi - local retries=${1} - local command="${@:2}" - until [[ "${retries}" -le 0 ]]; do - $command && return 0 - if [[ $? -ne 0 ]]; then - echo "command failed, retrying" - ((retries--)) - fi - done - return 1 -} - -# 3.6.9, 3.7.5 (Ned Deily) -retry 3 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys \ - 0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D - -# 3.8.0 (Łukasz Langa) -retry 3 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys \ - E3FF2839C048B25C084DEBE9B26995E310250568 - -# diff --git a/.kokoro/docker/docs/requirements.in b/.kokoro/docker/docs/requirements.in deleted file mode 100644 index 586bd07037..0000000000 --- a/.kokoro/docker/docs/requirements.in +++ /dev/null @@ -1,2 +0,0 @@ -nox -gcp-docuploader diff --git a/.kokoro/docker/docs/requirements.txt b/.kokoro/docker/docs/requirements.txt deleted file mode 100644 index a9360a25b7..0000000000 --- a/.kokoro/docker/docs/requirements.txt +++ /dev/null @@ -1,297 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.10 -# by the following command: -# -# pip-compile --allow-unsafe --generate-hashes requirements.in -# -argcomplete==3.5.3 \ - --hash=sha256:2ab2c4a215c59fd6caaff41a869480a23e8f6a5f910b266c1808037f4e375b61 \ - --hash=sha256:c12bf50eded8aebb298c7b7da7a5ff3ee24dffd9f5281867dfe1424b58c55392 - # via nox -cachetools==5.5.0 \ - --hash=sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292 \ - --hash=sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a - # via google-auth -certifi==2024.12.14 \ - --hash=sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56 \ - --hash=sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db - # via requests -charset-normalizer==3.4.1 \ - --hash=sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537 \ - --hash=sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa \ - --hash=sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a \ - --hash=sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294 \ - --hash=sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b \ - --hash=sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd \ - --hash=sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601 \ - --hash=sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd \ - --hash=sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4 \ - --hash=sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d \ - --hash=sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2 \ - --hash=sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313 \ - --hash=sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd \ - --hash=sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa \ - --hash=sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8 \ - --hash=sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1 \ - --hash=sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2 \ - --hash=sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496 \ - --hash=sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d \ - --hash=sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b \ - --hash=sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e \ - --hash=sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a \ - --hash=sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4 \ - --hash=sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca \ - --hash=sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78 \ - --hash=sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408 \ - --hash=sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5 \ - --hash=sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3 \ - --hash=sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f \ - --hash=sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a \ - --hash=sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765 \ - --hash=sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6 \ - --hash=sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146 \ - --hash=sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6 \ - --hash=sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9 \ - --hash=sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd \ - --hash=sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c \ - --hash=sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f \ - --hash=sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545 \ - --hash=sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176 \ - --hash=sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770 \ - --hash=sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824 \ - --hash=sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f \ - --hash=sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf \ - --hash=sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487 \ - --hash=sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d \ - --hash=sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd \ - --hash=sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b \ - --hash=sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534 \ - --hash=sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f \ - --hash=sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b \ - --hash=sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9 \ - --hash=sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd \ - --hash=sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125 \ - --hash=sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9 \ - --hash=sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de \ - --hash=sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11 \ - --hash=sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d \ - --hash=sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35 \ - --hash=sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f \ - --hash=sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda \ - --hash=sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7 \ - --hash=sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a \ - --hash=sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971 \ - --hash=sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8 \ - --hash=sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41 \ - --hash=sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d \ - --hash=sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f \ - --hash=sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757 \ - --hash=sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a \ - --hash=sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886 \ - --hash=sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77 \ - --hash=sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76 \ - --hash=sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247 \ - --hash=sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85 \ - --hash=sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb \ - --hash=sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7 \ - --hash=sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e \ - --hash=sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6 \ - --hash=sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037 \ - --hash=sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1 \ - --hash=sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e \ - --hash=sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807 \ - --hash=sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407 \ - --hash=sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c \ - --hash=sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12 \ - --hash=sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3 \ - --hash=sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089 \ - --hash=sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd \ - --hash=sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e \ - --hash=sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00 \ - --hash=sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616 - # via requests -click==8.1.8 \ - --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ - --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a - # via gcp-docuploader -colorlog==6.9.0 \ - --hash=sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff \ - --hash=sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2 - # via - # gcp-docuploader - # nox -distlib==0.3.9 \ - --hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \ - --hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403 - # via virtualenv -filelock==3.16.1 \ - --hash=sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0 \ - --hash=sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435 - # via virtualenv -gcp-docuploader==0.6.5 \ - --hash=sha256:30221d4ac3e5a2b9c69aa52fdbef68cc3f27d0e6d0d90e220fc024584b8d2318 \ - --hash=sha256:b7458ef93f605b9d46a4bf3a8dc1755dad1f31d030c8679edf304e343b347eea - # via -r requirements.in -google-api-core==2.24.0 \ - --hash=sha256:10d82ac0fca69c82a25b3efdeefccf6f28e02ebb97925a8cce8edbfe379929d9 \ - --hash=sha256:e255640547a597a4da010876d333208ddac417d60add22b6851a0c66a831fcaf - # via - # google-cloud-core - # google-cloud-storage -google-auth==2.37.0 \ - --hash=sha256:0054623abf1f9c83492c63d3f47e77f0a544caa3d40b2d98e099a611c2dd5d00 \ - --hash=sha256:42664f18290a6be591be5329a96fe30184be1a1badb7292a7f686a9659de9ca0 - # via - # google-api-core - # google-cloud-core - # google-cloud-storage -google-cloud-core==2.4.1 \ - --hash=sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073 \ - --hash=sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61 - # via google-cloud-storage -google-cloud-storage==2.19.0 \ - --hash=sha256:aeb971b5c29cf8ab98445082cbfe7b161a1f48ed275822f59ed3f1524ea54fba \ - --hash=sha256:cd05e9e7191ba6cb68934d8eb76054d9be4562aa89dbc4236feee4d7d51342b2 - # via gcp-docuploader -google-crc32c==1.6.0 \ - --hash=sha256:05e2d8c9a2f853ff116db9706b4a27350587f341eda835f46db3c0a8c8ce2f24 \ - --hash=sha256:18e311c64008f1f1379158158bb3f0c8d72635b9eb4f9545f8cf990c5668e59d \ - --hash=sha256:236c87a46cdf06384f614e9092b82c05f81bd34b80248021f729396a78e55d7e \ - --hash=sha256:35834855408429cecf495cac67ccbab802de269e948e27478b1e47dfb6465e57 \ - --hash=sha256:386122eeaaa76951a8196310432c5b0ef3b53590ef4c317ec7588ec554fec5d2 \ - --hash=sha256:40b05ab32a5067525670880eb5d169529089a26fe35dce8891127aeddc1950e8 \ - --hash=sha256:48abd62ca76a2cbe034542ed1b6aee851b6f28aaca4e6551b5599b6f3ef175cc \ - --hash=sha256:50cf2a96da226dcbff8671233ecf37bf6e95de98b2a2ebadbfdf455e6d05df42 \ - --hash=sha256:51c4f54dd8c6dfeb58d1df5e4f7f97df8abf17a36626a217f169893d1d7f3e9f \ - --hash=sha256:5bcc90b34df28a4b38653c36bb5ada35671ad105c99cfe915fb5bed7ad6924aa \ - --hash=sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b \ - --hash=sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc \ - --hash=sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760 \ - --hash=sha256:91ca8145b060679ec9176e6de4f89b07363d6805bd4760631ef254905503598d \ - --hash=sha256:a184243544811e4a50d345838a883733461e67578959ac59964e43cca2c791e7 \ - --hash=sha256:a9e4b426c3702f3cd23b933436487eb34e01e00327fac20c9aebb68ccf34117d \ - --hash=sha256:bb0966e1c50d0ef5bc743312cc730b533491d60585a9a08f897274e57c3f70e0 \ - --hash=sha256:bb8b3c75bd157010459b15222c3fd30577042a7060e29d42dabce449c087f2b3 \ - --hash=sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3 \ - --hash=sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00 \ - --hash=sha256:d2952396dc604544ea7476b33fe87faedc24d666fb0c2d5ac971a2b9576ab871 \ - --hash=sha256:d8797406499f28b5ef791f339594b0b5fdedf54e203b5066675c406ba69d705c \ - --hash=sha256:d9e9913f7bd69e093b81da4535ce27af842e7bf371cde42d1ae9e9bd382dc0e9 \ - --hash=sha256:e2806553238cd076f0a55bddab37a532b53580e699ed8e5606d0de1f856b5205 \ - --hash=sha256:ebab974b1687509e5c973b5c4b8b146683e101e102e17a86bd196ecaa4d099fc \ - --hash=sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d \ - --hash=sha256:f7a1fc29803712f80879b0806cb83ab24ce62fc8daf0569f2204a0cfd7f68ed4 - # via - # google-cloud-storage - # google-resumable-media -google-resumable-media==2.7.2 \ - --hash=sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa \ - --hash=sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0 - # via google-cloud-storage -googleapis-common-protos==1.66.0 \ - --hash=sha256:c3e7b33d15fdca5374cc0a7346dd92ffa847425cc4ea941d970f13680052ec8c \ - --hash=sha256:d7abcd75fabb2e0ec9f74466401f6c119a0b498e27370e9be4c94cb7e382b8ed - # via google-api-core -idna==3.10 \ - --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ - --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 - # via requests -nox==2024.10.9 \ - --hash=sha256:1d36f309a0a2a853e9bccb76bbef6bb118ba92fa92674d15604ca99adeb29eab \ - --hash=sha256:7aa9dc8d1c27e9f45ab046ffd1c3b2c4f7c91755304769df231308849ebded95 - # via -r requirements.in -packaging==24.2 \ - --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \ - --hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f - # via nox -platformdirs==4.3.6 \ - --hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \ - --hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb - # via virtualenv -proto-plus==1.25.0 \ - --hash=sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961 \ - --hash=sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91 - # via google-api-core -protobuf==5.29.3 \ - --hash=sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f \ - --hash=sha256:0eb32bfa5219fc8d4111803e9a690658aa2e6366384fd0851064b963b6d1f2a7 \ - --hash=sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888 \ - --hash=sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620 \ - --hash=sha256:6ce8cc3389a20693bfde6c6562e03474c40851b44975c9b2bf6df7d8c4f864da \ - --hash=sha256:84a57163a0ccef3f96e4b6a20516cedcf5bb3a95a657131c5c3ac62200d23252 \ - --hash=sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a \ - --hash=sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e \ - --hash=sha256:b89c115d877892a512f79a8114564fb435943b59067615894c3b13cd3e1fa107 \ - --hash=sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f \ - --hash=sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84 - # via - # gcp-docuploader - # google-api-core - # googleapis-common-protos - # proto-plus -pyasn1==0.6.1 \ - --hash=sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 \ - --hash=sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034 - # via - # pyasn1-modules - # rsa -pyasn1-modules==0.4.1 \ - --hash=sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd \ - --hash=sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c - # via google-auth -requests==2.32.3 \ - --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ - --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 - # via - # google-api-core - # google-cloud-storage -rsa==4.9 \ - --hash=sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7 \ - --hash=sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21 - # via google-auth -six==1.17.0 \ - --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ - --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 - # via gcp-docuploader -tomli==2.2.1 \ - --hash=sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6 \ - --hash=sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd \ - --hash=sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c \ - --hash=sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b \ - --hash=sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8 \ - --hash=sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6 \ - --hash=sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77 \ - --hash=sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff \ - --hash=sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea \ - --hash=sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192 \ - --hash=sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249 \ - --hash=sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee \ - --hash=sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4 \ - --hash=sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98 \ - --hash=sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8 \ - --hash=sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4 \ - --hash=sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281 \ - --hash=sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744 \ - --hash=sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69 \ - --hash=sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13 \ - --hash=sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140 \ - --hash=sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e \ - --hash=sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e \ - --hash=sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc \ - --hash=sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff \ - --hash=sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec \ - --hash=sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2 \ - --hash=sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222 \ - --hash=sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106 \ - --hash=sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272 \ - --hash=sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a \ - --hash=sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7 - # via nox -urllib3==2.3.0 \ - --hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \ - --hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d - # via requests -virtualenv==20.28.1 \ - --hash=sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb \ - --hash=sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329 - # via nox diff --git a/.kokoro/docs/common.cfg b/.kokoro/docs/common.cfg deleted file mode 100644 index 5f7559f9da..0000000000 --- a/.kokoro/docs/common.cfg +++ /dev/null @@ -1,67 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -# Build logs will be here -action { - define_artifacts { - regex: "**/*sponge_log.xml" - } -} - -# Download trampoline resources. -gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" - -# Use the trampoline script to run in docker. -build_file: "python-bigquery-dataframes/.kokoro/trampoline_v2.sh" - -# Configure the docker image for kokoro-trampoline. -env_vars: { - key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-devrel-kokoro-resources/python-lib-docs" -} -env_vars: { - key: "TRAMPOLINE_BUILD_FILE" - value: "github/python-bigquery-dataframes/.kokoro/publish-docs.sh" -} - -env_vars: { - key: "STAGING_BUCKET" - value: "docs-staging" -} - -env_vars: { - key: "V2_STAGING_BUCKET" - # Push non-cloud library docs to `docs-staging-v2-dev` instead of the - # Cloud RAD bucket `docs-staging-v2` - value: "docs-staging-v2" -} - -# It will upload the docker image after successful builds. -env_vars: { - key: "TRAMPOLINE_IMAGE_UPLOAD" - value: "true" -} - -# It will always build the docker image. -env_vars: { - key: "TRAMPOLINE_DOCKERFILE" - value: ".kokoro/docker/docs/Dockerfile" -} - -# Fetch the token needed for reporting release status to GitHub -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "yoshi-automation-github-key" - } - } -} - -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "docuploader_service_account" - } - } -} diff --git a/.kokoro/docs/docs-presubmit-gerrit.cfg b/.kokoro/docs/docs-presubmit-gerrit.cfg deleted file mode 100644 index 1d0dc4b499..0000000000 --- a/.kokoro/docs/docs-presubmit-gerrit.cfg +++ /dev/null @@ -1,23 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -env_vars: { - key: "V2_STAGING_BUCKET" - value: "gcloud-python-test" -} - -# We only upload the image in the main `docs` build. -env_vars: { - key: "TRAMPOLINE_IMAGE_UPLOAD" - value: "false" -} - -env_vars: { - key: "TRAMPOLINE_BUILD_FILE" - value: ".kokoro/build.sh" -} - -# Only run this nox session. -env_vars: { - key: "NOX_SESSION" - value: "docfx" -} diff --git a/.kokoro/docs/docs-presubmit.cfg b/.kokoro/docs/docs-presubmit.cfg deleted file mode 100644 index 805cfd162b..0000000000 --- a/.kokoro/docs/docs-presubmit.cfg +++ /dev/null @@ -1,28 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -env_vars: { - key: "STAGING_BUCKET" - value: "gcloud-python-test" -} - -env_vars: { - key: "V2_STAGING_BUCKET" - value: "gcloud-python-test" -} - -# We only upload the image in the main `docs` build. -env_vars: { - key: "TRAMPOLINE_IMAGE_UPLOAD" - value: "false" -} - -env_vars: { - key: "TRAMPOLINE_BUILD_FILE" - value: "github/python-bigquery-dataframes/.kokoro/build.sh" -} - -# Only run this nox session. -env_vars: { - key: "NOX_SESSION" - value: "docs docfx" -} diff --git a/.kokoro/docs/docs.cfg b/.kokoro/docs/docs.cfg deleted file mode 100644 index 8f43917d92..0000000000 --- a/.kokoro/docs/docs.cfg +++ /dev/null @@ -1 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto \ No newline at end of file diff --git a/.kokoro/presubmit/doctest.cfg b/.kokoro/presubmit/doctest.cfg index 6016700408..2aad95beed 100644 --- a/.kokoro/presubmit/doctest.cfg +++ b/.kokoro/presubmit/doctest.cfg @@ -3,7 +3,7 @@ # Only run this nox session. env_vars: { key: "NOX_SESSION" - value: "doctest cleanup" + value: "cleanup doctest" } env_vars: { diff --git a/.kokoro/publish-docs.sh b/.kokoro/publish-docs.sh deleted file mode 100755 index 2d5ba47549..0000000000 --- a/.kokoro/publish-docs.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -eo pipefail - -# Disable buffering, so that the logs stream through. -export PYTHONUNBUFFERED=1 - -export PATH="${HOME}/.local/bin:${PATH}" - -# build docs -nox -s docs - -# create metadata -python3.10 -m docuploader create-metadata \ - --name=$(jq --raw-output '.name // empty' .repo-metadata.json) \ - --version=$(python3.10 setup.py --version) \ - --language=$(jq --raw-output '.language // empty' .repo-metadata.json) \ - --distribution-name=$(python3.10 setup.py --name) \ - --product-page=$(jq --raw-output '.product_documentation // empty' .repo-metadata.json) \ - --github-repository=$(jq --raw-output '.repo // empty' .repo-metadata.json) \ - --issue-tracker=$(jq --raw-output '.issue_tracker // empty' .repo-metadata.json) - -cat docs.metadata - -# upload docs -python3.10 -m docuploader upload docs/_build/html --metadata-file docs.metadata --staging-bucket "${STAGING_BUCKET}" - - -# docfx yaml files -nox -s docfx - -# create metadata. -python3.10 -m docuploader create-metadata \ - --name=$(jq --raw-output '.name // empty' .repo-metadata.json) \ - --version=$(python3.10 setup.py --version) \ - --language=$(jq --raw-output '.language // empty' .repo-metadata.json) \ - --distribution-name=$(python3.10 setup.py --name) \ - --product-page=$(jq --raw-output '.product_documentation // empty' .repo-metadata.json) \ - --github-repository=$(jq --raw-output '.repo // empty' .repo-metadata.json) \ - --issue-tracker=$(jq --raw-output '.issue_tracker // empty' .repo-metadata.json) - -cat docs.metadata - -# Replace toc.yml template file -mv docs/templates/toc.yml docs/_build/html/docfx_yaml/toc.yml - -# upload docs -python3.10 -m docuploader upload docs/_build/html/docfx_yaml --metadata-file docs.metadata --destination-prefix docfx --staging-bucket "${V2_STAGING_BUCKET}" diff --git a/.kokoro/release-nightly.sh b/.kokoro/release-nightly.sh index 7da0881bbe..124e4b8b48 100755 --- a/.kokoro/release-nightly.sh +++ b/.kokoro/release-nightly.sh @@ -57,8 +57,7 @@ git config --global --add safe.directory "${PROJECT_ROOT}" # Workaround for older pip not able to resolve dependencies. See internal # issue 316909553. -python3.10 -m pip install pip==23.3.2 -python3.10 -m pip install --require-hashes -r .kokoro/requirements.txt +python3.10 -m pip install pip==25.0.1 # Disable buffering, so that the logs stream through. export PYTHONUNBUFFERED=1 diff --git a/.kokoro/release.sh b/.kokoro/release.sh deleted file mode 100755 index a2eae5fda1..0000000000 --- a/.kokoro/release.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -eo pipefail - -# Start the releasetool reporter -python3 -m pip install --require-hashes -r github/python-bigquery-dataframes/.kokoro/requirements.txt -python3 -m releasetool publish-reporter-script > /tmp/publisher-script; source /tmp/publisher-script - -# Disable buffering, so that the logs stream through. -export PYTHONUNBUFFERED=1 - -# Move into the package, build the distribution and upload. -TWINE_PASSWORD=$(cat "${KOKORO_KEYSTORE_DIR}/73713_google-cloud-pypi-token-keystore-3") -cd github/python-bigquery-dataframes -python3 setup.py sdist bdist_wheel -twine upload --username __token__ --password "${TWINE_PASSWORD}" dist/* diff --git a/.kokoro/release/common.cfg b/.kokoro/release/common.cfg deleted file mode 100644 index 146dd8f451..0000000000 --- a/.kokoro/release/common.cfg +++ /dev/null @@ -1,43 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -# Build logs will be here -action { - define_artifacts { - regex: "**/*sponge_log.xml" - } -} - -# Download trampoline resources. -gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" - -# Use the trampoline script to run in docker. -build_file: "python-bigquery-dataframes/.kokoro/trampoline.sh" - -# Configure the docker image for kokoro-trampoline. -env_vars: { - key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-devrel-kokoro-resources/python-multi" -} -env_vars: { - key: "TRAMPOLINE_BUILD_FILE" - value: "github/python-bigquery-dataframes/.kokoro/release.sh" -} - -# Fetch PyPI password -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "google-cloud-pypi-token-keystore-3" - } - } -} - -# Store the packages we uploaded to PyPI. That way, we have a record of exactly -# what we published, which we can use to generate SBOMs and attestations. -action { - define_artifacts { - regex: "github/python-bigquery-dataframes/**/*.tar.gz" - strip_prefix: "github/python-bigquery-dataframes" - } -} diff --git a/.kokoro/release/release.cfg b/.kokoro/release/release.cfg deleted file mode 100644 index 8f43917d92..0000000000 --- a/.kokoro/release/release.cfg +++ /dev/null @@ -1 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto \ No newline at end of file diff --git a/.kokoro/requirements.in b/.kokoro/requirements.in deleted file mode 100644 index fff4d9ce0d..0000000000 --- a/.kokoro/requirements.in +++ /dev/null @@ -1,11 +0,0 @@ -gcp-docuploader -gcp-releasetool>=2 # required for compatibility with cryptography>=42.x -importlib-metadata -typing-extensions -twine -wheel -setuptools -nox>=2022.11.21 # required to remove dependency on py -charset-normalizer<3 -click<8.1.0 -cryptography>=42.0.5 diff --git a/.kokoro/requirements.txt b/.kokoro/requirements.txt deleted file mode 100644 index 006d8ef931..0000000000 --- a/.kokoro/requirements.txt +++ /dev/null @@ -1,509 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.9 -# by the following command: -# -# pip-compile --allow-unsafe --generate-hashes requirements.in -# -argcomplete==3.5.1 \ - --hash=sha256:1a1d148bdaa3e3b93454900163403df41448a248af01b6e849edc5ac08e6c363 \ - --hash=sha256:eb1ee355aa2557bd3d0145de7b06b2a45b0ce461e1e7813f5d066039ab4177b4 - # via nox -attrs==24.2.0 \ - --hash=sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346 \ - --hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2 - # via gcp-releasetool -backports-tarfile==1.2.0 \ - --hash=sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34 \ - --hash=sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991 - # via jaraco-context -cachetools==5.5.0 \ - --hash=sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292 \ - --hash=sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a - # via google-auth -certifi==2024.8.30 \ - --hash=sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8 \ - --hash=sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9 - # via requests -cffi==1.17.1 \ - --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ - --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ - --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \ - --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \ - --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \ - --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ - --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \ - --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \ - --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \ - --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \ - --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \ - --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \ - --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ - --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \ - --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \ - --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \ - --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \ - --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \ - --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \ - --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \ - --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \ - --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \ - --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \ - --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ - --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ - --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \ - --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \ - --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \ - --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \ - --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \ - --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \ - --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \ - --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \ - --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \ - --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \ - --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \ - --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \ - --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \ - --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \ - --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \ - --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \ - --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \ - --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ - --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \ - --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \ - --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \ - --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \ - --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \ - --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \ - --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ - --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \ - --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \ - --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ - --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \ - --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ - --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \ - --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ - --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \ - --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \ - --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \ - --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \ - --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ - --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \ - --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \ - --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ - --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ - --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b - # via cryptography -charset-normalizer==2.1.1 \ - --hash=sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845 \ - --hash=sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f - # via - # -r requirements.in - # requests -click==8.0.4 \ - --hash=sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1 \ - --hash=sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb - # via - # -r requirements.in - # gcp-docuploader - # gcp-releasetool -colorlog==6.8.2 \ - --hash=sha256:3e3e079a41feb5a1b64f978b5ea4f46040a94f11f0e8bbb8261e3dbbeca64d44 \ - --hash=sha256:4dcbb62368e2800cb3c5abd348da7e53f6c362dda502ec27c560b2e58a66bd33 - # via - # gcp-docuploader - # nox -cryptography==43.0.1 \ - --hash=sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494 \ - --hash=sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806 \ - --hash=sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d \ - --hash=sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062 \ - --hash=sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2 \ - --hash=sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4 \ - --hash=sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1 \ - --hash=sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85 \ - --hash=sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84 \ - --hash=sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042 \ - --hash=sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d \ - --hash=sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962 \ - --hash=sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2 \ - --hash=sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa \ - --hash=sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d \ - --hash=sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365 \ - --hash=sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96 \ - --hash=sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47 \ - --hash=sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d \ - --hash=sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d \ - --hash=sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c \ - --hash=sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb \ - --hash=sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277 \ - --hash=sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172 \ - --hash=sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034 \ - --hash=sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a \ - --hash=sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289 - # via - # -r requirements.in - # gcp-releasetool - # secretstorage -distlib==0.3.9 \ - --hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \ - --hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403 - # via virtualenv -docutils==0.21.2 \ - --hash=sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f \ - --hash=sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2 - # via readme-renderer -filelock==3.16.1 \ - --hash=sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0 \ - --hash=sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435 - # via virtualenv -gcp-docuploader==0.6.5 \ - --hash=sha256:30221d4ac3e5a2b9c69aa52fdbef68cc3f27d0e6d0d90e220fc024584b8d2318 \ - --hash=sha256:b7458ef93f605b9d46a4bf3a8dc1755dad1f31d030c8679edf304e343b347eea - # via -r requirements.in -gcp-releasetool==2.1.1 \ - --hash=sha256:25639269f4eae510094f9dbed9894977e1966933211eb155a451deebc3fc0b30 \ - --hash=sha256:845f4ded3d9bfe8cc7fdaad789e83f4ea014affa77785259a7ddac4b243e099e - # via -r requirements.in -google-api-core==2.21.0 \ - --hash=sha256:4a152fd11a9f774ea606388d423b68aa7e6d6a0ffe4c8266f74979613ec09f81 \ - --hash=sha256:6869eacb2a37720380ba5898312af79a4d30b8bca1548fb4093e0697dc4bdf5d - # via - # google-cloud-core - # google-cloud-storage -google-auth==2.35.0 \ - --hash=sha256:25df55f327ef021de8be50bad0dfd4a916ad0de96da86cd05661c9297723ad3f \ - --hash=sha256:f4c64ed4e01e8e8b646ef34c018f8bf3338df0c8e37d8b3bba40e7f574a3278a - # via - # gcp-releasetool - # google-api-core - # google-cloud-core - # google-cloud-storage -google-cloud-core==2.4.1 \ - --hash=sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073 \ - --hash=sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61 - # via google-cloud-storage -google-cloud-storage==2.18.2 \ - --hash=sha256:97a4d45c368b7d401ed48c4fdfe86e1e1cb96401c9e199e419d289e2c0370166 \ - --hash=sha256:aaf7acd70cdad9f274d29332673fcab98708d0e1f4dceb5a5356aaef06af4d99 - # via gcp-docuploader -google-crc32c==1.6.0 \ - --hash=sha256:05e2d8c9a2f853ff116db9706b4a27350587f341eda835f46db3c0a8c8ce2f24 \ - --hash=sha256:18e311c64008f1f1379158158bb3f0c8d72635b9eb4f9545f8cf990c5668e59d \ - --hash=sha256:236c87a46cdf06384f614e9092b82c05f81bd34b80248021f729396a78e55d7e \ - --hash=sha256:35834855408429cecf495cac67ccbab802de269e948e27478b1e47dfb6465e57 \ - --hash=sha256:386122eeaaa76951a8196310432c5b0ef3b53590ef4c317ec7588ec554fec5d2 \ - --hash=sha256:40b05ab32a5067525670880eb5d169529089a26fe35dce8891127aeddc1950e8 \ - --hash=sha256:48abd62ca76a2cbe034542ed1b6aee851b6f28aaca4e6551b5599b6f3ef175cc \ - --hash=sha256:50cf2a96da226dcbff8671233ecf37bf6e95de98b2a2ebadbfdf455e6d05df42 \ - --hash=sha256:51c4f54dd8c6dfeb58d1df5e4f7f97df8abf17a36626a217f169893d1d7f3e9f \ - --hash=sha256:5bcc90b34df28a4b38653c36bb5ada35671ad105c99cfe915fb5bed7ad6924aa \ - --hash=sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b \ - --hash=sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc \ - --hash=sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760 \ - --hash=sha256:91ca8145b060679ec9176e6de4f89b07363d6805bd4760631ef254905503598d \ - --hash=sha256:a184243544811e4a50d345838a883733461e67578959ac59964e43cca2c791e7 \ - --hash=sha256:a9e4b426c3702f3cd23b933436487eb34e01e00327fac20c9aebb68ccf34117d \ - --hash=sha256:bb0966e1c50d0ef5bc743312cc730b533491d60585a9a08f897274e57c3f70e0 \ - --hash=sha256:bb8b3c75bd157010459b15222c3fd30577042a7060e29d42dabce449c087f2b3 \ - --hash=sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3 \ - --hash=sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00 \ - --hash=sha256:d2952396dc604544ea7476b33fe87faedc24d666fb0c2d5ac971a2b9576ab871 \ - --hash=sha256:d8797406499f28b5ef791f339594b0b5fdedf54e203b5066675c406ba69d705c \ - --hash=sha256:d9e9913f7bd69e093b81da4535ce27af842e7bf371cde42d1ae9e9bd382dc0e9 \ - --hash=sha256:e2806553238cd076f0a55bddab37a532b53580e699ed8e5606d0de1f856b5205 \ - --hash=sha256:ebab974b1687509e5c973b5c4b8b146683e101e102e17a86bd196ecaa4d099fc \ - --hash=sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d \ - --hash=sha256:f7a1fc29803712f80879b0806cb83ab24ce62fc8daf0569f2204a0cfd7f68ed4 - # via - # google-cloud-storage - # google-resumable-media -google-resumable-media==2.7.2 \ - --hash=sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa \ - --hash=sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0 - # via google-cloud-storage -googleapis-common-protos==1.65.0 \ - --hash=sha256:2972e6c496f435b92590fd54045060867f3fe9be2c82ab148fc8885035479a63 \ - --hash=sha256:334a29d07cddc3aa01dee4988f9afd9b2916ee2ff49d6b757155dc0d197852c0 - # via google-api-core -idna==3.10 \ - --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ - --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 - # via requests -importlib-metadata==8.5.0 \ - --hash=sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b \ - --hash=sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7 - # via - # -r requirements.in - # keyring - # twine -jaraco-classes==3.4.0 \ - --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ - --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 - # via keyring -jaraco-context==6.0.1 \ - --hash=sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3 \ - --hash=sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4 - # via keyring -jaraco-functools==4.1.0 \ - --hash=sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d \ - --hash=sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649 - # via keyring -jeepney==0.8.0 \ - --hash=sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806 \ - --hash=sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755 - # via - # keyring - # secretstorage -jinja2==3.1.4 \ - --hash=sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369 \ - --hash=sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d - # via gcp-releasetool -keyring==25.4.1 \ - --hash=sha256:5426f817cf7f6f007ba5ec722b1bcad95a75b27d780343772ad76b17cb47b0bf \ - --hash=sha256:b07ebc55f3e8ed86ac81dd31ef14e81ace9dd9c3d4b5d77a6e9a2016d0d71a1b - # via - # gcp-releasetool - # twine -markdown-it-py==3.0.0 \ - --hash=sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1 \ - --hash=sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb - # via rich -markupsafe==3.0.1 \ - --hash=sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396 \ - --hash=sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38 \ - --hash=sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a \ - --hash=sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8 \ - --hash=sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b \ - --hash=sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad \ - --hash=sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a \ - --hash=sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a \ - --hash=sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da \ - --hash=sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6 \ - --hash=sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8 \ - --hash=sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344 \ - --hash=sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a \ - --hash=sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8 \ - --hash=sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5 \ - --hash=sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7 \ - --hash=sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170 \ - --hash=sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132 \ - --hash=sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9 \ - --hash=sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd \ - --hash=sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9 \ - --hash=sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346 \ - --hash=sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc \ - --hash=sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589 \ - --hash=sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5 \ - --hash=sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915 \ - --hash=sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295 \ - --hash=sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453 \ - --hash=sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea \ - --hash=sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b \ - --hash=sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d \ - --hash=sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b \ - --hash=sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4 \ - --hash=sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b \ - --hash=sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7 \ - --hash=sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf \ - --hash=sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f \ - --hash=sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91 \ - --hash=sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd \ - --hash=sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50 \ - --hash=sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b \ - --hash=sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583 \ - --hash=sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a \ - --hash=sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984 \ - --hash=sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c \ - --hash=sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c \ - --hash=sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25 \ - --hash=sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa \ - --hash=sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4 \ - --hash=sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3 \ - --hash=sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97 \ - --hash=sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1 \ - --hash=sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd \ - --hash=sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772 \ - --hash=sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a \ - --hash=sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729 \ - --hash=sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca \ - --hash=sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6 \ - --hash=sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635 \ - --hash=sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b \ - --hash=sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f - # via jinja2 -mdurl==0.1.2 \ - --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ - --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba - # via markdown-it-py -more-itertools==10.5.0 \ - --hash=sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef \ - --hash=sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6 - # via - # jaraco-classes - # jaraco-functools -nh3==0.2.18 \ - --hash=sha256:0411beb0589eacb6734f28d5497ca2ed379eafab8ad8c84b31bb5c34072b7164 \ - --hash=sha256:14c5a72e9fe82aea5fe3072116ad4661af5cf8e8ff8fc5ad3450f123e4925e86 \ - --hash=sha256:19aaba96e0f795bd0a6c56291495ff59364f4300d4a39b29a0abc9cb3774a84b \ - --hash=sha256:34c03fa78e328c691f982b7c03d4423bdfd7da69cd707fe572f544cf74ac23ad \ - --hash=sha256:36c95d4b70530b320b365659bb5034341316e6a9b30f0b25fa9c9eff4c27a204 \ - --hash=sha256:3a157ab149e591bb638a55c8c6bcb8cdb559c8b12c13a8affaba6cedfe51713a \ - --hash=sha256:42c64511469005058cd17cc1537578eac40ae9f7200bedcfd1fc1a05f4f8c200 \ - --hash=sha256:5f36b271dae35c465ef5e9090e1fdaba4a60a56f0bb0ba03e0932a66f28b9189 \ - --hash=sha256:6955369e4d9f48f41e3f238a9e60f9410645db7e07435e62c6a9ea6135a4907f \ - --hash=sha256:7b7c2a3c9eb1a827d42539aa64091640bd275b81e097cd1d8d82ef91ffa2e811 \ - --hash=sha256:8ce0f819d2f1933953fca255db2471ad58184a60508f03e6285e5114b6254844 \ - --hash=sha256:94a166927e53972a9698af9542ace4e38b9de50c34352b962f4d9a7d4c927af4 \ - --hash=sha256:a7f1b5b2c15866f2db413a3649a8fe4fd7b428ae58be2c0f6bca5eefd53ca2be \ - --hash=sha256:c8b3a1cebcba9b3669ed1a84cc65bf005728d2f0bc1ed2a6594a992e817f3a50 \ - --hash=sha256:de3ceed6e661954871d6cd78b410213bdcb136f79aafe22aa7182e028b8c7307 \ - --hash=sha256:f0eca9ca8628dbb4e916ae2491d72957fdd35f7a5d326b7032a345f111ac07fe - # via readme-renderer -nox==2024.10.9 \ - --hash=sha256:1d36f309a0a2a853e9bccb76bbef6bb118ba92fa92674d15604ca99adeb29eab \ - --hash=sha256:7aa9dc8d1c27e9f45ab046ffd1c3b2c4f7c91755304769df231308849ebded95 - # via -r requirements.in -packaging==24.1 \ - --hash=sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002 \ - --hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 - # via - # gcp-releasetool - # nox -pkginfo==1.10.0 \ - --hash=sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297 \ - --hash=sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097 - # via twine -platformdirs==4.3.6 \ - --hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \ - --hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb - # via virtualenv -proto-plus==1.24.0 \ - --hash=sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445 \ - --hash=sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12 - # via google-api-core -protobuf==5.28.2 \ - --hash=sha256:2c69461a7fcc8e24be697624c09a839976d82ae75062b11a0972e41fd2cd9132 \ - --hash=sha256:35cfcb15f213449af7ff6198d6eb5f739c37d7e4f1c09b5d0641babf2cc0c68f \ - --hash=sha256:52235802093bd8a2811abbe8bf0ab9c5f54cca0a751fdd3f6ac2a21438bffece \ - --hash=sha256:59379674ff119717404f7454647913787034f03fe7049cbef1d74a97bb4593f0 \ - --hash=sha256:5e8a95246d581eef20471b5d5ba010d55f66740942b95ba9b872d918c459452f \ - --hash=sha256:87317e9bcda04a32f2ee82089a204d3a2f0d3c8aeed16568c7daf4756e4f1fe0 \ - --hash=sha256:8ddc60bf374785fb7cb12510b267f59067fa10087325b8e1855b898a0d81d276 \ - --hash=sha256:a8b9403fc70764b08d2f593ce44f1d2920c5077bf7d311fefec999f8c40f78b7 \ - --hash=sha256:c0ea0123dac3399a2eeb1a1443d82b7afc9ff40241433296769f7da42d142ec3 \ - --hash=sha256:ca53faf29896c526863366a52a8f4d88e69cd04ec9571ed6082fa117fac3ab36 \ - --hash=sha256:eeea10f3dc0ac7e6b4933d32db20662902b4ab81bf28df12218aa389e9c2102d - # via - # gcp-docuploader - # gcp-releasetool - # google-api-core - # googleapis-common-protos - # proto-plus -pyasn1==0.6.1 \ - --hash=sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 \ - --hash=sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034 - # via - # pyasn1-modules - # rsa -pyasn1-modules==0.4.1 \ - --hash=sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd \ - --hash=sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c - # via google-auth -pycparser==2.22 \ - --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ - --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc - # via cffi -pygments==2.18.0 \ - --hash=sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199 \ - --hash=sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a - # via - # readme-renderer - # rich -pyjwt==2.9.0 \ - --hash=sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850 \ - --hash=sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c - # via gcp-releasetool -pyperclip==1.9.0 \ - --hash=sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310 - # via gcp-releasetool -python-dateutil==2.9.0.post0 \ - --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ - --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 - # via gcp-releasetool -readme-renderer==44.0 \ - --hash=sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151 \ - --hash=sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1 - # via twine -requests==2.32.3 \ - --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ - --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 - # via - # gcp-releasetool - # google-api-core - # google-cloud-storage - # requests-toolbelt - # twine -requests-toolbelt==1.0.0 \ - --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ - --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 - # via twine -rfc3986==2.0.0 \ - --hash=sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd \ - --hash=sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c - # via twine -rich==13.9.2 \ - --hash=sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c \ - --hash=sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1 - # via twine -rsa==4.9 \ - --hash=sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7 \ - --hash=sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21 - # via google-auth -secretstorage==3.3.3 \ - --hash=sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77 \ - --hash=sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99 - # via keyring -six==1.16.0 \ - --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ - --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 - # via - # gcp-docuploader - # python-dateutil -tomli==2.0.2 \ - --hash=sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38 \ - --hash=sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed - # via nox -twine==5.1.1 \ - --hash=sha256:215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997 \ - --hash=sha256:9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db - # via -r requirements.in -typing-extensions==4.12.2 \ - --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ - --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 - # via - # -r requirements.in - # rich -urllib3==2.2.3 \ - --hash=sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac \ - --hash=sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9 - # via - # requests - # twine -virtualenv==20.26.6 \ - --hash=sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48 \ - --hash=sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2 - # via nox -wheel==0.44.0 \ - --hash=sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f \ - --hash=sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49 - # via -r requirements.in -zipp==3.20.2 \ - --hash=sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350 \ - --hash=sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29 - # via importlib-metadata - -# The following packages are considered to be unsafe in a requirements file: -setuptools==75.1.0 \ - --hash=sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2 \ - --hash=sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538 - # via -r requirements.in diff --git a/.librarian/state.yaml b/.librarian/state.yaml new file mode 100644 index 0000000000..99fac71a63 --- /dev/null +++ b/.librarian/state.yaml @@ -0,0 +1,11 @@ +image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620 +libraries: + - id: bigframes + version: 2.31.0 + last_generated_commit: "" + apis: [] + source_roots: + - . + preserve_regex: [] + remove_regex: [] + tag_format: v{version} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d11c951a1..096bdeb2a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,12 +14,17 @@ # # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks +default_install_hook_types: +- pre-commit +- commit-msg + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks: - id: trailing-whitespace - id: end-of-file-fixer + exclude: "^tests/unit/core/compile/sqlglot/.*snapshots" - id: check-yaml - repo: https://github.com/pycqa/isort rev: 5.12.0 @@ -31,13 +36,24 @@ repos: hooks: - id: black - repo: https://github.com/pycqa/flake8 - rev: 6.1.0 + rev: 7.1.2 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.0 + rev: v1.15.0 hooks: - id: mypy - additional_dependencies: [types-requests, types-tabulate, pandas-stubs] + additional_dependencies: [types-requests, types-tabulate, types-PyYAML, pandas-stubs<=2.2.3.241126] exclude: "^third_party" args: ["--check-untyped-defs", "--explicit-package-bases", "--ignore-missing-imports"] +- repo: https://github.com/biomejs/pre-commit + rev: v2.2.4 + hooks: + - id: biome-check + files: '\.(js|css)$' +- repo: https://github.com/compilerla/conventional-pre-commit + rev: fdde5f0251edbfc554795afdd6df71826d6602f3 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [] diff --git a/CHANGELOG.md b/CHANGELOG.md index b301f85a6a..6867151bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,1038 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.31.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.30.0...bigframes-v2.31.0) (2025-12-10) + + +### Features + +* add `bigframes.bigquery.ml` methods (#2300) ([719b278c844ca80c1bec741873b30a9ee4fd6c56](https://github.com/googleapis/google-cloud-python/commit/719b278c844ca80c1bec741873b30a9ee4fd6c56)) +* add 'weekday' property to DatatimeMethod (#2304) ([fafd7c732d434eca3f8b5d849a87149f106e3d5d](https://github.com/googleapis/google-cloud-python/commit/fafd7c732d434eca3f8b5d849a87149f106e3d5d)) + + +### Bug Fixes + +* cache DataFrames to temp tables in bigframes.bigquery.ml methods to avoid time travel (#2318) ([d99383195ac3f1683842cfe472cca5a914b04d8e](https://github.com/googleapis/google-cloud-python/commit/d99383195ac3f1683842cfe472cca5a914b04d8e)) + +## [2.30.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.29.0...bigframes-v2.30.0) (2025-12-03) + + +### Documentation + +* Add Google Analytics configuration to conf.py (#2301) ([0b266da10f4d3d0ef9b4dd71ddadebfc7d5064ca](https://github.com/googleapis/google-cloud-python/commit/0b266da10f4d3d0ef9b4dd71ddadebfc7d5064ca)) +* fix LogisticRegression docs rendering (#2295) ([32e531343c764156b45c6fb9de49793d26c19f02](https://github.com/googleapis/google-cloud-python/commit/32e531343c764156b45c6fb9de49793d26c19f02)) +* update API reference to new `dataframes.bigquery.dev` location (#2293) ([da064397acd2358c16fdd9659edf23afde5c882a](https://github.com/googleapis/google-cloud-python/commit/da064397acd2358c16fdd9659edf23afde5c882a)) +* use autosummary to split documentation pages (#2251) ([f7fd2d20896fe3e0e210c3833b6a4c3913270ebc](https://github.com/googleapis/google-cloud-python/commit/f7fd2d20896fe3e0e210c3833b6a4c3913270ebc)) +* update docs and tests for Gemini 2.5 models (#2279) ([08c0c0c8fe8f806f6224dc403a3f1d4db708573a](https://github.com/googleapis/google-cloud-python/commit/08c0c0c8fe8f806f6224dc403a3f1d4db708573a)) + + +### Features + +* Allow drop_duplicates over unordered dataframe (#2303) ([52665fa57ef13c58254bfc8736afcc521f7f0f11](https://github.com/googleapis/google-cloud-python/commit/52665fa57ef13c58254bfc8736afcc521f7f0f11)) +* Add agg/aggregate methods to windows (#2288) ([c4cb39dcbd388356f5f1c48ff28b19b79b996485](https://github.com/googleapis/google-cloud-python/commit/c4cb39dcbd388356f5f1c48ff28b19b79b996485)) +* Implement single-column sorting for interactive table widget (#2255) ([d1ecc61bf448651a0cca0fc760673da54f5c2183](https://github.com/googleapis/google-cloud-python/commit/d1ecc61bf448651a0cca0fc760673da54f5c2183)) +* add bigquery.json_keys (#2286) ([b487cf1f6ecacb1ee3b35ffdd934221516bbd558](https://github.com/googleapis/google-cloud-python/commit/b487cf1f6ecacb1ee3b35ffdd934221516bbd558)) +* use end user credentials for `bigframes.bigquery.ai` functions when `connection_id` is not present (#2272) ([7c062a68c6a3c9737865985b4f1fd80117490c73](https://github.com/googleapis/google-cloud-python/commit/7c062a68c6a3c9737865985b4f1fd80117490c73)) +* pivot_table supports fill_value arg (#2257) ([8f490e68a9a2584236486060ad3b55923781d975](https://github.com/googleapis/google-cloud-python/commit/8f490e68a9a2584236486060ad3b55923781d975)) +* Support mixed scalar-analytic expressions (#2239) ([20ab469d29767a2f04fe02aa66797893ecd1c539](https://github.com/googleapis/google-cloud-python/commit/20ab469d29767a2f04fe02aa66797893ecd1c539)) +* Support builtins funcs for df.agg (#2256) ([956a5b00dff55b73e3cbebb4e6e81672680f1f63](https://github.com/googleapis/google-cloud-python/commit/956a5b00dff55b73e3cbebb4e6e81672680f1f63)) +* Preserve source names better for more readable sql (#2243) ([64995d659837a8576b2ee9335921904e577c7014](https://github.com/googleapis/google-cloud-python/commit/64995d659837a8576b2ee9335921904e577c7014)) +* Add bigframes.pandas.crosstab (#2231) ([c62e5535ed4c19b6d65f9a46cb1531e8099621b2](https://github.com/googleapis/google-cloud-python/commit/c62e5535ed4c19b6d65f9a46cb1531e8099621b2)) + + +### Bug Fixes + +* Update max_instances default to reflect actual value (#2302) ([4489687eafc9a1ea1b985600010296a4245cef94](https://github.com/googleapis/google-cloud-python/commit/4489687eafc9a1ea1b985600010296a4245cef94)) +* Fix issue with stream upload batch size upload limit (#2290) ([6cdf64b0674d0e673f86362032d549316850837b](https://github.com/googleapis/google-cloud-python/commit/6cdf64b0674d0e673f86362032d549316850837b)) +* Pass credentials properly for read api instantiation (#2280) ([3e3fe259567d249d91f90786a577b05577e2b9fd](https://github.com/googleapis/google-cloud-python/commit/3e3fe259567d249d91f90786a577b05577e2b9fd)) +* Improve Anywidget pagination and display for unknown row counts (#2258) ([508deae5869e06cdad7bb94537c9c58d8f083d86](https://github.com/googleapis/google-cloud-python/commit/508deae5869e06cdad7bb94537c9c58d8f083d86)) +* calling info() on empty dataframes no longer leads to errors (#2267) ([95a83f7774766cd19cb583dfaa3417882b5c9b1e](https://github.com/googleapis/google-cloud-python/commit/95a83f7774766cd19cb583dfaa3417882b5c9b1e)) +* do not warn with DefaultIndexWarning in partial ordering mode (#2230) ([cc2dbae684103a21fe8838468f7eb8267188780d](https://github.com/googleapis/google-cloud-python/commit/cc2dbae684103a21fe8838468f7eb8267188780d)) + +## [2.29.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.28.0...v2.29.0) (2025-11-10) + + +### Features + +* Add bigframes.bigquery.st_regionstats to join raster data from Earth Engine ([#2228](https://github.com/googleapis/python-bigquery-dataframes/issues/2228)) ([10ec52f](https://github.com/googleapis/python-bigquery-dataframes/commit/10ec52f30a0a9c61b9eda9cf4f9bd6aa0cd95db5)) +* Add DataFrame.resample and Series.resample ([#2213](https://github.com/googleapis/python-bigquery-dataframes/issues/2213)) ([c9ca02c](https://github.com/googleapis/python-bigquery-dataframes/commit/c9ca02c5194c8b8e9b940eddd2224efd2ff0d5d9)) +* SQL Cell no longer escapes formatted string values ([#2245](https://github.com/googleapis/python-bigquery-dataframes/issues/2245)) ([d2d38f9](https://github.com/googleapis/python-bigquery-dataframes/commit/d2d38f94ed8333eae6f9cff3833177756eefe85a)) +* Support left_index and right_index for merge ([#2220](https://github.com/googleapis/python-bigquery-dataframes/issues/2220)) ([da9ba26](https://github.com/googleapis/python-bigquery-dataframes/commit/da9ba267812c01ffa6fa0b09943d7a4c63b8f187)) + + +### Bug Fixes + +* Correctly iterate over null struct values in ManagedArrowTable ([#2209](https://github.com/googleapis/python-bigquery-dataframes/issues/2209)) ([12e04d5](https://github.com/googleapis/python-bigquery-dataframes/commit/12e04d55f0d6aef1297b7ca773935aecf3313ee7)) +* Simplify UnsupportedTypeError message ([#2212](https://github.com/googleapis/python-bigquery-dataframes/issues/2212)) ([6c9a18d](https://github.com/googleapis/python-bigquery-dataframes/commit/6c9a18d7e67841c6fe6c1c6f34f80b950815141f)) +* Support results with STRUCT and ARRAY columns containing JSON subfields in `to_pandas_batches()` ([#2216](https://github.com/googleapis/python-bigquery-dataframes/issues/2216)) ([3d8b17f](https://github.com/googleapis/python-bigquery-dataframes/commit/3d8b17fa5eb9bbfc9e151031141a419f2dc3acb4)) + + +### Documentation + +* Switch API reference docs to pydata theme ([#2237](https://github.com/googleapis/python-bigquery-dataframes/issues/2237)) ([9b86dcf](https://github.com/googleapis/python-bigquery-dataframes/commit/9b86dcf87929648bf5ab565dfd46a23b639f01ac)) +* Update notebook for JSON subfields support in to_pandas_batches() ([#2138](https://github.com/googleapis/python-bigquery-dataframes/issues/2138)) ([5663d2a](https://github.com/googleapis/python-bigquery-dataframes/commit/5663d2a18064589596558af109e915f87d426eb0)) + +## [2.28.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.27.0...v2.28.0) (2025-11-03) + + +### Features + +* Add bigframes.bigquery.st_simplify ([#2210](https://github.com/googleapis/python-bigquery-dataframes/issues/2210)) ([ecee2bc](https://github.com/googleapis/python-bigquery-dataframes/commit/ecee2bc6ada0bc968fc56ed7194dc8c043547e93)) +* Add Series.dt.day_name ([#2218](https://github.com/googleapis/python-bigquery-dataframes/issues/2218)) ([5e006e4](https://github.com/googleapis/python-bigquery-dataframes/commit/5e006e404b65c32e5b1d342ebfcfce59ee592c8c)) +* Polars engine supports std, var ([#2215](https://github.com/googleapis/python-bigquery-dataframes/issues/2215)) ([ef5e83a](https://github.com/googleapis/python-bigquery-dataframes/commit/ef5e83acedf005cbe1e6ad174bec523ac50517d7)) +* Support INFORMATION_SCHEMA views in `read_gbq` ([#1895](https://github.com/googleapis/python-bigquery-dataframes/issues/1895)) ([d97cafc](https://github.com/googleapis/python-bigquery-dataframes/commit/d97cafcb5921fca2351b18011b0e54e2631cc53d)) +* Support some python standard lib callables in apply/combine ([#2187](https://github.com/googleapis/python-bigquery-dataframes/issues/2187)) ([86a2756](https://github.com/googleapis/python-bigquery-dataframes/commit/86a27564b48b854a32b3d11cd2105aa0fa496279)) + + +### Bug Fixes + +* Correct connection normalization in blob system tests ([#2222](https://github.com/googleapis/python-bigquery-dataframes/issues/2222)) ([a0e1e50](https://github.com/googleapis/python-bigquery-dataframes/commit/a0e1e50e47c758bdceb54d04180ed36b35cf2e35)) +* Improve error handling in blob operations ([#2194](https://github.com/googleapis/python-bigquery-dataframes/issues/2194)) ([d410046](https://github.com/googleapis/python-bigquery-dataframes/commit/d4100466612df0523d01ed01ca1e115dabd6ef45)) +* Resolve AttributeError in TableWidget and improve initialization ([#1937](https://github.com/googleapis/python-bigquery-dataframes/issues/1937)) ([4c4c9b1](https://github.com/googleapis/python-bigquery-dataframes/commit/4c4c9b14657b7cda1940ef39e7d4db20a9ff5308)) + + +### Documentation + +* Update bq_dataframes_llm_output_schema.ipynb ([#2004](https://github.com/googleapis/python-bigquery-dataframes/issues/2004)) ([316ba9f](https://github.com/googleapis/python-bigquery-dataframes/commit/316ba9f557d792117d5a7845d7567498f78dd513)) + +## [2.27.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.26.0...v2.27.0) (2025-10-24) + + +### Features + +* Add __abs__ to dataframe ([#2186](https://github.com/googleapis/python-bigquery-dataframes/issues/2186)) ([c331dfe](https://github.com/googleapis/python-bigquery-dataframes/commit/c331dfed59174962fbdc8ace175dd00fcc3d5d50)) +* Add df.groupby().corr()/cov() support ([#2190](https://github.com/googleapis/python-bigquery-dataframes/issues/2190)) ([ccd7c07](https://github.com/googleapis/python-bigquery-dataframes/commit/ccd7c0774a65d09e6cf31d2b62d0bc64bd7c4248)) +* Add str accessor to index ([#2179](https://github.com/googleapis/python-bigquery-dataframes/issues/2179)) ([cd87ce0](https://github.com/googleapis/python-bigquery-dataframes/commit/cd87ce0d504747f44d1b5a55f869a2e0fca6df17)) +* Add support for `np.isnan` and `np.isfinite` ufuncs ([#2188](https://github.com/googleapis/python-bigquery-dataframes/issues/2188)) ([68723bc](https://github.com/googleapis/python-bigquery-dataframes/commit/68723bc1f08013e43a8b11752f908bf8fd6d51f5)) +* Include local data bytes in the dry run report when available ([#2185](https://github.com/googleapis/python-bigquery-dataframes/issues/2185)) ([ee2c40c](https://github.com/googleapis/python-bigquery-dataframes/commit/ee2c40c6789535e259fb6a9774831d6913d16212)) +* Support len() on Groupby objects ([#2183](https://github.com/googleapis/python-bigquery-dataframes/issues/2183)) ([4191821](https://github.com/googleapis/python-bigquery-dataframes/commit/4191821b0976281a96c8965336ef51f061b0c481)) +* Support pa.json_(pa.string()) in struct/list if available ([#2180](https://github.com/googleapis/python-bigquery-dataframes/issues/2180)) ([5ec3cc0](https://github.com/googleapis/python-bigquery-dataframes/commit/5ec3cc0298c7a6195d5bd12a08d996e7df57fc5f)) + + +### Documentation + +* Update AI operators deprecation notice ([#2182](https://github.com/googleapis/python-bigquery-dataframes/issues/2182)) ([2c50310](https://github.com/googleapis/python-bigquery-dataframes/commit/2c503107e17c59232b14b0d7bc40c350bb087d6f)) + +## [2.26.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.25.0...v2.26.0) (2025-10-17) + + +### ⚠ BREAKING CHANGES + +* turn Series.struct.dtypes into a property to match pandas (https://github.com/googleapis/python-bigquery-dataframes/pull/2169) + +### Features + +* Add df.sort_index(axis=1) ([#2173](https://github.com/googleapis/python-bigquery-dataframes/issues/2173)) ([ebf95e3](https://github.com/googleapis/python-bigquery-dataframes/commit/ebf95e3ef77822650f2e190df7b868011174d412)) +* Enhanced multimodal error handling with verbose mode for blob image functions ([#2024](https://github.com/googleapis/python-bigquery-dataframes/issues/2024)) ([f9e28fe](https://github.com/googleapis/python-bigquery-dataframes/commit/f9e28fe3f883cc4d486178fe241bc8b76473700f)) +* Implement cos, sin, and log operations for polars compiler ([#2170](https://github.com/googleapis/python-bigquery-dataframes/issues/2170)) ([5613e44](https://github.com/googleapis/python-bigquery-dataframes/commit/5613e4454f198691209ec28e58ce652104ac2de4)) +* Make `all` and `any` compatible with integer columns on Polars session ([#2154](https://github.com/googleapis/python-bigquery-dataframes/issues/2154)) ([6353d6e](https://github.com/googleapis/python-bigquery-dataframes/commit/6353d6ecad5139551ef68376c08f8749dd440014)) + + +### Bug Fixes + +* `blob.display()` shows <NA> for null rows ([#2158](https://github.com/googleapis/python-bigquery-dataframes/issues/2158)) ([ddb4df0](https://github.com/googleapis/python-bigquery-dataframes/commit/ddb4df0dd991bef051e2a365c5cacf502803014d)) +* Turn Series.struct.dtypes into a property to match pandas (https://github.com/googleapis/python-bigquery-dataframes/pull/2169) ([62f7e9f](https://github.com/googleapis/python-bigquery-dataframes/commit/62f7e9f38f26b6eb549219a4cbf2c9b9023c9c35)) + + +### Documentation + +* Clarify that only NULL values are handled by fillna/isna, not NaN ([#2176](https://github.com/googleapis/python-bigquery-dataframes/issues/2176)) ([8f27e73](https://github.com/googleapis/python-bigquery-dataframes/commit/8f27e737fc78a182238090025d09479fac90b326)) +* Remove import bigframes.pandas as bpd boilerplate from many samples ([#2147](https://github.com/googleapis/python-bigquery-dataframes/issues/2147)) ([1a01ab9](https://github.com/googleapis/python-bigquery-dataframes/commit/1a01ab97f103361f489f37b0af8c4b4d7806707c)) + +## [2.25.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.24.0...v2.25.0) (2025-10-13) + + +### Features + +* Add barh, pie plot types ([#2146](https://github.com/googleapis/python-bigquery-dataframes/issues/2146)) ([5cc3c5b](https://github.com/googleapis/python-bigquery-dataframes/commit/5cc3c5b1391a7dfa062b1d77f001726b013f6337)) +* Add Index.__eq__ for consts, aligned objects ([#2141](https://github.com/googleapis/python-bigquery-dataframes/issues/2141)) ([8514200](https://github.com/googleapis/python-bigquery-dataframes/commit/85142008ec895fa078d192bbab942d0257f70df3)) +* Add output_schema parameter to ai.generate() ([#2139](https://github.com/googleapis/python-bigquery-dataframes/issues/2139)) ([ef0b0b7](https://github.com/googleapis/python-bigquery-dataframes/commit/ef0b0b73843da2a93baf08e4cd5457fbb590b89c)) +* Create session-scoped `cut`, `DataFrame`, `MultiIndex`, `Index`, `Series`, `to_datetime`, and `to_timedelta` methods ([#2157](https://github.com/googleapis/python-bigquery-dataframes/issues/2157)) ([5e1e809](https://github.com/googleapis/python-bigquery-dataframes/commit/5e1e8098ecf212c91d73fa80d722d1cb3e46668b)) +* Replace ML.GENERATE_TEXT with AI.GENERATE for audio transcription ([#2151](https://github.com/googleapis/python-bigquery-dataframes/issues/2151)) ([a410d0a](https://github.com/googleapis/python-bigquery-dataframes/commit/a410d0ae43ef3b053b650804156eda0b1f569da9)) +* Support string literal inputs for AI functions ([#2152](https://github.com/googleapis/python-bigquery-dataframes/issues/2152)) ([7600001](https://github.com/googleapis/python-bigquery-dataframes/commit/760000122dc190ac8a3303234cf4cbee1bbb9493)) + + +### Bug Fixes + +* Address typo in error message ([#2142](https://github.com/googleapis/python-bigquery-dataframes/issues/2142)) ([cdf2dd5](https://github.com/googleapis/python-bigquery-dataframes/commit/cdf2dd55a0c03da50ab92de09788cafac0abf6f6)) +* Avoid possible circular imports in global session ([#2115](https://github.com/googleapis/python-bigquery-dataframes/issues/2115)) ([095c0b8](https://github.com/googleapis/python-bigquery-dataframes/commit/095c0b85a25a2e51087880909597cc62a0341c93)) +* Fix too many cluster columns requested by caching ([#2155](https://github.com/googleapis/python-bigquery-dataframes/issues/2155)) ([35c1c33](https://github.com/googleapis/python-bigquery-dataframes/commit/35c1c33b85d1b92e402aab73677df3ffe43a51b4)) +* Show progress even in job optional queries ([#2119](https://github.com/googleapis/python-bigquery-dataframes/issues/2119)) ([1f48d3a](https://github.com/googleapis/python-bigquery-dataframes/commit/1f48d3a62e7e6dac4acb39e911daf766b8e2fe62)) +* Yield row count from read session if otherwise unknown ([#2148](https://github.com/googleapis/python-bigquery-dataframes/issues/2148)) ([8997d4d](https://github.com/googleapis/python-bigquery-dataframes/commit/8997d4d7d9965e473195f98c550c80657035b7e1)) + + +### Documentation + +* Add a brief intro notebook for bbq AI functions ([#2150](https://github.com/googleapis/python-bigquery-dataframes/issues/2150)) ([1f434fb](https://github.com/googleapis/python-bigquery-dataframes/commit/1f434fb5c7c00601654b3ab19c6ad7fceb258bd6)) +* Fix ai function related docs ([#2149](https://github.com/googleapis/python-bigquery-dataframes/issues/2149)) ([93a0749](https://github.com/googleapis/python-bigquery-dataframes/commit/93a0749392b84f27162654fe5ea5baa329a23f99)) +* Remove progress bar from getting started template ([#2143](https://github.com/googleapis/python-bigquery-dataframes/issues/2143)) ([d13abad](https://github.com/googleapis/python-bigquery-dataframes/commit/d13abadbcd68d03997e8dc11bb7a2b14bbd57fcc)) + +## [2.24.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.23.0...v2.24.0) (2025-10-07) + + +### Features + +* Add ai.classify() to bigframes.bigquery package ([#2137](https://github.com/googleapis/python-bigquery-dataframes/issues/2137)) ([56e5033](https://github.com/googleapis/python-bigquery-dataframes/commit/56e50331d198b7f517f85695c208f893ab9389d2)) +* Add ai.generate() to bigframes.bigquery module ([#2128](https://github.com/googleapis/python-bigquery-dataframes/issues/2128)) ([3810452](https://github.com/googleapis/python-bigquery-dataframes/commit/3810452f16d8d6c9d3eb9075f1537177d98b4725)) +* Add ai.if_() and ai.score() to bigframes.bigquery package ([#2132](https://github.com/googleapis/python-bigquery-dataframes/issues/2132)) ([32502f4](https://github.com/googleapis/python-bigquery-dataframes/commit/32502f4195306d262788f39d1ab4206fc84ae50e)) + + +### Bug Fixes + +* Fix internal type errors with temporal accessors ([#2125](https://github.com/googleapis/python-bigquery-dataframes/issues/2125)) ([c390da1](https://github.com/googleapis/python-bigquery-dataframes/commit/c390da11b7c2aa710bc2fbc692efb9f06059e4c4)) +* Fix row count local execution bug ([#2133](https://github.com/googleapis/python-bigquery-dataframes/issues/2133)) ([ece0762](https://github.com/googleapis/python-bigquery-dataframes/commit/ece07623e354a1dde2bd37020349e13f682e863f)) +* Join on, how args are now positional ([#2140](https://github.com/googleapis/python-bigquery-dataframes/issues/2140)) ([b711815](https://github.com/googleapis/python-bigquery-dataframes/commit/b7118152bfecc6ecf67aa4df23ec3f0a2b08aa30)) +* Only show JSON dtype warning when accessing dtypes directly ([#2136](https://github.com/googleapis/python-bigquery-dataframes/issues/2136)) ([eca22ee](https://github.com/googleapis/python-bigquery-dataframes/commit/eca22ee3104104cea96189391e527cad09bd7509)) +* Remove noisy AmbiguousWindowWarning from partial ordering mode ([#2129](https://github.com/googleapis/python-bigquery-dataframes/issues/2129)) ([4607f86](https://github.com/googleapis/python-bigquery-dataframes/commit/4607f86ebd77b916aafc37f69725b676e203b332)) + + +### Performance Improvements + +* Scale read stream workers to cpu count ([#2135](https://github.com/googleapis/python-bigquery-dataframes/issues/2135)) ([67e46cd](https://github.com/googleapis/python-bigquery-dataframes/commit/67e46cd47933b84b55808003ed344b559e47c498)) + +## [2.23.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.22.0...v2.23.0) (2025-09-29) + + +### Features + +* Add ai.generate_double to bigframes.bigquery package ([#2111](https://github.com/googleapis/python-bigquery-dataframes/issues/2111)) ([6b8154c](https://github.com/googleapis/python-bigquery-dataframes/commit/6b8154c578bb1a276e9cf8fe494d91f8cd6260f2)) + + +### Bug Fixes + +* Prevent invalid syntax for no-op .replace ops ([#2112](https://github.com/googleapis/python-bigquery-dataframes/issues/2112)) ([c311876](https://github.com/googleapis/python-bigquery-dataframes/commit/c311876b2adbc0b66ae5e463c6e56466c6a6a495)) + + +### Documentation + +* Add timedelta notebook sample ([#2124](https://github.com/googleapis/python-bigquery-dataframes/issues/2124)) ([d1a9888](https://github.com/googleapis/python-bigquery-dataframes/commit/d1a9888a2b47de6aca5dddc94d0c8f280344b58a)) + +## [2.22.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.21.0...v2.22.0) (2025-09-25) + + +### Features + +* Add `GroupBy.__iter__` ([#1394](https://github.com/googleapis/python-bigquery-dataframes/issues/1394)) ([c56a78c](https://github.com/googleapis/python-bigquery-dataframes/commit/c56a78cd509a535d4998d5b9a99ec3ecd334b883)) +* Add ai.generate_int to bigframes.bigquery package ([#2109](https://github.com/googleapis/python-bigquery-dataframes/issues/2109)) ([af6b862](https://github.com/googleapis/python-bigquery-dataframes/commit/af6b862de5c3921684210ec169338815f45b19dd)) +* Add Groupby.describe() ([#2088](https://github.com/googleapis/python-bigquery-dataframes/issues/2088)) ([328a765](https://github.com/googleapis/python-bigquery-dataframes/commit/328a765e746138806a021bea22475e8c03512aeb)) +* Implement `Index.to_list()` ([#2106](https://github.com/googleapis/python-bigquery-dataframes/issues/2106)) ([60056ca](https://github.com/googleapis/python-bigquery-dataframes/commit/60056ca06511f99092647fe55fc02eeab486b4ca)) +* Implement inplace parameter for `DataFrame.drop` ([#2105](https://github.com/googleapis/python-bigquery-dataframes/issues/2105)) ([3487f13](https://github.com/googleapis/python-bigquery-dataframes/commit/3487f13d12e34999b385c2e11551b5e27bfbf4ff)) +* Support callable for series map method ([#2100](https://github.com/googleapis/python-bigquery-dataframes/issues/2100)) ([ac25618](https://github.com/googleapis/python-bigquery-dataframes/commit/ac25618feed2da11fe4fb85058d498d262c085c0)) +* Support df.info() with null index ([#2094](https://github.com/googleapis/python-bigquery-dataframes/issues/2094)) ([fb81eea](https://github.com/googleapis/python-bigquery-dataframes/commit/fb81eeaf13af059f32cb38e7f117fb3504243d51)) + + +### Bug Fixes + +* Avoid ibis fillna warning in compiler ([#2113](https://github.com/googleapis/python-bigquery-dataframes/issues/2113)) ([7ef667b](https://github.com/googleapis/python-bigquery-dataframes/commit/7ef667b0f46f13bcc8ad4f2ed8f81278132b5aec)) +* Negative start and stop parameter values in Series.str.slice() ([#2104](https://github.com/googleapis/python-bigquery-dataframes/issues/2104)) ([f57a348](https://github.com/googleapis/python-bigquery-dataframes/commit/f57a348f1935a4e2bb14c501bb4c47cd552d102a)) +* Throw type error for incomparable join keys ([#2098](https://github.com/googleapis/python-bigquery-dataframes/issues/2098)) ([9dc9695](https://github.com/googleapis/python-bigquery-dataframes/commit/9dc96959a84b751d18b290129c2926df6e50b3f5)) +* Transformers with non-standard column names throw errors ([#2089](https://github.com/googleapis/python-bigquery-dataframes/issues/2089)) ([a2daa3f](https://github.com/googleapis/python-bigquery-dataframes/commit/a2daa3fffe6743327edb9f4c74db93198bd12f8e)) + +## [2.21.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.20.0...v2.21.0) (2025-09-17) + + +### Features + +* Add bigframes.bigquery.to_json ([#2078](https://github.com/googleapis/python-bigquery-dataframes/issues/2078)) ([0fc795a](https://github.com/googleapis/python-bigquery-dataframes/commit/0fc795a9fb56f469b62603462c3f0f56f52bfe04)) +* Support average='binary' in precision_score() ([#2080](https://github.com/googleapis/python-bigquery-dataframes/issues/2080)) ([920f381](https://github.com/googleapis/python-bigquery-dataframes/commit/920f381aec7e0a0b986886cdbc333e86335c6d7d)) +* Support pandas series in ai.generate_bool ([#2086](https://github.com/googleapis/python-bigquery-dataframes/issues/2086)) ([a3de53f](https://github.com/googleapis/python-bigquery-dataframes/commit/a3de53f68b2a24f4ed85a474dfaff9b59570a2f1)) + + +### Bug Fixes + +* Allow bigframes.options.bigquery.credentials to be `None` ([#2092](https://github.com/googleapis/python-bigquery-dataframes/issues/2092)) ([78f4001](https://github.com/googleapis/python-bigquery-dataframes/commit/78f4001e8fcfc77fc82f3893d58e0d04c0f6d3db)) + +## [2.20.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.19.0...v2.20.0) (2025-09-16) + + +### Features + +* Add `__dataframe__` interchange support ([#2063](https://github.com/googleapis/python-bigquery-dataframes/issues/2063)) ([3b46a0d](https://github.com/googleapis/python-bigquery-dataframes/commit/3b46a0d91eb379c61ced45ae0b25339281326c3d)) +* Add ai_generate_bool to the bigframes.bigquery package ([#2060](https://github.com/googleapis/python-bigquery-dataframes/issues/2060)) ([70d6562](https://github.com/googleapis/python-bigquery-dataframes/commit/70d6562df64b2aef4ff0024df6f57702d52dcaf8)) +* Add bigframes.bigquery.to_json_string ([#2076](https://github.com/googleapis/python-bigquery-dataframes/issues/2076)) ([41e8f33](https://github.com/googleapis/python-bigquery-dataframes/commit/41e8f33ceb46a7c2a75d1c59a4a3f2f9413d281d)) +* Add rank(pct=True) support ([#2084](https://github.com/googleapis/python-bigquery-dataframes/issues/2084)) ([c1e871d](https://github.com/googleapis/python-bigquery-dataframes/commit/c1e871d9327bf6c920d17e1476fed3088d506f5f)) +* Add StreamingDataFrame.to_bigtable and .to_pubsub start_timestamp parameter ([#2066](https://github.com/googleapis/python-bigquery-dataframes/issues/2066)) ([a63cbae](https://github.com/googleapis/python-bigquery-dataframes/commit/a63cbae24ff2dc191f0a53dced885bc95f38ec96)) +* Can call agg with some callables ([#2055](https://github.com/googleapis/python-bigquery-dataframes/issues/2055)) ([17a1ed9](https://github.com/googleapis/python-bigquery-dataframes/commit/17a1ed99ec8c6d3215d3431848814d5d458d4ff1)) +* Support astype to json ([#2073](https://github.com/googleapis/python-bigquery-dataframes/issues/2073)) ([6bd6738](https://github.com/googleapis/python-bigquery-dataframes/commit/6bd67386341de7a92ada948381702430c399406e)) +* Support pandas.Index as key for DataFrame.__setitem__() ([#2062](https://github.com/googleapis/python-bigquery-dataframes/issues/2062)) ([b3cf824](https://github.com/googleapis/python-bigquery-dataframes/commit/b3cf8248e3b8ea76637ded64fb12028d439448d1)) +* Support pd.cut() for array-like type ([#2064](https://github.com/googleapis/python-bigquery-dataframes/issues/2064)) ([21eb213](https://github.com/googleapis/python-bigquery-dataframes/commit/21eb213c5f0e0f696f2d1ca1f1263678d791cf7c)) +* Support to cast struct to json ([#2067](https://github.com/googleapis/python-bigquery-dataframes/issues/2067)) ([b0ff718](https://github.com/googleapis/python-bigquery-dataframes/commit/b0ff718a04fadda33cfa3613b1d02822cde34bc2)) + + +### Bug Fixes + +* Deflake ai_gen_bool multimodel test ([#2085](https://github.com/googleapis/python-bigquery-dataframes/issues/2085)) ([566a37a](https://github.com/googleapis/python-bigquery-dataframes/commit/566a37a30ad5677aef0c5f79bdd46bca2139cc1e)) +* Do not scroll page selector in anywidget `repr_mode` ([#2082](https://github.com/googleapis/python-bigquery-dataframes/issues/2082)) ([5ce5d63](https://github.com/googleapis/python-bigquery-dataframes/commit/5ce5d63fcb51bfb3df2769108b7486287896ccb9)) +* Fix the potential invalid VPC egress configuration ([#2068](https://github.com/googleapis/python-bigquery-dataframes/issues/2068)) ([cce4966](https://github.com/googleapis/python-bigquery-dataframes/commit/cce496605385f2ac7ab0becc0773800ed5901aa5)) +* Return a DataFrame containing query stats for all non-SELECT statements ([#2071](https://github.com/googleapis/python-bigquery-dataframes/issues/2071)) ([a52b913](https://github.com/googleapis/python-bigquery-dataframes/commit/a52b913d9d8794b4b959ea54744a38d9f2f174e7)) +* Use the remote and managed functions for bigframes results ([#2079](https://github.com/googleapis/python-bigquery-dataframes/issues/2079)) ([49b91e8](https://github.com/googleapis/python-bigquery-dataframes/commit/49b91e878de651de23649756259ee35709e3f5a8)) + + +### Performance Improvements + +* Avoid re-authenticating if credentials have already been fetched ([#2058](https://github.com/googleapis/python-bigquery-dataframes/issues/2058)) ([913de1b](https://github.com/googleapis/python-bigquery-dataframes/commit/913de1b31f3bb0b306846fddae5dcaff6be3cec4)) +* Improve apply axis=1 performance ([#2077](https://github.com/googleapis/python-bigquery-dataframes/issues/2077)) ([12e4380](https://github.com/googleapis/python-bigquery-dataframes/commit/12e438051134577e911c1a6ce9d5a5885a0b45ad)) + +## [2.19.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.18.0...v2.19.0) (2025-09-09) + + +### Features + +* Add str.join method ([#2054](https://github.com/googleapis/python-bigquery-dataframes/issues/2054)) ([8804ada](https://github.com/googleapis/python-bigquery-dataframes/commit/8804adaf8ba23fdcad6e42a7bf034bd0a11c890f)) +* Support display.max_colwidth option ([#2053](https://github.com/googleapis/python-bigquery-dataframes/issues/2053)) ([5229e07](https://github.com/googleapis/python-bigquery-dataframes/commit/5229e07b4535c01b0cdbd731455ff225a373b5c8)) +* Support VPC egress setting in remote function ([#2059](https://github.com/googleapis/python-bigquery-dataframes/issues/2059)) ([5df779d](https://github.com/googleapis/python-bigquery-dataframes/commit/5df779d4f421d3ba777cfd928d99ca2e8a3f79ad)) + + +### Bug Fixes + +* Fix issue mishandling chunked array while loading data ([#2051](https://github.com/googleapis/python-bigquery-dataframes/issues/2051)) ([873d0ee](https://github.com/googleapis/python-bigquery-dataframes/commit/873d0eee474ed34f1d5164c37383f2737dbec4db)) +* Remove warning for slot_millis_sum ([#2047](https://github.com/googleapis/python-bigquery-dataframes/issues/2047)) ([425a691](https://github.com/googleapis/python-bigquery-dataframes/commit/425a6917d5442eeb4df486c6eed1fd136bbcedfb)) + +## [2.18.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.17.0...v2.18.0) (2025-09-03) + + +### ⚠ BREAKING CHANGES + +* add `allow_large_results` option to `read_gbq_query`, aligning with `bpd.options.compute.allow_large_results` option ([#1935](https://github.com/googleapis/python-bigquery-dataframes/issues/1935)) + +### Features + +* Add `allow_large_results` option to `read_gbq_query`, aligning with `bpd.options.compute.allow_large_results` option ([#1935](https://github.com/googleapis/python-bigquery-dataframes/issues/1935)) ([a7963fe](https://github.com/googleapis/python-bigquery-dataframes/commit/a7963fe57a0e141debf726f0bc7b0e953ebe9634)) +* Add parameter shuffle for ml.model_selection.train_test_split ([#2030](https://github.com/googleapis/python-bigquery-dataframes/issues/2030)) ([2c72c56](https://github.com/googleapis/python-bigquery-dataframes/commit/2c72c56fb5893eb01d5aec6273d11945c9c532c5)) +* Can pivot unordered, unindexed dataframe ([#2040](https://github.com/googleapis/python-bigquery-dataframes/issues/2040)) ([1a0f710](https://github.com/googleapis/python-bigquery-dataframes/commit/1a0f710ac11418fd71ab3373f3f6002fa581b180)) +* Local date accessor execution support ([#2034](https://github.com/googleapis/python-bigquery-dataframes/issues/2034)) ([7ac6fe1](https://github.com/googleapis/python-bigquery-dataframes/commit/7ac6fe16f7f2c09d2efac6ab813ec841c21baef8)) +* Support args in dataframe apply method ([#2026](https://github.com/googleapis/python-bigquery-dataframes/issues/2026)) ([164c481](https://github.com/googleapis/python-bigquery-dataframes/commit/164c4818bc4ff2990dca16b9f22a798f47e0a60b)) +* Support args in series apply method ([#2013](https://github.com/googleapis/python-bigquery-dataframes/issues/2013)) ([d9d725c](https://github.com/googleapis/python-bigquery-dataframes/commit/d9d725cfbc3dca9e66b460cae4084e25162f2acf)) +* Support callable for dataframe mask method ([#2020](https://github.com/googleapis/python-bigquery-dataframes/issues/2020)) ([9d4504b](https://github.com/googleapis/python-bigquery-dataframes/commit/9d4504be310d38b63515d67c0f60d2e48e68c7b5)) +* Support multi-column assignment for DataFrame ([#2028](https://github.com/googleapis/python-bigquery-dataframes/issues/2028)) ([ba0d23b](https://github.com/googleapis/python-bigquery-dataframes/commit/ba0d23b59c44ba5a46ace8182ad0e0cfc703b3ab)) +* Support string matching in local executor ([#2032](https://github.com/googleapis/python-bigquery-dataframes/issues/2032)) ([c0b54f0](https://github.com/googleapis/python-bigquery-dataframes/commit/c0b54f03849ee3115413670e690e68f3ef10f2ec)) + + +### Bug Fixes + +* Fix scalar op lowering tree walk ([#2029](https://github.com/googleapis/python-bigquery-dataframes/issues/2029)) ([935af10](https://github.com/googleapis/python-bigquery-dataframes/commit/935af107ef98837fb2b81d72185d0b6a9e09fbcf)) +* Read_csv fails when check file size for wildcard gcs files ([#2019](https://github.com/googleapis/python-bigquery-dataframes/issues/2019)) ([b0d620b](https://github.com/googleapis/python-bigquery-dataframes/commit/b0d620bbe8227189bbdc2ba5a913b03c70575296)) +* Resolve the validation issue for other arg in dataframe where method ([#2042](https://github.com/googleapis/python-bigquery-dataframes/issues/2042)) ([8689199](https://github.com/googleapis/python-bigquery-dataframes/commit/8689199aa82212ed300fff592097093812e0290e)) + + +### Performance Improvements + +* Improve axis=1 aggregation performance ([#2036](https://github.com/googleapis/python-bigquery-dataframes/issues/2036)) ([fbb2094](https://github.com/googleapis/python-bigquery-dataframes/commit/fbb209468297a8057d9d49c40e425c3bfdeb92bd)) +* Improve iter_nodes_topo performance using Kahn's algorithm ([#2038](https://github.com/googleapis/python-bigquery-dataframes/issues/2038)) ([3961637](https://github.com/googleapis/python-bigquery-dataframes/commit/39616374bba424996ebeb9a12096bfaf22660b44)) + +## [2.17.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.16.0...v2.17.0) (2025-08-22) + + +### Features + +* Add isin local execution impl ([#1993](https://github.com/googleapis/python-bigquery-dataframes/issues/1993)) ([26df6e6](https://github.com/googleapis/python-bigquery-dataframes/commit/26df6e691bb27ed09322a81214faedbf3639b32e)) +* Add reset_index names, col_level, col_fill, allow_duplicates args ([#2017](https://github.com/googleapis/python-bigquery-dataframes/issues/2017)) ([c02a1b6](https://github.com/googleapis/python-bigquery-dataframes/commit/c02a1b67d27758815430bb8006ac3a72cea55a89)) +* Support callable for series mask method ([#2014](https://github.com/googleapis/python-bigquery-dataframes/issues/2014)) ([5ac32eb](https://github.com/googleapis/python-bigquery-dataframes/commit/5ac32ebe17cfda447870859f5dd344b082b4d3d0)) + +## [2.16.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.15.0...v2.16.0) (2025-08-20) + + +### Features + +* Add `bigframes.pandas.options.display.precision` option ([#1979](https://github.com/googleapis/python-bigquery-dataframes/issues/1979)) ([15e6175](https://github.com/googleapis/python-bigquery-dataframes/commit/15e6175ec0aeb1b7b02d0bba9e8e1e018bd11c31)) +* Add level, inplace params to reset_index ([#1988](https://github.com/googleapis/python-bigquery-dataframes/issues/1988)) ([3446950](https://github.com/googleapis/python-bigquery-dataframes/commit/34469504b79a082d3380f9f25c597483aef2068a)) +* Add ML code samples from dbt blog post ([#1978](https://github.com/googleapis/python-bigquery-dataframes/issues/1978)) ([ebaa244](https://github.com/googleapis/python-bigquery-dataframes/commit/ebaa244a9eb7b87f7f9fd9c3bebe5c7db24cd013)) +* Add where, coalesce, fillna, casewhen, invert local impl ([#1976](https://github.com/googleapis/python-bigquery-dataframes/issues/1976)) ([f7f686c](https://github.com/googleapis/python-bigquery-dataframes/commit/f7f686cf85ab7e265d9c07ebc7f0cd59babc5357)) +* Adjust anywidget CSS to prevent overflow ([#1981](https://github.com/googleapis/python-bigquery-dataframes/issues/1981)) ([204f083](https://github.com/googleapis/python-bigquery-dataframes/commit/204f083a2f00fcc9fd1500dcd7a738eda3904d2f)) +* Format page number in table widget ([#1992](https://github.com/googleapis/python-bigquery-dataframes/issues/1992)) ([e83836e](https://github.com/googleapis/python-bigquery-dataframes/commit/e83836e8e1357f009f3f95666f1661bdbe0d3751)) +* Or, And, Xor can execute locally ([#1994](https://github.com/googleapis/python-bigquery-dataframes/issues/1994)) ([59c52a5](https://github.com/googleapis/python-bigquery-dataframes/commit/59c52a55ebea697855eb4c70529e226cc077141f)) +* Support callable bigframes function for dataframe where ([#1990](https://github.com/googleapis/python-bigquery-dataframes/issues/1990)) ([44c1ec4](https://github.com/googleapis/python-bigquery-dataframes/commit/44c1ec48cc4db1c4c9c15ec1fab43d4ef0758e56)) +* Support callable for series where method ([#2005](https://github.com/googleapis/python-bigquery-dataframes/issues/2005)) ([768b82a](https://github.com/googleapis/python-bigquery-dataframes/commit/768b82af96a5dd0c434edcb171036eb42cfb9b41)) +* When using `repr_mode = "anywidget"`, numeric values align right ([15e6175](https://github.com/googleapis/python-bigquery-dataframes/commit/15e6175ec0aeb1b7b02d0bba9e8e1e018bd11c31)) + + +### Bug Fixes + +* Address the packages issue for bigframes function ([#1991](https://github.com/googleapis/python-bigquery-dataframes/issues/1991)) ([68f1d22](https://github.com/googleapis/python-bigquery-dataframes/commit/68f1d22d5ed8457a5cabc7751ed1d178063dd63e)) +* Correct pypdf dependency specifier for remote PDF functions ([#1980](https://github.com/googleapis/python-bigquery-dataframes/issues/1980)) ([0bd5e1b](https://github.com/googleapis/python-bigquery-dataframes/commit/0bd5e1b3c004124d2100c3fbec2fbe1e965d1e96)) +* Enable default retries in calls to BQ Storage Read API ([#1985](https://github.com/googleapis/python-bigquery-dataframes/issues/1985)) ([f25d7bd](https://github.com/googleapis/python-bigquery-dataframes/commit/f25d7bd30800dffa65b6c31b0b7ac711a13d790f)) +* Fix the copyright year in dbt sample files ([#1996](https://github.com/googleapis/python-bigquery-dataframes/issues/1996)) ([fad5722](https://github.com/googleapis/python-bigquery-dataframes/commit/fad57223d129f0c95d0c6a066179bb66880edd06)) + + +### Performance Improvements + +* Faster session startup by defering anon dataset fetch ([#1982](https://github.com/googleapis/python-bigquery-dataframes/issues/1982)) ([2720c4c](https://github.com/googleapis/python-bigquery-dataframes/commit/2720c4cf070bf57a0930d7623bfc41d89cc053ee)) + + +### Documentation + +* Add examples of running bigframes in kaggle ([#2002](https://github.com/googleapis/python-bigquery-dataframes/issues/2002)) ([7d89d76](https://github.com/googleapis/python-bigquery-dataframes/commit/7d89d76976595b75cb0105fbe7b4f7ca2fdf49f2)) +* Remove preview warning from partial ordering mode sample notebook ([#1986](https://github.com/googleapis/python-bigquery-dataframes/issues/1986)) ([132e0ed](https://github.com/googleapis/python-bigquery-dataframes/commit/132e0edfe9f96c15753649d77fcb6edd0b0708a3)) + +## [2.15.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.14.0...v2.15.0) (2025-08-11) + + +### Features + +* Add `st_buffer`, `st_centroid`, and `st_convexhull` and their corresponding GeoSeries methods ([#1963](https://github.com/googleapis/python-bigquery-dataframes/issues/1963)) ([c4c7fa5](https://github.com/googleapis/python-bigquery-dataframes/commit/c4c7fa578e135e7f0e31ad3063db379514957acc)) +* Add first, last support to GroupBy ([#1969](https://github.com/googleapis/python-bigquery-dataframes/issues/1969)) ([41dda88](https://github.com/googleapis/python-bigquery-dataframes/commit/41dda889860c0ed8ca2eab81b34a9d71372c69f7)) +* Add value_counts to GroupBy classes ([#1974](https://github.com/googleapis/python-bigquery-dataframes/issues/1974)) ([82175a4](https://github.com/googleapis/python-bigquery-dataframes/commit/82175a4d0fa41d8aee11efdf8778a21bb70b1c0f)) +* Allow callable as a conditional or replacement input in DataFrame.where ([#1971](https://github.com/googleapis/python-bigquery-dataframes/issues/1971)) ([a8d57d2](https://github.com/googleapis/python-bigquery-dataframes/commit/a8d57d2f7075158eff69ec65a14c232756ab72a6)) +* Can cast locally in hybrid engine ([#1944](https://github.com/googleapis/python-bigquery-dataframes/issues/1944)) ([d9bc4a5](https://github.com/googleapis/python-bigquery-dataframes/commit/d9bc4a5940e9930d5e3c3bfffdadd2f91f96b53b)) +* Df.join lsuffix and rsuffix support ([#1857](https://github.com/googleapis/python-bigquery-dataframes/issues/1857)) ([26515c3](https://github.com/googleapis/python-bigquery-dataframes/commit/26515c34c4f0a5e4602d2f59bf229d41e0fc9196)) + + +### Bug Fixes + +* Add warnings for duplicated or conflicting type hints in bigfram… ([#1956](https://github.com/googleapis/python-bigquery-dataframes/issues/1956)) ([d38e42c](https://github.com/googleapis/python-bigquery-dataframes/commit/d38e42ce689e65f57223e9a8b14c4262cba08966)) +* Make `remote_function` more robust when there are `create_function` retries ([#1973](https://github.com/googleapis/python-bigquery-dataframes/issues/1973)) ([cd954ac](https://github.com/googleapis/python-bigquery-dataframes/commit/cd954ac07ad5e5820a20b941d3c6cab7cfcc1f29)) +* Make ExecutionMetrics stats tracking more robust to missing stats ([#1977](https://github.com/googleapis/python-bigquery-dataframes/issues/1977)) ([feb3ff4](https://github.com/googleapis/python-bigquery-dataframes/commit/feb3ff4b543eb8acbf6adf335b67a266a1cf4297)) + + +### Performance Improvements + +* Remove an unnecessary extra `dry_run` query from `read_gbq_table` ([#1972](https://github.com/googleapis/python-bigquery-dataframes/issues/1972)) ([d17b711](https://github.com/googleapis/python-bigquery-dataframes/commit/d17b711750d281ef3efd42c160f3784cd60021ae)) + + +### Documentation + +* Divide BQ DataFrames quickstart code cell ([#1975](https://github.com/googleapis/python-bigquery-dataframes/issues/1975)) ([fedb8f2](https://github.com/googleapis/python-bigquery-dataframes/commit/fedb8f23120aa315c7e9dd6f1bf1255ccf1ebc48)) + +## [2.14.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.13.0...v2.14.0) (2025-08-05) + + +### Features + +* Dynamic table width for better display across devices (https://github.com/googleapis/python-bigquery-dataframes/issues/1948) ([a6d30ae](https://github.com/googleapis/python-bigquery-dataframes/commit/a6d30ae3f4358925c999c53b558c1ecd3ee03e6c)) ([a6d30ae](https://github.com/googleapis/python-bigquery-dataframes/commit/a6d30ae3f4358925c999c53b558c1ecd3ee03e6c)) +* Retry AI/ML jobs that fail more often ([#1965](https://github.com/googleapis/python-bigquery-dataframes/issues/1965)) ([25bde9f](https://github.com/googleapis/python-bigquery-dataframes/commit/25bde9f9b89112db0efcc119bf29b6d1f3896c33)) +* Support series input in managed function ([#1920](https://github.com/googleapis/python-bigquery-dataframes/issues/1920)) ([62a189f](https://github.com/googleapis/python-bigquery-dataframes/commit/62a189f4d69f6c05fe348a1acd1fbac364fa60b9)) + + +### Bug Fixes + +* Enhance type error messages for bigframes functions ([#1958](https://github.com/googleapis/python-bigquery-dataframes/issues/1958)) ([770918e](https://github.com/googleapis/python-bigquery-dataframes/commit/770918e998bf1fde7a656e8f8a0ff0a8c68509f2)) + + +### Performance Improvements + +* Use promote_offsets for consistent row number generation for index.get_loc ([#1957](https://github.com/googleapis/python-bigquery-dataframes/issues/1957)) ([c67a25a](https://github.com/googleapis/python-bigquery-dataframes/commit/c67a25a879ab2a35ca9053a81c9c85b5660206ae)) + + +### Documentation + +* Add code snippet for storing dataframes to a CSV file ([#1943](https://github.com/googleapis/python-bigquery-dataframes/issues/1943)) ([a511e09](https://github.com/googleapis/python-bigquery-dataframes/commit/a511e09e6924d2e8302af2eb4a602c6b9e5d2d72)) +* Add code snippet for storing dataframes to a CSV file ([#1953](https://github.com/googleapis/python-bigquery-dataframes/issues/1953)) ([a298a02](https://github.com/googleapis/python-bigquery-dataframes/commit/a298a02b451f03ca200fe0756b9a7b57e3d1bf0e)) + +## [2.13.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.12.0...v2.13.0) (2025-07-25) + + +### Features + +* _read_gbq_colab creates hybrid session ([#1901](https://github.com/googleapis/python-bigquery-dataframes/issues/1901)) ([31b17b0](https://github.com/googleapis/python-bigquery-dataframes/commit/31b17b01706ccfcee9a2d838c43a9609ec4dc218)) +* Add CSS styling for TableWidget pagination interface ([#1934](https://github.com/googleapis/python-bigquery-dataframes/issues/1934)) ([5b232d7](https://github.com/googleapis/python-bigquery-dataframes/commit/5b232d7e33563196316f5dbb50b28c6be388d440)) +* Add row numbering local pushdown in hybrid execution ([#1932](https://github.com/googleapis/python-bigquery-dataframes/issues/1932)) ([92a2377](https://github.com/googleapis/python-bigquery-dataframes/commit/92a237712aa4ce516b1a44748127b34d7780fff6)) +* Implement Index.get_loc ([#1921](https://github.com/googleapis/python-bigquery-dataframes/issues/1921)) ([bbbcaf3](https://github.com/googleapis/python-bigquery-dataframes/commit/bbbcaf35df113617fd6bb8ae36468cf3f7ab493b)) + + +### Bug Fixes + +* Add license header and correct issues in dbt sample ([#1931](https://github.com/googleapis/python-bigquery-dataframes/issues/1931)) ([ab01b0a](https://github.com/googleapis/python-bigquery-dataframes/commit/ab01b0a236ffc7b667f258e0497105ea5c3d3aab)) + + +### Dependencies + +* Replace `google-cloud-iam` with `grpc-google-iam-v1` ([#1864](https://github.com/googleapis/python-bigquery-dataframes/issues/1864)) ([e5ff8f7](https://github.com/googleapis/python-bigquery-dataframes/commit/e5ff8f7d9fdac3ea47dabcc80a2598d601f39e64)) + +## [2.12.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.11.0...v2.12.0) (2025-07-23) + + +### Features + +* Add code samples for dbt bigframes integration ([#1898](https://github.com/googleapis/python-bigquery-dataframes/issues/1898)) ([7e03252](https://github.com/googleapis/python-bigquery-dataframes/commit/7e03252d31e505731db113eb38af77842bf29b9b)) +* Add isin local execution to hybrid engine ([#1915](https://github.com/googleapis/python-bigquery-dataframes/issues/1915)) ([c0cefd3](https://github.com/googleapis/python-bigquery-dataframes/commit/c0cefd36cfd55962b86178d2a612d625ed17f79c)) +* Add ml.metrics.mean_absolute_error method ([#1910](https://github.com/googleapis/python-bigquery-dataframes/issues/1910)) ([15b8449](https://github.com/googleapis/python-bigquery-dataframes/commit/15b8449dc5ad0c8190a5cbf47894436de18c8e88)) +* Allow local arithmetic execution in hybrid engine ([#1906](https://github.com/googleapis/python-bigquery-dataframes/issues/1906)) ([ebdcd02](https://github.com/googleapis/python-bigquery-dataframes/commit/ebdcd0240f0d8edaef3094b3a4e664b4a84d4a25)) +* Provide day_of_year and day_of_week for dt accessor ([#1911](https://github.com/googleapis/python-bigquery-dataframes/issues/1911)) ([40e7638](https://github.com/googleapis/python-bigquery-dataframes/commit/40e76383948a79bde48108f6180fd6ae2b3d0875)) +* Support params `max_batching_rows`, `container_cpu`, and `container_memory` for `udf` ([#1897](https://github.com/googleapis/python-bigquery-dataframes/issues/1897)) ([8baa912](https://github.com/googleapis/python-bigquery-dataframes/commit/8baa9126e595ae682469a6bb462244240699f57f)) +* Support typed pyarrow.Scalar in assignment ([#1930](https://github.com/googleapis/python-bigquery-dataframes/issues/1930)) ([cd28e12](https://github.com/googleapis/python-bigquery-dataframes/commit/cd28e12b3f70a6934a68963a7f25dbd5e3c67335)) + + +### Bug Fixes + +* Correct min field from max() to min() in remote function tests ([#1917](https://github.com/googleapis/python-bigquery-dataframes/issues/1917)) ([d5c54fc](https://github.com/googleapis/python-bigquery-dataframes/commit/d5c54fca32ed75c1aef52c99781db7f8ac7426e1)) +* Resolve location reset issue in bigquery options ([#1914](https://github.com/googleapis/python-bigquery-dataframes/issues/1914)) ([c15cb8a](https://github.com/googleapis/python-bigquery-dataframes/commit/c15cb8a1a9c834c2c1c2984930415b246f3f948b)) +* Series.str.isdigit in unicode superscripts and fractions ([#1924](https://github.com/googleapis/python-bigquery-dataframes/issues/1924)) ([8d46c36](https://github.com/googleapis/python-bigquery-dataframes/commit/8d46c36da7881a99861166c03a0831beff8ee0dd)) + + +### Documentation + +* Add code snippets for session and IO public docs ([#1919](https://github.com/googleapis/python-bigquery-dataframes/issues/1919)) ([6e01cbe](https://github.com/googleapis/python-bigquery-dataframes/commit/6e01cbec0dcf40e528b4a96e944681df18773c11)) +* Add snippets for performance optimization doc ([#1923](https://github.com/googleapis/python-bigquery-dataframes/issues/1923)) ([4da309e](https://github.com/googleapis/python-bigquery-dataframes/commit/4da309e27bd58a685e8aca953717da75d4ba5305)) + +## [2.11.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.10.0...v2.11.0) (2025-07-15) + + +### Features + +* Add `__contains__` to Index, Series, DataFrame ([#1899](https://github.com/googleapis/python-bigquery-dataframes/issues/1899)) ([07222bf](https://github.com/googleapis/python-bigquery-dataframes/commit/07222bfe2f6ae60859d33eb366598d7dee5c0572)) +* Add `thresh` param for Dataframe.dropna ([#1885](https://github.com/googleapis/python-bigquery-dataframes/issues/1885)) ([1395a50](https://github.com/googleapis/python-bigquery-dataframes/commit/1395a502ffa0faf4b7462045dcb0657485c7ce26)) +* Add concat pushdown for hybrid engine ([#1891](https://github.com/googleapis/python-bigquery-dataframes/issues/1891)) ([813624d](https://github.com/googleapis/python-bigquery-dataframes/commit/813624dddfd4f2396c8b1c9768c0c831bb0681ac)) +* Add pagination buttons (prev/next) to anywidget mode for DataFrames ([#1841](https://github.com/googleapis/python-bigquery-dataframes/issues/1841)) ([8eca767](https://github.com/googleapis/python-bigquery-dataframes/commit/8eca767425c7910c8f907747a8a8b335df0caa1a)) +* Add total_rows property to pandas batches iterator ([#1888](https://github.com/googleapis/python-bigquery-dataframes/issues/1888)) ([e3f5e65](https://github.com/googleapis/python-bigquery-dataframes/commit/e3f5e6539d220f8da57f08f67863ade29df4ad16)) +* Hybrid engine local join support ([#1900](https://github.com/googleapis/python-bigquery-dataframes/issues/1900)) ([1aa7950](https://github.com/googleapis/python-bigquery-dataframes/commit/1aa7950334bdc826a9a0a1894dad67ca6f755425)) +* Support `date` data type for to_datetime() ([#1902](https://github.com/googleapis/python-bigquery-dataframes/issues/1902)) ([24050cb](https://github.com/googleapis/python-bigquery-dataframes/commit/24050cb00247f68eb4ece827fd31ee1dd8b25380)) +* Support bpd.Series(json_data, dtype="json") ([#1882](https://github.com/googleapis/python-bigquery-dataframes/issues/1882)) ([05cb7d0](https://github.com/googleapis/python-bigquery-dataframes/commit/05cb7d0bc3599054acf8ecb8b15eb2045b9bf463)) + + +### Bug Fixes + +* Bpd.merge on common columns ([#1905](https://github.com/googleapis/python-bigquery-dataframes/issues/1905)) ([a1fa112](https://github.com/googleapis/python-bigquery-dataframes/commit/a1fa11291305a1da0d6a4121436c09ed04b224b5)) +* DataFrame string addition respects order ([#1894](https://github.com/googleapis/python-bigquery-dataframes/issues/1894)) ([52c8233](https://github.com/googleapis/python-bigquery-dataframes/commit/52c82337bcc9f2b6cfc1c6ac14deb83b693d114d)) +* Show slot_millis_sum warning only when `allow_large_results=False` ([#1892](https://github.com/googleapis/python-bigquery-dataframes/issues/1892)) ([25efabc](https://github.com/googleapis/python-bigquery-dataframes/commit/25efabc4897e0692725618ce43134127a7f2c2ee)) +* Used query row count metadata instead of table metadata ([#1893](https://github.com/googleapis/python-bigquery-dataframes/issues/1893)) ([e1ebc53](https://github.com/googleapis/python-bigquery-dataframes/commit/e1ebc5369a416280cec0ab1513e763b7a2fe3c20)) + +## [2.10.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.9.0...v2.10.0) (2025-07-08) + + +### Features + +* `df.to_pandas_batches()` returns one empty DataFrame if `df` is empty ([#1878](https://github.com/googleapis/python-bigquery-dataframes/issues/1878)) ([e43d15d](https://github.com/googleapis/python-bigquery-dataframes/commit/e43d15d535d6d5fd73c33967271f3591c41dffb3)) +* Add filter pushdown to hybrid engine ([#1871](https://github.com/googleapis/python-bigquery-dataframes/issues/1871)) ([6454aff](https://github.com/googleapis/python-bigquery-dataframes/commit/6454aff726dee791acbac98f893075ee5ee6d9a1)) +* Add simple stats support to hybrid local pushdown ([#1873](https://github.com/googleapis/python-bigquery-dataframes/issues/1873)) ([8715105](https://github.com/googleapis/python-bigquery-dataframes/commit/8715105239216bffe899ddcbb15805f2e3063af4)) + + +### Bug Fixes + +* Fix issues where duration type returned as int ([#1875](https://github.com/googleapis/python-bigquery-dataframes/issues/1875)) ([f30f750](https://github.com/googleapis/python-bigquery-dataframes/commit/f30f75053a6966abd1a6a644c23efb86b2ac568d)) + + +### Documentation + +* Update gsutil commands to gcloud commands ([#1876](https://github.com/googleapis/python-bigquery-dataframes/issues/1876)) ([c289f70](https://github.com/googleapis/python-bigquery-dataframes/commit/c289f7061320ec6d9de099cab2416cc9f289baac)) + +## [2.9.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.8.0...v2.9.0) (2025-06-30) + + +### Features + +* Add `bpd.read_arrow` to convert an Arrow object into a bigframes DataFrame ([#1855](https://github.com/googleapis/python-bigquery-dataframes/issues/1855)) ([633bf98](https://github.com/googleapis/python-bigquery-dataframes/commit/633bf98fde33264be4fc9d7454e541c560589152)) +* Add experimental polars execution ([#1747](https://github.com/googleapis/python-bigquery-dataframes/issues/1747)) ([daf0c3b](https://github.com/googleapis/python-bigquery-dataframes/commit/daf0c3b349fb1e85e7070c54a2d3f5460f5e40c9)) +* Add size op support in local engine ([#1865](https://github.com/googleapis/python-bigquery-dataframes/issues/1865)) ([942e66c](https://github.com/googleapis/python-bigquery-dataframes/commit/942e66c483c9afbb680a7af56c9e9a76172a33e1)) +* Create `deploy_remote_function` and `deploy_udf` functions to immediately deploy functions to BigQuery ([#1832](https://github.com/googleapis/python-bigquery-dataframes/issues/1832)) ([c706759](https://github.com/googleapis/python-bigquery-dataframes/commit/c706759b85359b6d23ce3449f6ab138ad2d22f9d)) +* Support index item assign in Series ([#1868](https://github.com/googleapis/python-bigquery-dataframes/issues/1868)) ([c5d251a](https://github.com/googleapis/python-bigquery-dataframes/commit/c5d251a1d454bb4ef55ea9905faeadd646a23b14)) +* Support item assignment in series ([#1859](https://github.com/googleapis/python-bigquery-dataframes/issues/1859)) ([25684ff](https://github.com/googleapis/python-bigquery-dataframes/commit/25684ff60367f49dd318d4677a7438abdc98bff9)) +* Support local execution of comparison ops ([#1849](https://github.com/googleapis/python-bigquery-dataframes/issues/1849)) ([1c45ccb](https://github.com/googleapis/python-bigquery-dataframes/commit/1c45ccb133091aa85bc34450704fc8cab3d9296b)) + + +### Bug Fixes + +* Fix bug selecting column repeatedly ([#1858](https://github.com/googleapis/python-bigquery-dataframes/issues/1858)) ([cc339e9](https://github.com/googleapis/python-bigquery-dataframes/commit/cc339e9938129cac896460e3a794b3ec8479fa4a)) +* Fix bug with DataFrame.agg for string values ([#1870](https://github.com/googleapis/python-bigquery-dataframes/issues/1870)) ([81e4d64](https://github.com/googleapis/python-bigquery-dataframes/commit/81e4d64c5a3bd8d30edaf909d0bef2d1d1a51c01)) +* Generate GoogleSQL instead of legacy SQL data types for `dry_run=True` from `bpd._read_gbq_colab` with local pandas DataFrame ([#1867](https://github.com/googleapis/python-bigquery-dataframes/issues/1867)) ([fab3c38](https://github.com/googleapis/python-bigquery-dataframes/commit/fab3c387b2ad66043244fa813a366e613b41c60f)) +* Revert dict back to protobuf in the iam binding update ([#1838](https://github.com/googleapis/python-bigquery-dataframes/issues/1838)) ([9fb3cb4](https://github.com/googleapis/python-bigquery-dataframes/commit/9fb3cb444607df6736d383a2807059bca470c453)) + + +### Documentation + +* Add data visualization samples for public doc ([#1847](https://github.com/googleapis/python-bigquery-dataframes/issues/1847)) ([15e1277](https://github.com/googleapis/python-bigquery-dataframes/commit/15e1277b1413de18a5e36f72959a99701d6df08b)) +* Changed broken logo ([#1866](https://github.com/googleapis/python-bigquery-dataframes/issues/1866)) ([e3c06b4](https://github.com/googleapis/python-bigquery-dataframes/commit/e3c06b4a07d0669a42460d081f1582b681ae3dd5)) +* Update ai.forecast notebook ([#1844](https://github.com/googleapis/python-bigquery-dataframes/issues/1844)) ([1863538](https://github.com/googleapis/python-bigquery-dataframes/commit/186353888db537b561ee994256f998df361b4071)) + +## [2.8.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.7.0...v2.8.0) (2025-06-23) + + +### ⚠ BREAKING CHANGES + +* add required param 'engine' to multimodal functions ([#1834](https://github.com/googleapis/python-bigquery-dataframes/issues/1834)) + +### Features + +* Add `bpd.options.compute.maximum_result_rows` option to limit client data download ([#1829](https://github.com/googleapis/python-bigquery-dataframes/issues/1829)) ([e22a3f6](https://github.com/googleapis/python-bigquery-dataframes/commit/e22a3f61a02cc1b7a5155556e5a07a1a2fea1d82)) +* Add `bpd.options.display.repr_mode = "anywidget"` to create an interactive display of the results ([#1820](https://github.com/googleapis/python-bigquery-dataframes/issues/1820)) ([be0a3cf](https://github.com/googleapis/python-bigquery-dataframes/commit/be0a3cf7711dadc68d8366ea90b99855773e2a2e)) +* Add DataFrame.ai.forecast() support ([#1828](https://github.com/googleapis/python-bigquery-dataframes/issues/1828)) ([7bc7f36](https://github.com/googleapis/python-bigquery-dataframes/commit/7bc7f36fc20d233f4cf5ed688cc5dcaf100ce4fb)) +* Add describe() method to Series ([#1827](https://github.com/googleapis/python-bigquery-dataframes/issues/1827)) ([a4205f8](https://github.com/googleapis/python-bigquery-dataframes/commit/a4205f882012820c034cb15d73b2768ec4ad3ac8)) +* Add required param 'engine' to multimodal functions ([#1834](https://github.com/googleapis/python-bigquery-dataframes/issues/1834)) ([37666e4](https://github.com/googleapis/python-bigquery-dataframes/commit/37666e4c137d52c28ab13477dfbcc6e92b913334)) + + +### Performance Improvements + +* Produce simpler sql ([#1836](https://github.com/googleapis/python-bigquery-dataframes/issues/1836)) ([cf9c22a](https://github.com/googleapis/python-bigquery-dataframes/commit/cf9c22a09c4e668a598fa1dad0f6a07b59bc6524)) + + +### Documentation + +* Add ai.forecast notebook ([#1840](https://github.com/googleapis/python-bigquery-dataframes/issues/1840)) ([2430497](https://github.com/googleapis/python-bigquery-dataframes/commit/24304972fdbdfd12c25c7f4ef5a7b280f334801a)) + +## [2.7.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.6.0...v2.7.0) (2025-06-16) + + +### Features + +* Add bbq.json_query_array and warn bbq.json_extract_array deprecated ([#1811](https://github.com/googleapis/python-bigquery-dataframes/issues/1811)) ([dc9eb27](https://github.com/googleapis/python-bigquery-dataframes/commit/dc9eb27fa75e90c2c95a0619551bf67aea6ef63b)) +* Add bbq.json_value_array and deprecate bbq.json_extract_string_array ([#1818](https://github.com/googleapis/python-bigquery-dataframes/issues/1818)) ([019051e](https://github.com/googleapis/python-bigquery-dataframes/commit/019051e453d81769891aa398475ebd04d1826e81)) +* Add groupby cumcount ([#1798](https://github.com/googleapis/python-bigquery-dataframes/issues/1798)) ([18f43e8](https://github.com/googleapis/python-bigquery-dataframes/commit/18f43e8b58e03a27b021bce07566a3d006ac3679)) +* Support custom build service account in `remote_function` ([#1796](https://github.com/googleapis/python-bigquery-dataframes/issues/1796)) ([e586151](https://github.com/googleapis/python-bigquery-dataframes/commit/e586151df81917b49f702ae496aaacbd02931636)) + + +### Bug Fixes + +* Correct read_csv behaviours with use_cols, names, index_col ([#1804](https://github.com/googleapis/python-bigquery-dataframes/issues/1804)) ([855031a](https://github.com/googleapis/python-bigquery-dataframes/commit/855031a316a6957731a5d1c5e59dedb9757d9f7a)) +* Fix single row broadcast with null index ([#1803](https://github.com/googleapis/python-bigquery-dataframes/issues/1803)) ([080eb7b](https://github.com/googleapis/python-bigquery-dataframes/commit/080eb7be3cde591e08cad0d5c52c68cc0b25ade8)) + + +### Documentation + +* Document how to use ai.map() for information extraction ([#1808](https://github.com/googleapis/python-bigquery-dataframes/issues/1808)) ([b586746](https://github.com/googleapis/python-bigquery-dataframes/commit/b5867464a5bf30300dcfc069eda546b11f03146c)) +* Rearrange README.rst to include a short code sample ([#1812](https://github.com/googleapis/python-bigquery-dataframes/issues/1812)) ([f6265db](https://github.com/googleapis/python-bigquery-dataframes/commit/f6265dbb8e22de81bb59c7def175cd325e85c041)) +* Use pandas API instead of pandas-like or pandas-compatible ([#1825](https://github.com/googleapis/python-bigquery-dataframes/issues/1825)) ([aa32369](https://github.com/googleapis/python-bigquery-dataframes/commit/aa323694e161f558bc5e60490c2f21008961e2ca)) + +## [2.6.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.5.0...v2.6.0) (2025-06-09) + + +### Features + +* Add blob.transcribe function ([#1773](https://github.com/googleapis/python-bigquery-dataframes/issues/1773)) ([86159a7](https://github.com/googleapis/python-bigquery-dataframes/commit/86159a7d24102574c26764a056478757844e2eca)) +* Implement ai.classify() ([#1781](https://github.com/googleapis/python-bigquery-dataframes/issues/1781)) ([8af26d0](https://github.com/googleapis/python-bigquery-dataframes/commit/8af26d07cf3e8b22e0c69dd0172352fadc1857d8)) +* Implement item() for Series and Index ([#1792](https://github.com/googleapis/python-bigquery-dataframes/issues/1792)) ([d2154c8](https://github.com/googleapis/python-bigquery-dataframes/commit/d2154c82fa0fed6e89c47db747d3c9cd57f9c618)) +* Implement ST_ISCLOSED geography function ([#1789](https://github.com/googleapis/python-bigquery-dataframes/issues/1789)) ([36bc179](https://github.com/googleapis/python-bigquery-dataframes/commit/36bc179ee7ef9b0b6799f98f8fac3f64d91412af)) +* Implement ST_LENGTH geography function ([#1791](https://github.com/googleapis/python-bigquery-dataframes/issues/1791)) ([c5b7fda](https://github.com/googleapis/python-bigquery-dataframes/commit/c5b7fdae74a22e581f7705bc0cf5390e928f4425)) +* Support isin with bigframes.pandas.Index arg ([#1779](https://github.com/googleapis/python-bigquery-dataframes/issues/1779)) ([e480d29](https://github.com/googleapis/python-bigquery-dataframes/commit/e480d29f03636fa9824404ef90c510701e510195)) + + +### Bug Fixes + +* Address `read_csv` with both `index_col` and `use_cols` behavior inconsistency with pandas ([#1785](https://github.com/googleapis/python-bigquery-dataframes/issues/1785)) ([ba7c313](https://github.com/googleapis/python-bigquery-dataframes/commit/ba7c313c8d308e3ff3f736b60978cb7a51715209)) +* Allow KMeans model init parameter as k-means++ alias ([#1790](https://github.com/googleapis/python-bigquery-dataframes/issues/1790)) ([0b59cf1](https://github.com/googleapis/python-bigquery-dataframes/commit/0b59cf1008613770fa1433c6da395e755c86fe22)) +* Replace function now can handle pd.NA value. ([#1786](https://github.com/googleapis/python-bigquery-dataframes/issues/1786)) ([7269512](https://github.com/googleapis/python-bigquery-dataframes/commit/7269512a28eb42029447d5380c764353278a74e1)) + + +### Documentation + +* Adjust strip method examples to match latest pandas ([#1797](https://github.com/googleapis/python-bigquery-dataframes/issues/1797)) ([817b0c0](https://github.com/googleapis/python-bigquery-dataframes/commit/817b0c0c5dc481598fbfdbe40fd925fb38f3a066)) +* Fix docstrings to improve html rendering of code examples ([#1788](https://github.com/googleapis/python-bigquery-dataframes/issues/1788)) ([38d9b73](https://github.com/googleapis/python-bigquery-dataframes/commit/38d9b7376697f8e19124e5d1f5fccda82d920b92)) + +## [2.5.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.4.0...v2.5.0) (2025-05-30) + + +### ⚠ BREAKING CHANGES + +* the updated `ai.map()` parameter list is not backward-compatible + +### Features + +* Add `bpd.options.bigquery.requests_transport_adapters` option ([#1755](https://github.com/googleapis/python-bigquery-dataframes/issues/1755)) ([bb45db8](https://github.com/googleapis/python-bigquery-dataframes/commit/bb45db8afdffa1417f11c050d40d4ec6d15b8654)) +* Add bbq.json_query and warn bbq.json_extract deprecated ([#1756](https://github.com/googleapis/python-bigquery-dataframes/issues/1756)) ([ec81dd2](https://github.com/googleapis/python-bigquery-dataframes/commit/ec81dd2228697d5bf193d86396cf7f3212e0289d)) +* Add bpd.options.reset() method ([#1743](https://github.com/googleapis/python-bigquery-dataframes/issues/1743)) ([36c359d](https://github.com/googleapis/python-bigquery-dataframes/commit/36c359d2521089e186a412d353daf9de6cfbc8f4)) +* Add DataFrame.round method ([#1742](https://github.com/googleapis/python-bigquery-dataframes/issues/1742)) ([3ea6043](https://github.com/googleapis/python-bigquery-dataframes/commit/3ea6043be7025fa7a11cca27b02f5505bbc9b129)) +* Add deferred data uploading ([#1720](https://github.com/googleapis/python-bigquery-dataframes/issues/1720)) ([1f6442e](https://github.com/googleapis/python-bigquery-dataframes/commit/1f6442e576c35ec784ccf9cab3d081d46e45a5ce)) +* Add deprecation warning to Gemini-1.5-X, text-embedding-004, and remove remove legacy models in notebooks and docs ([#1723](https://github.com/googleapis/python-bigquery-dataframes/issues/1723)) ([80aad9a](https://github.com/googleapis/python-bigquery-dataframes/commit/80aad9af794c2e06d1608c879f459a836fd4448b)) +* Add structured output for ai map, ai filter and ai join ([#1746](https://github.com/googleapis/python-bigquery-dataframes/issues/1746)) ([133ac6b](https://github.com/googleapis/python-bigquery-dataframes/commit/133ac6b0e1f1e7a12844a4b6fd5b26df59f7ef37)) +* Add support for df.loc[list, column(s)] ([#1761](https://github.com/googleapis/python-bigquery-dataframes/issues/1761)) ([768a757](https://github.com/googleapis/python-bigquery-dataframes/commit/768a7570845c4eb88f495d7f3c0f3158accdc231)) +* Include bq schema and query string in dry run results ([#1752](https://github.com/googleapis/python-bigquery-dataframes/issues/1752)) ([bb51147](https://github.com/googleapis/python-bigquery-dataframes/commit/bb511475b74cc253230725846098a9045be2e324)) +* Support `inplace=True` in `rename` and `rename_axis` ([#1744](https://github.com/googleapis/python-bigquery-dataframes/issues/1744)) ([734cc65](https://github.com/googleapis/python-bigquery-dataframes/commit/734cc652e435dc5d97a23411735aa51b7824e381)) +* Support `unique()` for Index ([#1750](https://github.com/googleapis/python-bigquery-dataframes/issues/1750)) ([27fac78](https://github.com/googleapis/python-bigquery-dataframes/commit/27fac78cb5654e5655aec861062837a7d4f3f679)) +* Support astype conversions to and from JSON dtypes ([#1716](https://github.com/googleapis/python-bigquery-dataframes/issues/1716)) ([8ef4de1](https://github.com/googleapis/python-bigquery-dataframes/commit/8ef4de10151717f88364a909b29fa7600e959ada)) +* Support dict param for dataframe.agg() ([#1772](https://github.com/googleapis/python-bigquery-dataframes/issues/1772)) ([f9c29c8](https://github.com/googleapis/python-bigquery-dataframes/commit/f9c29c85053d8111a74ce382490daed36f8bb35b)) +* Support dtype parameter in read_csv for bigquery engine ([#1749](https://github.com/googleapis/python-bigquery-dataframes/issues/1749)) ([50dca4c](https://github.com/googleapis/python-bigquery-dataframes/commit/50dca4c706d78673b03f90eccf776118247ba30b)) +* Use read api for some peek ops ([#1731](https://github.com/googleapis/python-bigquery-dataframes/issues/1731)) ([108f4d2](https://github.com/googleapis/python-bigquery-dataframes/commit/108f4d259e1bcfbe6c7aa3c3c3f8f605cf7615ee)) + + +### Bug Fixes + +* Fix clip int series with float bounds ([#1739](https://github.com/googleapis/python-bigquery-dataframes/issues/1739)) ([d451aef](https://github.com/googleapis/python-bigquery-dataframes/commit/d451aefd2181aef250c3b48cceac09063081cab2)) +* Fix error with self-merge operations ([#1774](https://github.com/googleapis/python-bigquery-dataframes/issues/1774)) ([e5fe143](https://github.com/googleapis/python-bigquery-dataframes/commit/e5fe14339b4a40ab4a25657ee0453e4108cf8bba)) +* Fix the default value for na_value for numpy conversions ([#1766](https://github.com/googleapis/python-bigquery-dataframes/issues/1766)) ([0629cac](https://github.com/googleapis/python-bigquery-dataframes/commit/0629cac7f9a9370a72c1ae25e014eb478a4c8c08)) +* Include location in Session-based temporary storage manager DDL queries ([#1780](https://github.com/googleapis/python-bigquery-dataframes/issues/1780)) ([acba032](https://github.com/googleapis/python-bigquery-dataframes/commit/acba0321cafeb49f3e560a364ebbf3d15fb8af88)) +* Prevent creating unnecessary client objects in multithreaded environments ([#1757](https://github.com/googleapis/python-bigquery-dataframes/issues/1757)) ([1cf9f5e](https://github.com/googleapis/python-bigquery-dataframes/commit/1cf9f5e8dba733ee26d15fc5edc44c81e094e9a0)) +* Reduce bigquery table modification via DML for to_gbq ([#1737](https://github.com/googleapis/python-bigquery-dataframes/issues/1737)) ([545cdca](https://github.com/googleapis/python-bigquery-dataframes/commit/545cdcac1361607678c2574f0f31eb43950073e5)) +* Stop ignoring arguments to `MatrixFactorization.score(X, y)` ([#1726](https://github.com/googleapis/python-bigquery-dataframes/issues/1726)) ([55c07e9](https://github.com/googleapis/python-bigquery-dataframes/commit/55c07e9d4315949c37ffa3e03c8fedc6daf17faf)) +* Support JSON and STRUCT for bbq.sql_scalar ([#1754](https://github.com/googleapis/python-bigquery-dataframes/issues/1754)) ([190390b](https://github.com/googleapis/python-bigquery-dataframes/commit/190390b804c2131c2eaa624d7f025febb7784b01)) +* Support str.replace re.compile with flags ([#1736](https://github.com/googleapis/python-bigquery-dataframes/issues/1736)) ([f8d2cd2](https://github.com/googleapis/python-bigquery-dataframes/commit/f8d2cd24281415f4a8f9193b676f5483128cd173)) + + +### Performance Improvements + +* Faster local data comparison using idenitity ([#1738](https://github.com/googleapis/python-bigquery-dataframes/issues/1738)) ([2858b1e](https://github.com/googleapis/python-bigquery-dataframes/commit/2858b1efb4fe74097dcb17c086ee1dc18e53053c)) +* Optimize repr for unordered gbq table ([#1778](https://github.com/googleapis/python-bigquery-dataframes/issues/1778)) ([2bc4fbc](https://github.com/googleapis/python-bigquery-dataframes/commit/2bc4fbc78eba4bb2ee335e0475700a7ca5bc84d7)) +* Use JOB_CREATION_OPTIONAL when `allow_large_results=False` ([#1763](https://github.com/googleapis/python-bigquery-dataframes/issues/1763)) ([15f3f2a](https://github.com/googleapis/python-bigquery-dataframes/commit/15f3f2aa42cfe4a2233f62c5f8906e7f7658f9fa)) + + +### Dependencies + +* Avoid `gcsfs==2025.5.0` ([#1762](https://github.com/googleapis/python-bigquery-dataframes/issues/1762)) ([68d5e2c](https://github.com/googleapis/python-bigquery-dataframes/commit/68d5e2cbef3510cadc7e9dd199117c1e3b02d19f)) + + +### Documentation + +* Add llm output_schema notebook ([#1732](https://github.com/googleapis/python-bigquery-dataframes/issues/1732)) ([b2261cc](https://github.com/googleapis/python-bigquery-dataframes/commit/b2261cc07cd58b51d212f9bf495c5022e587f816)) +* Add MatrixFactorization to the table of contents ([#1725](https://github.com/googleapis/python-bigquery-dataframes/issues/1725)) ([611e43b](https://github.com/googleapis/python-bigquery-dataframes/commit/611e43b156483848a5470f889fb7b2b473ecff4d)) +* Fix typo for "population" in the `GeminiTextGenerator.predict(..., output_schema={...})` sample notebook ([#1748](https://github.com/googleapis/python-bigquery-dataframes/issues/1748)) ([bd07e05](https://github.com/googleapis/python-bigquery-dataframes/commit/bd07e05d26820313c052eaf41c267a1ab20b4fc6)) +* Integrations notebook extracts token from `bqclient._http.credentials` instead of `bqclient._credentials` ([#1784](https://github.com/googleapis/python-bigquery-dataframes/issues/1784)) ([6e63eca](https://github.com/googleapis/python-bigquery-dataframes/commit/6e63eca29f20d83435878273604816ce7595c396)) +* Updated multimodal notebook instructions ([#1745](https://github.com/googleapis/python-bigquery-dataframes/issues/1745)) ([1df8ca6](https://github.com/googleapis/python-bigquery-dataframes/commit/1df8ca6312ee428d55c2091a00c73b13d9a6b193)) +* Use partial ordering mode in the quickstart sample ([#1734](https://github.com/googleapis/python-bigquery-dataframes/issues/1734)) ([476b7dd](https://github.com/googleapis/python-bigquery-dataframes/commit/476b7dd7c2639cb6804272d06aa5c1db666819da)) + +## [2.4.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.3.0...v2.4.0) (2025-05-12) + + +### Features + +* Add "dayofyear" property for `dt` accessors ([#1692](https://github.com/googleapis/python-bigquery-dataframes/issues/1692)) ([9d4a59d](https://github.com/googleapis/python-bigquery-dataframes/commit/9d4a59ddf22793d4e0587ea2f8648fae937875f3)) +* Add `.dt.days`, `.dt.seconds`, `dt.microseconds`, and `dt.total_seconds()` for timedelta series. ([#1713](https://github.com/googleapis/python-bigquery-dataframes/issues/1713)) ([2b3a45f](https://github.com/googleapis/python-bigquery-dataframes/commit/2b3a45f8c1fd299ee97cf1c343df7c80175b4287)) +* Add `DatetimeIndex` class ([#1719](https://github.com/googleapis/python-bigquery-dataframes/issues/1719)) ([c3c830c](https://github.com/googleapis/python-bigquery-dataframes/commit/c3c830cf20397830d531a89edf5302aede5d48a0)) +* Add `isocalendar()` for dt accessor" ([#1717](https://github.com/googleapis/python-bigquery-dataframes/issues/1717)) ([0479763](https://github.com/googleapis/python-bigquery-dataframes/commit/047976315dcbaed86e50d47f545b76c3a513dafb)) +* Add bigframes.bigquery.json_value ([#1697](https://github.com/googleapis/python-bigquery-dataframes/issues/1697)) ([46a9c53](https://github.com/googleapis/python-bigquery-dataframes/commit/46a9c53256be2a293f96122ba6b330564383bcd5)) +* Add blob.exif function support ([#1703](https://github.com/googleapis/python-bigquery-dataframes/issues/1703)) ([3f79528](https://github.com/googleapis/python-bigquery-dataframes/commit/3f79528781abe9bfc122f6f6e26bfa08b029265a)) +* Add inplace arg support to sort methods ([#1710](https://github.com/googleapis/python-bigquery-dataframes/issues/1710)) ([d1ccb52](https://github.com/googleapis/python-bigquery-dataframes/commit/d1ccb524ea26deac1cf9e481e9d55f9ae166247b)) +* Improve error message in `Series.apply` for direct udfs ([#1673](https://github.com/googleapis/python-bigquery-dataframes/issues/1673)) ([1a658b2](https://github.com/googleapis/python-bigquery-dataframes/commit/1a658b2aa43c4a7a7f2007a509b0e1401f925dab)) +* Publish bigframes blob(Multimodal) to preview ([#1693](https://github.com/googleapis/python-bigquery-dataframes/issues/1693)) ([e4c85ba](https://github.com/googleapis/python-bigquery-dataframes/commit/e4c85ba4813469d39edd7352201aefc26642d14c)) +* Support () operator between timedeltas ([#1702](https://github.com/googleapis/python-bigquery-dataframes/issues/1702)) ([edaac89](https://github.com/googleapis/python-bigquery-dataframes/commit/edaac89c03db1ffc93b56275c765d8a964f7d02d)) +* Support forecast_limit_lower_bound and forecast_limit_upper_bound in ARIMA_PLUS (and ARIMA_PLUS_XREG) models ([#1305](https://github.com/googleapis/python-bigquery-dataframes/issues/1305)) ([b16740e](https://github.com/googleapis/python-bigquery-dataframes/commit/b16740ef4ad7b1fbf731595238cf087c93c93066)) +* Support to_strip parameter for str.strip, str.lstrip and str.rstrip ([#1705](https://github.com/googleapis/python-bigquery-dataframes/issues/1705)) ([a84ee75](https://github.com/googleapis/python-bigquery-dataframes/commit/a84ee75ddd4d9dae1463e505549d74eb4f819338)) + + +### Bug Fixes + +* Fix dayofyear doc test ([#1701](https://github.com/googleapis/python-bigquery-dataframes/issues/1701)) ([9b777a0](https://github.com/googleapis/python-bigquery-dataframes/commit/9b777a019aa31a115a22289f21c7cd9df07aa8b9)) +* Fix issues with chunked arrow data ([#1700](https://github.com/googleapis/python-bigquery-dataframes/issues/1700)) ([e3289b7](https://github.com/googleapis/python-bigquery-dataframes/commit/e3289b7a64ee1400c6cb78e75cff4759d8da8b7a)) +* Rename columns with protected names such as `_TABLE_SUFFIX` in `to_gbq()` ([#1691](https://github.com/googleapis/python-bigquery-dataframes/issues/1691)) ([8ec6079](https://github.com/googleapis/python-bigquery-dataframes/commit/8ec607986fd38f357746fbaeabef2ce7ab3e501f)) + + +### Performance Improvements + +* Defer query in `read_gbq` with wildcard tables ([#1661](https://github.com/googleapis/python-bigquery-dataframes/issues/1661)) ([5c125c9](https://github.com/googleapis/python-bigquery-dataframes/commit/5c125c99d4632c617425c2ef5c399d17878c0043)) +* Rechunk result pages client side ([#1680](https://github.com/googleapis/python-bigquery-dataframes/issues/1680)) ([67d8760](https://github.com/googleapis/python-bigquery-dataframes/commit/67d876076027b6123e49d1d8ddee4e45eaa28f5d)) + + +### Dependencies + +* Move bigtable and pubsub to extras ([#1696](https://github.com/googleapis/python-bigquery-dataframes/issues/1696)) ([597d817](https://github.com/googleapis/python-bigquery-dataframes/commit/597d8178048b203cea4777f29b1ce95de7b0670e)) + + +### Documentation + +* Add snippets for Matrix Factorization tutorials ([#1630](https://github.com/googleapis/python-bigquery-dataframes/issues/1630)) ([24b37ae](https://github.com/googleapis/python-bigquery-dataframes/commit/24b37aece60460aabecce306397eb1bf6686f8a7)) +* Deprecate `bpd.options.bigquery.allow_large_results` in favor of `bpd.options.compute.allow_large_results` ([#1597](https://github.com/googleapis/python-bigquery-dataframes/issues/1597)) ([18780b4](https://github.com/googleapis/python-bigquery-dataframes/commit/18780b48a17dba2b3b3542500f027ae9527f6bee)) +* Include import statement in the bigframes code snippet ([#1699](https://github.com/googleapis/python-bigquery-dataframes/issues/1699)) ([08d70b6](https://github.com/googleapis/python-bigquery-dataframes/commit/08d70b6ad3ab3ac7b9a57d93da00168a8de7df9a)) +* Include the clean-up step in the udf code snippet ([#1698](https://github.com/googleapis/python-bigquery-dataframes/issues/1698)) ([48992e2](https://github.com/googleapis/python-bigquery-dataframes/commit/48992e26d460832704401bd2a3eedb800c5061cc)) +* Move multimodal notebook out of experimental folder ([#1712](https://github.com/googleapis/python-bigquery-dataframes/issues/1712)) ([68b6532](https://github.com/googleapis/python-bigquery-dataframes/commit/68b6532a780d6349a4b65994b696c8026457eb94)) +* Update blob_display option in snippets ([#1714](https://github.com/googleapis/python-bigquery-dataframes/issues/1714)) ([8b30143](https://github.com/googleapis/python-bigquery-dataframes/commit/8b30143e3320a730df168b5a72e6d18e631135ee)) + +## [2.3.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.2.0...v2.3.0) (2025-05-06) + + +### Features + +* Add dry_run parameter to `read_gbq()`, `read_gbq_table()` and `read_gbq_query()` ([#1674](https://github.com/googleapis/python-bigquery-dataframes/issues/1674)) ([4c5dee5](https://github.com/googleapis/python-bigquery-dataframes/commit/4c5dee5e6f4b30deb01e258670aa21dbf3ac9aa5)) + + +### Bug Fixes + +* Guarantee guid thread safety across threads ([#1684](https://github.com/googleapis/python-bigquery-dataframes/issues/1684)) ([cb0267d](https://github.com/googleapis/python-bigquery-dataframes/commit/cb0267deea227ea85f20d6dbef8c29cf03526d7a)) +* Support large lists of lists in bpd.Series() constructor ([#1662](https://github.com/googleapis/python-bigquery-dataframes/issues/1662)) ([0f4024c](https://github.com/googleapis/python-bigquery-dataframes/commit/0f4024c84508c17657a9104ef1f8718094827ada)) +* Use value equality to check types for unix epoch functions and timestamp diff ([#1690](https://github.com/googleapis/python-bigquery-dataframes/issues/1690)) ([81e8fb8](https://github.com/googleapis/python-bigquery-dataframes/commit/81e8fb8627f1d35423dbbdcc99d02ab0ad362d11)) + + +### Performance Improvements + +* `to_datetime()` now avoids caching inputs unless data is inspected to infer format ([#1667](https://github.com/googleapis/python-bigquery-dataframes/issues/1667)) ([dd08857](https://github.com/googleapis/python-bigquery-dataframes/commit/dd08857f65140cbe5c524050d2d538949897c3cc)) + + +### Documentation + +* Add a visualization notebook to BigFrame samples ([#1675](https://github.com/googleapis/python-bigquery-dataframes/issues/1675)) ([ee062bf](https://github.com/googleapis/python-bigquery-dataframes/commit/ee062bfc29c27949205ca21d6c1dcd6125300e5e)) +* Fix spacing of k-means code snippet ([#1687](https://github.com/googleapis/python-bigquery-dataframes/issues/1687)) ([99f45dd](https://github.com/googleapis/python-bigquery-dataframes/commit/99f45dd14bd9632d209389a5fef009f18c57adbf)) +* Update snippet for `Create a k-means` model tutorial ([#1664](https://github.com/googleapis/python-bigquery-dataframes/issues/1664)) ([761c364](https://github.com/googleapis/python-bigquery-dataframes/commit/761c364f4df045b9e9d8d3d5fee91d9a87b772db)) + +## [2.2.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.1.0...v2.2.0) (2025-04-30) + + +### Features + +* Add gemini-2.0-flash-001 and gemini-2.0-flash-lite-001 to fine tune score endponts and multimodal endpoints ([#1650](https://github.com/googleapis/python-bigquery-dataframes/issues/1650)) ([4fb54df](https://github.com/googleapis/python-bigquery-dataframes/commit/4fb54dfe448604a90fc1818cf18b1e77e1e7227b)) +* Add GeminiTextGenerator.predict structured output ([#1653](https://github.com/googleapis/python-bigquery-dataframes/issues/1653)) ([6199023](https://github.com/googleapis/python-bigquery-dataframes/commit/6199023a6a71e72e926f5879e74a15215bc6e4a0)) +* DataFrames.__getitem__ support for slice input ([#1668](https://github.com/googleapis/python-bigquery-dataframes/issues/1668)) ([563f0cb](https://github.com/googleapis/python-bigquery-dataframes/commit/563f0cbdf4a18c3cd1bd2a4b52de823165638911)) +* Print right origin of `PreviewWarning` for the `bpd.udf` ([#1629](https://github.com/googleapis/python-bigquery-dataframes/issues/1629)) ([48d10d1](https://github.com/googleapis/python-bigquery-dataframes/commit/48d10d1f0150a29dd3b91f505f8d3874e0b88c42)) +* Session.bytes_processed_sum will be updated when allow_large_re… ([#1669](https://github.com/googleapis/python-bigquery-dataframes/issues/1669)) ([ae312db](https://github.com/googleapis/python-bigquery-dataframes/commit/ae312dbed25da6da5e2817d5c9838654c2a1ad1c)) +* Short circuit query for local scan ([#1618](https://github.com/googleapis/python-bigquery-dataframes/issues/1618)) ([e84f232](https://github.com/googleapis/python-bigquery-dataframes/commit/e84f232b0fc5e2167a7cddb355cf0c8837ae5422)) +* Support names parameter in read_csv for bigquery engine ([#1659](https://github.com/googleapis/python-bigquery-dataframes/issues/1659)) ([3388191](https://github.com/googleapis/python-bigquery-dataframes/commit/33881914ab5b8d0e701eabd9c731aed1deab3d49)) +* Support passing list of values to bigframes.core.sql.simple_literal ([#1641](https://github.com/googleapis/python-bigquery-dataframes/issues/1641)) ([102d363](https://github.com/googleapis/python-bigquery-dataframes/commit/102d363aa7e3245ff262c817bc756ea0eaee57e7)) +* Support write api as loading option ([#1617](https://github.com/googleapis/python-bigquery-dataframes/issues/1617)) ([c46ad06](https://github.com/googleapis/python-bigquery-dataframes/commit/c46ad0647785a9207359eba0fb5b6f7a16610f2a)) + + +### Bug Fixes + +* DataFrame accessors is not pupulated ([#1639](https://github.com/googleapis/python-bigquery-dataframes/issues/1639)) ([28afa2c](https://github.com/googleapis/python-bigquery-dataframes/commit/28afa2c73c0517f9365fab05193706631b656551)) +* Prefer remote schema instead of throwing on materialize conflicts ([#1644](https://github.com/googleapis/python-bigquery-dataframes/issues/1644)) ([53fc25b](https://github.com/googleapis/python-bigquery-dataframes/commit/53fc25bfc86e166b91e5001506051b1cac34c996)) +* Remove itertools.pairwise usage ([#1638](https://github.com/googleapis/python-bigquery-dataframes/issues/1638)) ([9662745](https://github.com/googleapis/python-bigquery-dataframes/commit/9662745265c8c6e42f372629bd2c7806542cee1a)) +* Resolve issue where pre-release versions of google-auth are installed ([#1491](https://github.com/googleapis/python-bigquery-dataframes/issues/1491)) ([ebb7a5e](https://github.com/googleapis/python-bigquery-dataframes/commit/ebb7a5e2b24fa57d6fe6a76d9b857ad44c67d194)) +* Resolve some of the typo errors ([#1655](https://github.com/googleapis/python-bigquery-dataframes/issues/1655)) ([cd7fbde](https://github.com/googleapis/python-bigquery-dataframes/commit/cd7fbde026522f53a23a4bb6585ad8629769fad1)) + + +### Performance Improvements + +* Fold row count ops when known ([#1656](https://github.com/googleapis/python-bigquery-dataframes/issues/1656)) ([c958dbe](https://github.com/googleapis/python-bigquery-dataframes/commit/c958dbea32b77cec9fddfc09e3b40d1da220a42c)) +* Use flyweight for node fields ([#1654](https://github.com/googleapis/python-bigquery-dataframes/issues/1654)) ([8482bfc](https://github.com/googleapis/python-bigquery-dataframes/commit/8482bfc1d4caa91a35c4fbf0be420301d05ad544)) + + +### Dependencies + +* Support shapely 1.8.5+ again ([#1651](https://github.com/googleapis/python-bigquery-dataframes/issues/1651)) ([ae83e61](https://github.com/googleapis/python-bigquery-dataframes/commit/ae83e61c49ade64d6f727e9f364bd2f1aeec6e19)) + + +### Documentation + +* Add JSON data types notebook ([#1647](https://github.com/googleapis/python-bigquery-dataframes/issues/1647)) ([9128c4a](https://github.com/googleapis/python-bigquery-dataframes/commit/9128c4a31dab487bc23f67c43380abd0beda5b1c)) +* Add sample code snippets for `udf` ([#1649](https://github.com/googleapis/python-bigquery-dataframes/issues/1649)) ([53caa8d](https://github.com/googleapis/python-bigquery-dataframes/commit/53caa8d689e64436f5313095ee27479a06d8e8a8)) +* Fix `bq_dataframes_template` notebook to work if partial ordering mode is enabled ([#1665](https://github.com/googleapis/python-bigquery-dataframes/issues/1665)) ([f442e7a](https://github.com/googleapis/python-bigquery-dataframes/commit/f442e7a07ff273ba3af74eeabafb62110b78f692)) +* Note that `udf` is in preview and must be python 3.11 compatible ([#1629](https://github.com/googleapis/python-bigquery-dataframes/issues/1629)) ([48d10d1](https://github.com/googleapis/python-bigquery-dataframes/commit/48d10d1f0150a29dd3b91f505f8d3874e0b88c42)) + +## [2.1.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.0.0...v2.1.0) (2025-04-22) + + +### Features + +* Add `bigframes.bigquery.st_distance` function ([#1637](https://github.com/googleapis/python-bigquery-dataframes/issues/1637)) ([bf1ae70](https://github.com/googleapis/python-bigquery-dataframes/commit/bf1ae7091a02ad28d222fa63d311ed5ef3800807)) +* Enable local json string validations ([#1614](https://github.com/googleapis/python-bigquery-dataframes/issues/1614)) ([233347a](https://github.com/googleapis/python-bigquery-dataframes/commit/233347aca0ac55b2407e0f49430bf13536986e25)) +* Enhance `read_csv` `index_col` parameter support ([#1631](https://github.com/googleapis/python-bigquery-dataframes/issues/1631)) ([f4e5b26](https://github.com/googleapis/python-bigquery-dataframes/commit/f4e5b26b7b7b00ef807987c4b9c5fded56ad883f)) + + +### Bug Fixes + +* Add retry for test_clean_up_via_context_manager ([#1627](https://github.com/googleapis/python-bigquery-dataframes/issues/1627)) ([58e7cb0](https://github.com/googleapis/python-bigquery-dataframes/commit/58e7cb025a86959164643cebb725c853dc2ebc34)) +* Improve robustness of managed udf code extraction ([#1634](https://github.com/googleapis/python-bigquery-dataframes/issues/1634)) ([8cc56d5](https://github.com/googleapis/python-bigquery-dataframes/commit/8cc56d5118017beb2931519ddd1eb8e151852849)) + + +### Documentation + +* Add code samples in the `udf` API docstring ([#1632](https://github.com/googleapis/python-bigquery-dataframes/issues/1632)) ([f68b80c](https://github.com/googleapis/python-bigquery-dataframes/commit/f68b80cce2451a8c8d931a54e0cb69e02f34ce10)) + +## [2.0.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v1.42.0...v2.0.0) (2025-04-17) + + +### ⚠ BREAKING CHANGES + +* make `dataset` and `name` params mandatory in `udf` ([#1619](https://github.com/googleapis/python-bigquery-dataframes/issues/1619)) +* Locational endpoints support is not available in BigFrames 2.0. +* change default LLM model to gemini-2.0-flash-001, drop PaLM2TextGenerator and PaLM2TextEmbeddingGenerator ([#1558](https://github.com/googleapis/python-bigquery-dataframes/issues/1558)) +* change default ingress setting for `remote_function` to internal-only ([#1544](https://github.com/googleapis/python-bigquery-dataframes/issues/1544)) +* make `remote_function` params keyword only ([#1537](https://github.com/googleapis/python-bigquery-dataframes/issues/1537)) +* make `remote_function` default service account explicit ([#1537](https://github.com/googleapis/python-bigquery-dataframes/issues/1537)) +* set `allow_large_results=False` by default ([#1541](https://github.com/googleapis/python-bigquery-dataframes/issues/1541)) + +### Features + +* Add `on` parameter in `dataframe.rolling()` and `dataframe.groupby.rolling()` ([#1556](https://github.com/googleapis/python-bigquery-dataframes/issues/1556)) ([45c9d9f](https://github.com/googleapis/python-bigquery-dataframes/commit/45c9d9fd1c5c13a8692435aa22861820fc11e347)) +* Add component to manage temporary tables ([#1559](https://github.com/googleapis/python-bigquery-dataframes/issues/1559)) ([0a4e245](https://github.com/googleapis/python-bigquery-dataframes/commit/0a4e245670e678f4ead0aec8f8b534e7fe97d112)) +* Add Series.to_pandas_batches() method ([#1592](https://github.com/googleapis/python-bigquery-dataframes/issues/1592)) ([09ce979](https://github.com/googleapis/python-bigquery-dataframes/commit/09ce97999cfc1ded72906b1c7307da5950978ae6)) +* Add support for creating a Matrix Factorization model ([#1330](https://github.com/googleapis/python-bigquery-dataframes/issues/1330)) ([b5297f9](https://github.com/googleapis/python-bigquery-dataframes/commit/b5297f909b08928b97d887764d6e5142c763a5a3)) +* Allow `input_types`, `output_type`, and `dataset` to be used positionally in `remote_function` ([#1560](https://github.com/googleapis/python-bigquery-dataframes/issues/1560)) ([bcac8c6](https://github.com/googleapis/python-bigquery-dataframes/commit/bcac8c6ed0b40902d0ccaef3f907e6acbe6a52ed)) +* Allow pandas.cut 'labels' parameter to accept a list of string ([#1549](https://github.com/googleapis/python-bigquery-dataframes/issues/1549)) ([af842b1](https://github.com/googleapis/python-bigquery-dataframes/commit/af842b174de7eef4908b397d6a745caf8eda7b3d)) +* Change default ingress setting for `remote_function` to internal-only ([#1544](https://github.com/googleapis/python-bigquery-dataframes/issues/1544)) ([c848a80](https://github.com/googleapis/python-bigquery-dataframes/commit/c848a80766ff68ea92c05a5dc5c26508e6755381)) +* Detect duplicate column/index names in read_gbq before send query. ([#1615](https://github.com/googleapis/python-bigquery-dataframes/issues/1615)) ([40d6960](https://github.com/googleapis/python-bigquery-dataframes/commit/40d696088114fb08e68df74be261144350b785c8)) +* Drop support for locational endpoints ([#1542](https://github.com/googleapis/python-bigquery-dataframes/issues/1542)) ([4bf2e43](https://github.com/googleapis/python-bigquery-dataframes/commit/4bf2e43ef4498b11f32086231fc4cc749fde966a)) +* Enable time range rolling for DataFrame, DataFrameGroupBy and SeriesGroupBy ([#1605](https://github.com/googleapis/python-bigquery-dataframes/issues/1605)) ([b4b7073](https://github.com/googleapis/python-bigquery-dataframes/commit/b4b7073da8348b6597bd3d90d1a758cd29586533)) +* Improve local data validation ([#1598](https://github.com/googleapis/python-bigquery-dataframes/issues/1598)) ([815e471](https://github.com/googleapis/python-bigquery-dataframes/commit/815e471b904d4bd708afc4bfbf1db945e76f75c9)) +* Make `remote_function` default service account explicit ([#1537](https://github.com/googleapis/python-bigquery-dataframes/issues/1537)) ([9eb9089](https://github.com/googleapis/python-bigquery-dataframes/commit/9eb9089ce3f1dad39761ba8ebc2d6f76261bd243)) +* Set `allow_large_results=False` by default ([#1541](https://github.com/googleapis/python-bigquery-dataframes/issues/1541)) ([e9fb712](https://github.com/googleapis/python-bigquery-dataframes/commit/e9fb7129a05e8ac7c938ffe30e86902950316f20)) +* Support bigquery connection in managed function ([#1554](https://github.com/googleapis/python-bigquery-dataframes/issues/1554)) ([f6f697a](https://github.com/googleapis/python-bigquery-dataframes/commit/f6f697afc167e0fa7ea923c0aed85a9ef257d61f)) +* Support bq connection path format ([#1550](https://github.com/googleapis/python-bigquery-dataframes/issues/1550)) ([e7eb918](https://github.com/googleapis/python-bigquery-dataframes/commit/e7eb918dd9df3569febe695f57c1a5909844fd3c)) +* Support gemini-2.0-X models ([#1558](https://github.com/googleapis/python-bigquery-dataframes/issues/1558)) ([3104fab](https://github.com/googleapis/python-bigquery-dataframes/commit/3104fab019d20b0cbc06cd81d43b3f34fd1dd987)) +* Support inlining small list, struct, json data ([#1589](https://github.com/googleapis/python-bigquery-dataframes/issues/1589)) ([2ce891f](https://github.com/googleapis/python-bigquery-dataframes/commit/2ce891fcd5bfd9f093fbcbb1ea35158d2bf9d8b9)) +* Support time range rolling on Series. ([#1590](https://github.com/googleapis/python-bigquery-dataframes/issues/1590)) ([6e98a2c](https://github.com/googleapis/python-bigquery-dataframes/commit/6e98a2cf53dd130963a9c5ba07e21ce6c32b7c6d)) +* Use session temp tables for all ephemeral storage ([#1569](https://github.com/googleapis/python-bigquery-dataframes/issues/1569)) ([9711b83](https://github.com/googleapis/python-bigquery-dataframes/commit/9711b830a7bdc6740f4ebeaaab6f37082ae5dfd9)) +* Use validated local storage for data uploads ([#1612](https://github.com/googleapis/python-bigquery-dataframes/issues/1612)) ([aee4159](https://github.com/googleapis/python-bigquery-dataframes/commit/aee4159807401d7432bb8c0c41859ada3291599b)) +* Warn the deprecated `max_download_size`, `random_state` and `sampling_method` parameters in `(DataFrame|Series).to_pandas()` ([#1573](https://github.com/googleapis/python-bigquery-dataframes/issues/1573)) ([b9623da](https://github.com/googleapis/python-bigquery-dataframes/commit/b9623daa847805abf420f0f11e173674fb147193)) + + +### Bug Fixes + +* `to_pandas_batches()` respects `page_size` and `max_results` again ([#1572](https://github.com/googleapis/python-bigquery-dataframes/issues/1572)) ([27c5905](https://github.com/googleapis/python-bigquery-dataframes/commit/27c59051549b83fdac954eaa3d257803c6f9133d)) +* Ensure `page_size` works correctly in `to_pandas_batches` when `max_results` is not set ([#1588](https://github.com/googleapis/python-bigquery-dataframes/issues/1588)) ([570cff3](https://github.com/googleapis/python-bigquery-dataframes/commit/570cff3c2efe3a47535bb3c931a345856d256a19)) +* Include role and service account in IAM exception ([#1564](https://github.com/googleapis/python-bigquery-dataframes/issues/1564)) ([8c50755](https://github.com/googleapis/python-bigquery-dataframes/commit/8c507556c5f61fab95c6389a8ad04d731df1df7b)) +* Make `dataset` and `name` params mandatory in `udf` ([#1619](https://github.com/googleapis/python-bigquery-dataframes/issues/1619)) ([637e860](https://github.com/googleapis/python-bigquery-dataframes/commit/637e860d3cea0a36b1e58a45ec9b9ab0059fb3b1)) +* Pandas.cut returns labels index for numeric breaks when labels=False ([#1548](https://github.com/googleapis/python-bigquery-dataframes/issues/1548)) ([b2375de](https://github.com/googleapis/python-bigquery-dataframes/commit/b2375decedbf1a793eedbbc9dc2efc2296f8cc6e)) +* Prevent `KeyError` in `bpd.concat` with empty DF and struct/array types DF ([#1568](https://github.com/googleapis/python-bigquery-dataframes/issues/1568)) ([b4da1cf](https://github.com/googleapis/python-bigquery-dataframes/commit/b4da1cf3c0fb94a2bb21e6039896accab85742d4)) +* Read_csv supports for tilde local paths and includes index for bigquery_stream write engine ([#1580](https://github.com/googleapis/python-bigquery-dataframes/issues/1580)) ([352e8e4](https://github.com/googleapis/python-bigquery-dataframes/commit/352e8e4b05cf19e970b47b017f958a1c6fc89bea)) +* Use dictionaries to avoid problematic google.iam namespace ([#1611](https://github.com/googleapis/python-bigquery-dataframes/issues/1611)) ([b03e44f](https://github.com/googleapis/python-bigquery-dataframes/commit/b03e44f7fca429a6de41c42ec28504b688cd84f0)) + + +### Performance Improvements + +* Directly read gbq table for simple plans ([#1607](https://github.com/googleapis/python-bigquery-dataframes/issues/1607)) ([6ad38e8](https://github.com/googleapis/python-bigquery-dataframes/commit/6ad38e8287354f62b0c5cad1f3d5b897256860ca)) + + +### Dependencies + +* Remove jellyfish dependency ([#1604](https://github.com/googleapis/python-bigquery-dataframes/issues/1604)) ([1ac0e1e](https://github.com/googleapis/python-bigquery-dataframes/commit/1ac0e1e82c097717338a6816f27c01b67736f51c)) +* Remove parsy dependency ([#1610](https://github.com/googleapis/python-bigquery-dataframes/issues/1610)) ([293f676](https://github.com/googleapis/python-bigquery-dataframes/commit/293f676e98446c417c12c345d5db875dd4c438df)) +* Remove test dependency on pytest-mock package ([#1622](https://github.com/googleapis/python-bigquery-dataframes/issues/1622)) ([1ba72ea](https://github.com/googleapis/python-bigquery-dataframes/commit/1ba72ead256178afee6f1d3303b0556bec1c4a9b)) +* Support a shapely versions 1.8.5+ ([#1621](https://github.com/googleapis/python-bigquery-dataframes/issues/1621)) ([e39ee3b](https://github.com/googleapis/python-bigquery-dataframes/commit/e39ee3bcf37f2a4f5e6ce981d248c24c6f5d770b)) + + +### Documentation + +* Add details for `bigquery_connection` in `[@bpd](https://github.com/bpd).udf` docstring ([#1609](https://github.com/googleapis/python-bigquery-dataframes/issues/1609)) ([ef63772](https://github.com/googleapis/python-bigquery-dataframes/commit/ef6377277bc9c354385c83ceba9e00094c0a6cc6)) +* Add explain forecast snippet to multiple time series tutorial ([#1586](https://github.com/googleapis/python-bigquery-dataframes/issues/1586)) ([40c55a0](https://github.com/googleapis/python-bigquery-dataframes/commit/40c55a06a529ca49d203227ccf36c12427d0cd5b)) +* Add message to remove default model for version 3.0 ([#1563](https://github.com/googleapis/python-bigquery-dataframes/issues/1563)) ([910be2b](https://github.com/googleapis/python-bigquery-dataframes/commit/910be2b5b2bfaf0e21cdc4fd775c1605a864c1aa)) +* Add samples for ArimaPlus `time_series_id_col` feature ([#1577](https://github.com/googleapis/python-bigquery-dataframes/issues/1577)) ([1e4cd9c](https://github.com/googleapis/python-bigquery-dataframes/commit/1e4cd9cf69f98d4af6b2a70bd8189c619b19baaa)) +* Add warning for bigframes 2.0 ([#1557](https://github.com/googleapis/python-bigquery-dataframes/issues/1557)) ([3f0eaa1](https://github.com/googleapis/python-bigquery-dataframes/commit/3f0eaa1c6b02d086270421f91dbb6aa2f117317d)) +* Deprecate default model in `TextEmbedddingGenerator`, `GeminiTextGenerator`, and other `bigframes.ml.llm` classes ([#1570](https://github.com/googleapis/python-bigquery-dataframes/issues/1570)) ([89ab33e](https://github.com/googleapis/python-bigquery-dataframes/commit/89ab33e1179aef142415fd5c9073671903bf1d45)) +* Include all licenses for vendored packages in the root LICENSE file ([#1626](https://github.com/googleapis/python-bigquery-dataframes/issues/1626)) ([8116ed0](https://github.com/googleapis/python-bigquery-dataframes/commit/8116ed0938634d301a153613f8a9cd8053ddf026)) +* Remove gemini-1.5 deprecation warning for `GeminiTextGenerator` ([#1562](https://github.com/googleapis/python-bigquery-dataframes/issues/1562)) ([0cc6784](https://github.com/googleapis/python-bigquery-dataframes/commit/0cc678448fdec1eaa3acfbb563a018325a8c85bc)) +* Use restructured text to allow publishing to PyPI ([#1565](https://github.com/googleapis/python-bigquery-dataframes/issues/1565)) ([d1e9ec2](https://github.com/googleapis/python-bigquery-dataframes/commit/d1e9ec2936d270ec4035014ea3ddd335a5747ade)) + + +### Miscellaneous Chores + +* Make `remote_function` params keyword only ([#1537](https://github.com/googleapis/python-bigquery-dataframes/issues/1537)) ([9eb9089](https://github.com/googleapis/python-bigquery-dataframes/commit/9eb9089ce3f1dad39761ba8ebc2d6f76261bd243)) + +## [1.42.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v1.41.0...v1.42.0) (2025-03-27) + + +### Features + +* Add `closed` parameter in rolling() ([#1539](https://github.com/googleapis/python-bigquery-dataframes/issues/1539)) ([8bcc89b](https://github.com/googleapis/python-bigquery-dataframes/commit/8bcc89b30022f5ccf9ced80676a279c261c2f697)) +* Add `GeoSeries.difference()` and `bigframes.bigquery.st_difference()` ([#1471](https://github.com/googleapis/python-bigquery-dataframes/issues/1471)) ([e9fe815](https://github.com/googleapis/python-bigquery-dataframes/commit/e9fe8154d83e2674a05d7b670e949368b175ec8b)) +* Add `GeoSeries.intersection()` and `bigframes.bigquery.st_intersection()` ([#1529](https://github.com/googleapis/python-bigquery-dataframes/issues/1529)) ([8542bd4](https://github.com/googleapis/python-bigquery-dataframes/commit/8542bd469ff8775a9073f5a040b4117facfd8513)) +* Add df.take and series.take ([#1509](https://github.com/googleapis/python-bigquery-dataframes/issues/1509)) ([7d00be6](https://github.com/googleapis/python-bigquery-dataframes/commit/7d00be67cf50fdf713c40912f207d14f0f65538f)) +* Add Linear_Regression.global_explain() ([#1446](https://github.com/googleapis/python-bigquery-dataframes/issues/1446)) ([7e5b6a8](https://github.com/googleapis/python-bigquery-dataframes/commit/7e5b6a873d00162ffca3d254d3af276c5f06d866)) +* Allow iloc to support lists of negative indices ([#1497](https://github.com/googleapis/python-bigquery-dataframes/issues/1497)) ([a9cf215](https://github.com/googleapis/python-bigquery-dataframes/commit/a9cf215fb1403fda4ab2b58252f5fedc33aba3e1)) +* Support dry_run in `to_pandas()` ([#1436](https://github.com/googleapis/python-bigquery-dataframes/issues/1436)) ([75fc7e0](https://github.com/googleapis/python-bigquery-dataframes/commit/75fc7e0268dc5b10bdbc33dcf28db97dce62e41c)) +* Support window partition by geo column ([#1512](https://github.com/googleapis/python-bigquery-dataframes/issues/1512)) ([bdcb1e7](https://github.com/googleapis/python-bigquery-dataframes/commit/bdcb1e7929dc2f24c642ddb052629da394f45876)) +* Upgrade BQ managed `udf` to preview ([#1536](https://github.com/googleapis/python-bigquery-dataframes/issues/1536)) ([4a7fe4d](https://github.com/googleapis/python-bigquery-dataframes/commit/4a7fe4d75724e734634d41f18b4957e0877becc3)) + + +### Bug Fixes + +* Add deprecation warning to TextEmbeddingGenerator model, espeically gemini-1.0-X and gemini-1.5-X ([#1534](https://github.com/googleapis/python-bigquery-dataframes/issues/1534)) ([c93e720](https://github.com/googleapis/python-bigquery-dataframes/commit/c93e7204758435b0306699d3a1332aaf522f576b)) +* Change the default value for pdf extract/chunk ([#1517](https://github.com/googleapis/python-bigquery-dataframes/issues/1517)) ([a70a607](https://github.com/googleapis/python-bigquery-dataframes/commit/a70a607512797463f70ed529f078fcb2d40c85a1)) +* Local data always has sequential index ([#1514](https://github.com/googleapis/python-bigquery-dataframes/issues/1514)) ([014bd33](https://github.com/googleapis/python-bigquery-dataframes/commit/014bd33317966e15d05617c978e847de8c953453)) +* Read_pandas inline returns None when exceeds limit ([#1525](https://github.com/googleapis/python-bigquery-dataframes/issues/1525)) ([578081e](https://github.com/googleapis/python-bigquery-dataframes/commit/578081e978f2cca21ddae8b3ee371972ba723777)) +* Temporary fix for StreamingDataFrame not working backend bug ([#1533](https://github.com/googleapis/python-bigquery-dataframes/issues/1533)) ([6ab4ffd](https://github.com/googleapis/python-bigquery-dataframes/commit/6ab4ffd33d4900da833020ffa7ffc03a93a2b4b2)) +* Tolerate BQ connection service account propagation delay ([#1505](https://github.com/googleapis/python-bigquery-dataframes/issues/1505)) ([6681f1f](https://github.com/googleapis/python-bigquery-dataframes/commit/6681f1f9e30ed2325b85668de8a0b1d3d0e2858b)) + + +### Performance Improvements + +* Update shape to use quer_and_wait ([#1519](https://github.com/googleapis/python-bigquery-dataframes/issues/1519)) ([34ab9b8](https://github.com/googleapis/python-bigquery-dataframes/commit/34ab9b8abd2c632c806afe69f00d9e7dddb6a8b5)) + + +### Documentation + +* Update `GeoSeries.difference()` and `bigframes.bigquery.st_difference()` docs ([#1526](https://github.com/googleapis/python-bigquery-dataframes/issues/1526)) ([d553fa2](https://github.com/googleapis/python-bigquery-dataframes/commit/d553fa25fe85b3590269ed2ce08d5dff3bd22dfc)) + +## [1.41.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v1.40.0...v1.41.0) (2025-03-19) + + +### Features + +* Add support for the 'right' parameter in 'pandas.cut' ([#1496](https://github.com/googleapis/python-bigquery-dataframes/issues/1496)) ([8aff128](https://github.com/googleapis/python-bigquery-dataframes/commit/8aff1285b26754118cc8ee906c4ac3076456a791)) +* Support BQ managed functions through `read_gbq_function` ([#1476](https://github.com/googleapis/python-bigquery-dataframes/issues/1476)) ([802183d](https://github.com/googleapis/python-bigquery-dataframes/commit/802183dc000ad2ce5559d14181dd3f7d036b3fed)) +* Warn when the BigFrames version is more than a year old ([#1455](https://github.com/googleapis/python-bigquery-dataframes/issues/1455)) ([00e0750](https://github.com/googleapis/python-bigquery-dataframes/commit/00e07508cfb0d8798e079b86a14834b3b593aa54)) + + +### Bug Fixes + +* Fix pandas.cut errors with empty bins ([#1499](https://github.com/googleapis/python-bigquery-dataframes/issues/1499)) ([434fb5d](https://github.com/googleapis/python-bigquery-dataframes/commit/434fb5dd60d11f09b808ea656394790aba43fdde)) +* Fix read_gbq with ORDER BY query and index_col set ([#963](https://github.com/googleapis/python-bigquery-dataframes/issues/963)) ([de46d2f](https://github.com/googleapis/python-bigquery-dataframes/commit/de46d2fdf7a1a30b2be07dbaa1cb127f10f5fe30)) + + +### Performance Improvements + +* Eliminate count queries in llm retry ([#1489](https://github.com/googleapis/python-bigquery-dataframes/issues/1489)) ([1c934c2](https://github.com/googleapis/python-bigquery-dataframes/commit/1c934c2fe2374c9abaaa79696f5e5f349248f3b7)) + + +### Documentation + +* Add a sample notebook for vector search ([#1500](https://github.com/googleapis/python-bigquery-dataframes/issues/1500)) ([f3bf139](https://github.com/googleapis/python-bigquery-dataframes/commit/f3bf139d33ed00ca3081e4e0315f409fdb2ad84d)) + +## [1.40.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v1.39.0...v1.40.0) (2025-03-11) + + +### ⚠ BREAKING CHANGES + +* reading JSON data as a custom arrow extension type ([#1458](https://github.com/googleapis/python-bigquery-dataframes/issues/1458)) + +### Features + +* Reading JSON data as a custom arrow extension type ([#1458](https://github.com/googleapis/python-bigquery-dataframes/issues/1458)) ([e720f41](https://github.com/googleapis/python-bigquery-dataframes/commit/e720f41ef643ac14ae94fa98de5ef4a3fd6dde93)) +* Support list output for managed function ([#1457](https://github.com/googleapis/python-bigquery-dataframes/issues/1457)) ([461e9e0](https://github.com/googleapis/python-bigquery-dataframes/commit/461e9e017d513376fc623a5ee47f8b9dd002b452)) + + +### Bug Fixes + +* Fix list-like indexers in partial ordering mode ([#1456](https://github.com/googleapis/python-bigquery-dataframes/issues/1456)) ([fe72ada](https://github.com/googleapis/python-bigquery-dataframes/commit/fe72ada9cebb32947560c97567d7937c8b618f0d)) +* Fix the merge issue between 1424 and 1373 ([#1461](https://github.com/googleapis/python-bigquery-dataframes/issues/1461)) ([7b6e361](https://github.com/googleapis/python-bigquery-dataframes/commit/7b6e3615f8d4531beb4b59ca1223927112e713da)) +* Use `==` instead of `is` for timedelta type equality checks ([#1480](https://github.com/googleapis/python-bigquery-dataframes/issues/1480)) ([0db248b](https://github.com/googleapis/python-bigquery-dataframes/commit/0db248b5597a3966ac3dee1cca849509e48f4648)) + + +### Performance Improvements + +* Compilation no longer bounded by recursion ([#1464](https://github.com/googleapis/python-bigquery-dataframes/issues/1464)) ([27ab028](https://github.com/googleapis/python-bigquery-dataframes/commit/27ab028cdc45296923b12446c77b344af4208a3a)) + +## [1.39.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v1.38.0...v1.39.0) (2025-03-05) + + +### Features + +* (Preview) Support `diff()` for date series ([#1423](https://github.com/googleapis/python-bigquery-dataframes/issues/1423)) ([521e987](https://github.com/googleapis/python-bigquery-dataframes/commit/521e9874f1c7dcd80e10bfd86f1b467b0f6d6d6e)) +* (Preview) Support aggregations over timedeltas ([#1418](https://github.com/googleapis/python-bigquery-dataframes/issues/1418)) ([1251ded](https://github.com/googleapis/python-bigquery-dataframes/commit/1251dedac8faf383c931185a057a8bb26afb4b8f)) +* (Preview) Support arithmetics between dates and timedeltas ([#1413](https://github.com/googleapis/python-bigquery-dataframes/issues/1413)) ([962b152](https://github.com/googleapis/python-bigquery-dataframes/commit/962b152ce5a368132d1ac14f6d8348b7ba285694)) +* (Preview) Support automatic load of timedelta from BQ tables. ([#1429](https://github.com/googleapis/python-bigquery-dataframes/issues/1429)) ([b2917bb](https://github.com/googleapis/python-bigquery-dataframes/commit/b2917bb57212ac399c20356755c878d179454bfe)) +* Add `allow_large_results` option to many I/O methods. Set to `False` to reduce latency ([#1428](https://github.com/googleapis/python-bigquery-dataframes/issues/1428)) ([dd2f488](https://github.com/googleapis/python-bigquery-dataframes/commit/dd2f48893eced458afecc93dc17b7e22735c39b9)) +* Add `GeoSeries.boundary()` ([#1435](https://github.com/googleapis/python-bigquery-dataframes/issues/1435)) ([32cddfe](https://github.com/googleapis/python-bigquery-dataframes/commit/32cddfecd25ff4208473574df09a8010f8be0de9)) +* Add allow_large_results to peek ([#1448](https://github.com/googleapis/python-bigquery-dataframes/issues/1448)) ([67487b9](https://github.com/googleapis/python-bigquery-dataframes/commit/67487b9a3bbe07f1b76e0332fab693b4c4022529)) +* Add groupby.rank() ([#1433](https://github.com/googleapis/python-bigquery-dataframes/issues/1433)) ([3a633d5](https://github.com/googleapis/python-bigquery-dataframes/commit/3a633d5cc9c3e6a2bd8311c8834b406db5cb8699)) +* Iloc multiple columns selection. ([#1437](https://github.com/googleapis/python-bigquery-dataframes/issues/1437)) ([ddfd02a](https://github.com/googleapis/python-bigquery-dataframes/commit/ddfd02a83040847f6d4642420d3bd32a4a855001)) +* Support interface for BigQuery managed functions ([#1373](https://github.com/googleapis/python-bigquery-dataframes/issues/1373)) ([2bbf53f](https://github.com/googleapis/python-bigquery-dataframes/commit/2bbf53f0d92dc669e1d775fafc54199f582d9059)) +* Warn if default ingress_settings is used in remote_functions ([#1419](https://github.com/googleapis/python-bigquery-dataframes/issues/1419)) ([dfd891a](https://github.com/googleapis/python-bigquery-dataframes/commit/dfd891a0102314e7542d0b0057442dcde3d9a4a1)) + + +### Bug Fixes + +* Do not compare schema description during schema validation ([#1452](https://github.com/googleapis/python-bigquery-dataframes/issues/1452)) ([03a3a56](https://github.com/googleapis/python-bigquery-dataframes/commit/03a3a5632ab187e1208cdc7133acfe0214243832)) +* Remove warnings for null index and partial ordering mode in prep for GA ([#1431](https://github.com/googleapis/python-bigquery-dataframes/issues/1431)) ([6785aee](https://github.com/googleapis/python-bigquery-dataframes/commit/6785aee97f4ee0c122d83e78409f9d6cc361b6d8)) +* Warn if default `cloud_function_service_account` is used in `remote_function` ([#1424](https://github.com/googleapis/python-bigquery-dataframes/issues/1424)) ([fe7463a](https://github.com/googleapis/python-bigquery-dataframes/commit/fe7463a69e616776df3f1b3bce4abdeaf7579f9b)) +* Window operations over JSON columns ([#1451](https://github.com/googleapis/python-bigquery-dataframes/issues/1451)) ([0070e77](https://github.com/googleapis/python-bigquery-dataframes/commit/0070e77579d0d0535d9f9a6c12641128e8a6dfbc)) +* Write chunked text instead of dummy text for pdf chunk ([#1444](https://github.com/googleapis/python-bigquery-dataframes/issues/1444)) ([96b0e8a](https://github.com/googleapis/python-bigquery-dataframes/commit/96b0e8a7a9d405c895ffd8ece56f4e3d04e0fbe5)) + + +### Performance Improvements + +* Speed up DataFrame corr, cov ([#1309](https://github.com/googleapis/python-bigquery-dataframes/issues/1309)) ([c598c0a](https://github.com/googleapis/python-bigquery-dataframes/commit/c598c0a1694ebc5a49bd92c837e4aaf1c311a899)) + + +### Documentation + +* Add snippet for explaining the linear regression model prediction ([#1427](https://github.com/googleapis/python-bigquery-dataframes/issues/1427)) ([7c37c7d](https://github.com/googleapis/python-bigquery-dataframes/commit/7c37c7d81c0cdc4647667daeebf13d47dabf3972)) + +## [1.38.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v1.37.0...v1.38.0) (2025-02-24) + + +### Features + +* (Preview) Support diff aggregation for timestamp series. ([#1405](https://github.com/googleapis/python-bigquery-dataframes/issues/1405)) ([abe48d6](https://github.com/googleapis/python-bigquery-dataframes/commit/abe48d6f13a954534460fa14c9337e1085d9fbb3)) +* Add `GeoSeries.from_wkt() `and `GeoSeries.to_wkt()` ([#1401](https://github.com/googleapis/python-bigquery-dataframes/issues/1401)) ([2993b28](https://github.com/googleapis/python-bigquery-dataframes/commit/2993b283966960430ad8482f40f177e276db2d64)) +* Support DF.__array__(copy=True) ([#1403](https://github.com/googleapis/python-bigquery-dataframes/issues/1403)) ([693ed8c](https://github.com/googleapis/python-bigquery-dataframes/commit/693ed8cfb1ecc3af161801225d3e9cda489c29dd)) +* Support routines with ARRAY return type in `read_gbq_function` ([#1412](https://github.com/googleapis/python-bigquery-dataframes/issues/1412)) ([4b60049](https://github.com/googleapis/python-bigquery-dataframes/commit/4b60049e8362bfb07c136d8b2eb02b984d71f084)) + + +### Bug Fixes + +* Calling to_timdelta() over timedeltas no longer changes their values ([#1411](https://github.com/googleapis/python-bigquery-dataframes/issues/1411)) ([650a190](https://github.com/googleapis/python-bigquery-dataframes/commit/650a1907fdf84897eb7aa288863ee27d938e0879)) +* Replace empty dict with None to avoid mutable default arguments ([#1416](https://github.com/googleapis/python-bigquery-dataframes/issues/1416)) ([fa4e3ad](https://github.com/googleapis/python-bigquery-dataframes/commit/fa4e3ad8bcd5db56fa26b26609cc7e58b1edf498)) + + +### Performance Improvements + +* Avoid redundant SQL casts ([#1399](https://github.com/googleapis/python-bigquery-dataframes/issues/1399)) ([6ee48d5](https://github.com/googleapis/python-bigquery-dataframes/commit/6ee48d5c16870f1caa99c3f658c2c1a0e14be749)) + + +### Dependencies + +* Remove scikit-learn and sqlalchemy as required dependencies ([#1296](https://github.com/googleapis/python-bigquery-dataframes/issues/1296)) ([fd8bc89](https://github.com/googleapis/python-bigquery-dataframes/commit/fd8bc894bdbdf551ebbec1fb93832588371ae6af)) + + +### Documentation + +* Add samples using SQL methods via the `bigframes.bigquery` module ([#1358](https://github.com/googleapis/python-bigquery-dataframes/issues/1358)) ([f54e768](https://github.com/googleapis/python-bigquery-dataframes/commit/f54e7688fda6372c6decc9b61796b0272d803c79)) +* Add snippets for visualizing a time series and creating a time series model for the Limit forecasted values in time series model tutorial ([#1310](https://github.com/googleapis/python-bigquery-dataframes/issues/1310)) ([c6c9120](https://github.com/googleapis/python-bigquery-dataframes/commit/c6c9120e839647e5b3cb97f04a8d90cc8690b8a3)) + ## [1.37.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v1.36.0...v1.37.0) (2025-02-19) diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000000..0d447f17a4 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,148 @@ +# Contribution guidelines, tailored for LLM agents + +## Testing + +We use `nox` to instrument our tests. + +- To test your changes, run unit tests with `nox`: + + ```bash + nox -r -s unit + ``` + +- To run a single unit test: + + ```bash + nox -r -s unit-3.13 -- -k + ``` + +- Ignore this step if you lack access to Google Cloud resources. To run system + tests, you can execute:: + + # Run all system tests + $ nox -r -s system + + # Run a single system test + $ nox -r -s system-3.13 -- -k + +- The codebase must have better coverage than it had previously after each + change. You can test coverage via `nox -s unit system cover` (takes a long + time). Omit `system` if you lack access to cloud resources. + +## Code Style + +- We use the automatic code formatter `black`. You can run it using + the nox session `format`. This will eliminate many lint errors. Run via: + + ```bash + nox -r -s format + ``` + +- PEP8 compliance is required, with exceptions defined in the linter configuration. + If you have ``nox`` installed, you can test that you have not introduced + any non-compliant code via: + + ``` + nox -r -s lint + ``` + +- When writing tests, use the idiomatic "pytest" style. + +## Documentation + +If a method or property is implementing the same interface as a third-party +package such as pandas or scikit-learn, place the relevant docstring in the +corresponding `third_party/bigframes_vendored/package_name` directory, not in +the `bigframes` directory. Implementations may be placed in the `bigframes` +directory, though. + +### Testing code samples + +Code samples are very important for accurate documentation. We use the "doctest" +framework to ensure the samples are functioning as expected. After adding a code +sample, please ensure it is correct by running doctest. To run the samples +doctests for just a single method, refer to the following example: + +```bash +pytest --doctest-modules bigframes/pandas/__init__.py::bigframes.pandas.cut +``` + +## Tips for implementing common BigFrames features + +### Adding a scalar operator + +For an example, see commit +[c5b7fdae74a22e581f7705bc0cf5390e928f4425](https://github.com/googleapis/python-bigquery-dataframes/commit/c5b7fdae74a22e581f7705bc0cf5390e928f4425). + +To add a new scalar operator, follow these steps: + +1. **Define the operation dataclass:** + - In `bigframes/operations/`, find the relevant file (e.g., `geo_ops.py` for geography functions) or create a new one. + - Create a new dataclass inheriting from `base_ops.UnaryOp` for unary + operators, `base_ops.BinaryOp` for binary operators, `base_ops.TernaryOp` + for ternary operators, or `base_ops.NaryOp for operators with many + arguments. Note that these operators are counting the number column-like + arguments. A function that takes only a single column but several literal + values would still be a `UnaryOp`. + - Define the `name` of the operation and any parameters it requires. + - Implement the `output_type` method to specify the data type of the result. + +2. **Export the new operation:** + - In `bigframes/operations/__init__.py`, import your new operation dataclass and add it to the `__all__` list. + +3. **Implement the user-facing function (pandas-like):** + + - Identify the canonical function from pandas / geopandas / awkward array / + other popular Python package that this operator implements. + - Find the corresponding class in BigFrames. For example, the implementation + for most geopandas.GeoSeries methods is in + `bigframes/geopandas/geoseries.py`. Pandas Series methods are implemented + in `bigframes/series.py` or one of the accessors, such as `StringMethods` + in `bigframes/operations/strings.py`. + - Create the user-facing function that will be called by users (e.g., `length`). + - If the SQL method differs from pandas or geopandas in a way that can't be + made the same, raise a `NotImplementedError` with an appropriate message and + link to the feedback form. + - Add the docstring to the corresponding file in + `third_party/bigframes_vendored`, modeled after pandas / geopandas. + +4. **Implement the user-facing function (SQL-like):** + + - In `bigframes/bigquery/_operations/`, find the relevant file (e.g., `geo.py`) or create a new one. + - Create the user-facing function that will be called by users (e.g., `st_length`). + - This function should take a `Series` for any column-like inputs, plus any other parameters. + - Inside the function, call `series._apply_unary_op`, + `series._apply_binary_op`, or similar passing the operation dataclass you + created. + - Add a comprehensive docstring with examples. + - In `bigframes/bigquery/__init__.py`, import your new user-facing function and add it to the `__all__` list. + +5. **Implement the compilation logic:** + - In `bigframes/core/compile/scalar_op_compiler.py`: + - If the BigQuery function has a direct equivalent in Ibis, you can often reuse an existing Ibis method. + - If not, define a new Ibis UDF using `@ibis_udf.scalar.builtin` to map to the specific BigQuery function signature. + - Create a new compiler implementation function (e.g., `geo_length_op_impl`). + - Register this function to your operation dataclass using `@scalar_op_compiler.register_unary_op` or `@scalar_op_compiler.register_binary_op`. + - This implementation will translate the BigQuery DataFrames operation into the appropriate Ibis expression. + +6. **Add Tests:** + - Add system tests in the `tests/system/` directory to verify the end-to-end + functionality of the new operator. Test various inputs, including edge cases + and `NULL` values. + + Where possible, run the same test code against pandas or GeoPandas and + compare that the outputs are the same (except for dtypes if BigFrames + differs from pandas). + - If you are overriding a pandas or GeoPandas property, add a unit test to + ensure the correct behavior (e.g., raising `NotImplementedError` if the + functionality is not supported). + + +## Constraints + +- Only add git commits. Do not change git history. +- Follow the spec file for development. + - Check off items in the "Acceptance + criteria" and "Detailed steps" sections with `[x]`. + - Please do this as they are completed. + - Refer back to the spec after each step. diff --git a/LICENSE b/LICENSE index d645695673..c7807337dc 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,6 @@ +Files: All files not covered by another license. Notably: the bigframes module, +tests/*, bigframes_vendored.google_cloud_bigquery module, +bigframes_vendored.ibis module, and bigframes_vendored.xgboost module. Apache License Version 2.0, January 2004 @@ -200,3 +203,118 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +--- + +Files: For the bigframes_vendored.cpython module. + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 + +1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and the Individual or Organization ("Licensee") accessing and otherwise using this software ("Python") in source or binary form and its associated documentation. +2. Subject to the terms and conditions of this License Agreement, PSF hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python alone or in any derivative version, provided, however, that PSF's License Agreement and PSF's notice of copyright , i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006 Python Software Foundation All Rights Reserved" are retained in Python alone or in any derivative version prepared by Licensee. +3. In the event Licensee prepares a derivative work that is based on or incorporates Python or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to Python. +4. PSF is making Python available to Licensee on an "AS IS" basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. +6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. +7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between PSF and Licensee. This License Agreement does not grant permission to use PSF trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. +8. By copying, installing or otherwise using Python, Licensee agrees to be bound by the terms and conditions of this License Agreement. + +--- + +Files: for the bigframes_vendored.geopandas module. + +Copyright (c) 2013-2022, GeoPandas developers. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of GeoPandas nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +Files: The bigframes_vendored.pandas module. + +BSD 3-Clause License + +Copyright (c) 2008-2011, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team +All rights reserved. + +Copyright (c) 2011-2023, Open source contributors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +Files: The bigframes_vendored.sklearn module. + +BSD 3-Clause License + +Copyright (c) 2007-2023 The scikit-learn developers. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in index 16a933a629..c8555a39bf 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -17,7 +17,7 @@ # Generated by synthtool. DO NOT EDIT! include README.rst LICENSE recursive-include third_party/bigframes_vendored * -recursive-include bigframes *.json *.proto py.typed +recursive-include bigframes *.json *.proto *.js *.css py.typed recursive-include tests * global-exclude *.py[co] global-exclude __pycache__ diff --git a/README.rst b/README.rst index 185c50c14a..281f764094 100644 --- a/README.rst +++ b/README.rst @@ -1,16 +1,23 @@ -BigQuery DataFrames -=================== +:orphan: + +BigQuery DataFrames (BigFrames) +=============================== |GA| |pypi| |versions| -BigQuery DataFrames provides a Pythonic DataFrame and machine learning (ML) API -powered by the BigQuery engine. +BigQuery DataFrames (also known as BigFrames) provides a Pythonic DataFrame +and machine learning (ML) API powered by the BigQuery engine. It provides modules +for many use cases, including: -* ``bigframes.pandas`` provides a pandas-compatible API for analytics. -* ``bigframes.ml`` provides a scikit-learn-like API for ML. +* `bigframes.pandas `_ + is a pandas API for analytics. Many workloads can be + migrated from pandas to bigframes by just changing a few imports. +* `bigframes.ml `_ + is a scikit-learn-like API for ML. +* `bigframes.bigquery.ai `_ + are a collection of powerful AI methods, powered by Gemini. -BigQuery DataFrames is an open-source package. You can run -``pip install --upgrade bigframes`` to install the latest version. +BigQuery DataFrames is an `open-source package `_. .. |GA| image:: https://img.shields.io/badge/support-GA-gold.svg :target: https://github.com/googleapis/google-cloud-python/blob/main/README.rst#general-availability @@ -19,21 +26,48 @@ BigQuery DataFrames is an open-source package. You can run .. |versions| image:: https://img.shields.io/pypi/pyversions/bigframes.svg :target: https://pypi.org/project/bigframes/ -Documentation -------------- +Getting started with BigQuery DataFrames +---------------------------------------- -* `BigQuery DataFrames source code (GitHub) `_ -* `BigQuery DataFrames sample notebooks `_ -* `BigQuery DataFrames API reference `_ -* `BigQuery DataFrames supported pandas APIs `_ +The easiest way to get started is to try the +`BigFrames quickstart `_ +in a `notebook in BigQuery Studio `_. +To use BigFrames in your local development environment, -Getting started with BigQuery DataFrames ----------------------------------------- -Read `Introduction to BigQuery DataFrames `_ -and try the `BigQuery DataFrames quickstart `_ -to get up and running in just a few minutes. +1. Run ``pip install --upgrade bigframes`` to install the latest version. + +2. Setup `Application default credentials `_ + for your local development environment enviroment. + +3. Create a `GCP project with the BigQuery API enabled `_. + +4. Use the ``bigframes`` package to query data. + +.. code-block:: python + + import bigframes.pandas as bpd + + bpd.options.bigquery.project = your_gcp_project_id # Optional in BQ Studio. + bpd.options.bigquery.ordering_mode = "partial" # Recommended for performance. + df = bpd.read_gbq("bigquery-public-data.usa_names.usa_1910_2013") + print( + df.groupby("name") + .agg({"number": "sum"}) + .sort_values("number", ascending=False) + .head(10) + .to_pandas() + ) + +Documentation +------------- + +To learn more about BigQuery DataFrames, visit these pages +* `Introduction to BigQuery DataFrames (BigFrames) `_ +* `Sample notebooks `_ +* `API reference `_ +* `Source code (GitHub) `_ License ------- diff --git a/bigframes/_config/__init__.py b/bigframes/_config/__init__.py index db860e6b1d..1302f6cc03 100644 --- a/bigframes/_config/__init__.py +++ b/bigframes/_config/__init__.py @@ -17,148 +17,24 @@ DataFrames from this package. """ -from __future__ import annotations - -import copy -from dataclasses import dataclass, field -import threading -from typing import Optional - -import bigframes_vendored.pandas._config.config as pandas_config - -import bigframes._config.bigquery_options as bigquery_options -import bigframes._config.compute_options as compute_options -import bigframes._config.display_options as display_options -import bigframes._config.experiment_options as experiment_options -import bigframes._config.sampling_options as sampling_options - - -@dataclass -class ThreadLocalConfig(threading.local): - # If unset, global settings will be used - bigquery_options: Optional[bigquery_options.BigQueryOptions] = None - # Note: use default factory instead of default instance so each thread initializes to default values - display_options: display_options.DisplayOptions = field( - default_factory=display_options.DisplayOptions - ) - sampling_options: sampling_options.SamplingOptions = field( - default_factory=sampling_options.SamplingOptions - ) - compute_options: compute_options.ComputeOptions = field( - default_factory=compute_options.ComputeOptions - ) - experiment_options: experiment_options.ExperimentOptions = field( - default_factory=experiment_options.ExperimentOptions - ) - - -class Options: - """Global options affecting BigQuery DataFrames behavior.""" - - def __init__(self): - self._local = ThreadLocalConfig() - - # BigQuery options are special because they can only be set once per - # session, so we need an indicator as to whether we are using the - # thread-local session or the global session. - self._bigquery_options = bigquery_options.BigQueryOptions() - - def _init_bigquery_thread_local(self): - """Initialize thread-local options, based on current global options.""" - - # Already thread-local, so don't reset any options that have been set - # already. No locks needed since this only modifies thread-local - # variables. - if self._local.bigquery_options is not None: - return - - self._local.bigquery_options = copy.deepcopy(self._bigquery_options) - self._local.bigquery_options._session_started = False - - @property - def bigquery(self) -> bigquery_options.BigQueryOptions: - """Options to use with the BigQuery engine. - - Returns: - bigframes._config.bigquery_options.BigQueryOptions: - Options for BigQuery engine. - """ - if self._local.bigquery_options is not None: - # The only way we can get here is if someone called - # _init_bigquery_thread_local. - return self._local.bigquery_options - - return self._bigquery_options - - @property - def display(self) -> display_options.DisplayOptions: - """Options controlling object representation. - - Returns: - bigframes._config.display_options.DisplayOptions: - Options for controlling object representation. - """ - return self._local.display_options - - @property - def sampling(self) -> sampling_options.SamplingOptions: - """Options controlling downsampling when downloading data - to memory. - - The data can be downloaded into memory explicitly - (e.g., to_pandas, to_numpy, values) or implicitly (e.g., - matplotlib plotting). This option can be overridden by - parameters in specific functions. - - Returns: - bigframes._config.sampling_options.SamplingOptions: - Options for controlling downsampling. - """ - return self._local.sampling_options - - @property - def compute(self) -> compute_options.ComputeOptions: - """Thread-local options controlling object computation. - - Returns: - bigframes._config.compute_options.ComputeOptions: - Thread-local options for controlling object computation - """ - return self._local.compute_options - - @property - def experiments(self) -> experiment_options.ExperimentOptions: - """Options controlling experiments - - Returns: - bigframes._config.experiment_options.ExperimentOptions: - Thread-local options for controlling experiments - """ - return self._local.experiment_options - - @property - def is_bigquery_thread_local(self) -> bool: - """Indicator that we're using a thread-local session. - - A thread-local session can be started by using - `with bigframes.option_context("bigquery.some_option", "some-value"):`. - - Returns: - bool: - A boolean value, where a value is True if a thread-local session - is in use; otherwise False. - """ - return self._local.bigquery_options is not None - - -options = Options() -"""Global options for default session.""" - -option_context = pandas_config.option_context +from bigframes._config.bigquery_options import BigQueryOptions +from bigframes._config.compute_options import ComputeOptions +from bigframes._config.display_options import DisplayOptions +from bigframes._config.experiment_options import ExperimentOptions +from bigframes._config.global_options import option_context, Options +import bigframes._config.global_options as global_options +from bigframes._config.sampling_options import SamplingOptions +options = global_options.options +"""Global options for the default session.""" __all__ = ( "Options", "options", "option_context", + "BigQueryOptions", + "ComputeOptions", + "DisplayOptions", + "ExperimentOptions", + "SamplingOptions", ) diff --git a/bigframes/_config/auth.py b/bigframes/_config/auth.py new file mode 100644 index 0000000000..1574fc4883 --- /dev/null +++ b/bigframes/_config/auth.py @@ -0,0 +1,57 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import threading +from typing import Optional + +import google.auth.credentials +import google.auth.transport.requests +import pydata_google_auth + +_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"] + +# Put the lock here rather than in BigQueryOptions so that BigQueryOptions +# remains deepcopy-able. +_AUTH_LOCK = threading.Lock() +_cached_credentials: Optional[google.auth.credentials.Credentials] = None +_cached_project_default: Optional[str] = None + + +def get_default_credentials_with_project() -> tuple[ + google.auth.credentials.Credentials, Optional[str] +]: + global _AUTH_LOCK, _cached_credentials, _cached_project_default + + with _AUTH_LOCK: + if _cached_credentials is not None: + return _cached_credentials, _cached_project_default + + _cached_credentials, _cached_project_default = pydata_google_auth.default( + scopes=_SCOPES, use_local_webserver=False + ) + + # Ensure an access token is available. + _cached_credentials.refresh(google.auth.transport.requests.Request()) + + return _cached_credentials, _cached_project_default + + +def reset_default_credentials_and_project(): + global _AUTH_LOCK, _cached_credentials, _cached_project_default + + with _AUTH_LOCK: + _cached_credentials = None + _cached_project_default = None diff --git a/bigframes/_config/bigquery_options.py b/bigframes/_config/bigquery_options.py index 8fec253b24..648b69dea7 100644 --- a/bigframes/_config/bigquery_options.py +++ b/bigframes/_config/bigquery_options.py @@ -16,14 +16,13 @@ from __future__ import annotations -from typing import Literal, Optional +from typing import Literal, Optional, Sequence, Tuple import warnings -import google.api_core.exceptions import google.auth.credentials -import jellyfish +import requests.adapters -import bigframes.constants +import bigframes._importing import bigframes.enums import bigframes.exceptions as bfe @@ -37,6 +36,7 @@ def _get_validated_location(value: Optional[str]) -> Optional[str]: + import bigframes._tools.strings if value is None or value in bigframes.constants.ALL_BIGQUERY_LOCATIONS: return value @@ -53,13 +53,15 @@ def _get_validated_location(value: Optional[str]) -> Optional[str]: possibility = min( bigframes.constants.ALL_BIGQUERY_LOCATIONS, - key=lambda item: jellyfish.levenshtein_distance(location, item), + key=lambda item: bigframes._tools.strings.levenshtein_distance(location, item), ) # There are many layers before we get to (possibly) the user's code: # -> bpd.options.bigquery.location = "us-central-1" # -> location.setter # -> _get_validated_location - msg = UNKNOWN_LOCATION_MESSAGE.format(location=location, possibility=possibility) + msg = bfe.format_message( + UNKNOWN_LOCATION_MESSAGE.format(location=location, possibility=possibility) + ) warnings.warn(msg, stacklevel=3, category=bfe.UnknownLocationWarning) return value @@ -87,8 +89,13 @@ def __init__( kms_key_name: Optional[str] = None, skip_bq_connection_check: bool = False, *, + allow_large_results: bool = False, ordering_mode: Literal["strict", "partial"] = "strict", client_endpoints_override: Optional[dict] = None, + requests_transport_adapters: Sequence[ + Tuple[str, requests.adapters.BaseAdapter] + ] = (), + enable_polars_execution: bool = False, ): self._credentials = credentials self._project = project @@ -98,6 +105,8 @@ def __init__( self._application_name = application_name self._kms_key_name = kms_key_name self._skip_bq_connection_check = skip_bq_connection_check + self._allow_large_results = allow_large_results + self._requests_transport_adapters = requests_transport_adapters self._session_started = False # Determines the ordering strictness for the session. self._ordering_mode = _validate_ordering_mode(ordering_mode) @@ -106,6 +115,9 @@ def __init__( client_endpoints_override = {} self._client_endpoints_override = client_endpoints_override + if enable_polars_execution: + bigframes._importing.import_polars() + self._enable_polars_execution = enable_polars_execution @property def application_name(self) -> Optional[str]: @@ -159,7 +171,7 @@ def location(self) -> Optional[str]: @location.setter def location(self, value: Optional[str]): - if self._session_started and self._location != value: + if self._session_started and self._location != _get_validated_location(value): raise ValueError(SESSION_STARTED_MESSAGE.format(attribute="location")) self._location = _get_validated_location(value) @@ -232,9 +244,43 @@ def skip_bq_connection_check(self, value: bool): ) self._skip_bq_connection_check = value + @property + def allow_large_results(self) -> bool: + """ + DEPRECATED: Checks the legacy global setting for allowing large results. + Use ``bpd.options.compute.allow_large_results`` instead. + + Warning: Accessing ``bpd.options.bigquery.allow_large_results`` is deprecated + and this property will be removed in a future version. The configuration for + handling large results has moved. + + Returns: + bool: The value of the deprecated setting. + """ + return self._allow_large_results + + @allow_large_results.setter + def allow_large_results(self, value: bool): + warnings.warn( + "Setting `bpd.options.bigquery.allow_large_results` is deprecated, " + "and will be removed in the future. " + "Please use `bpd.options.compute.allow_large_results = ` instead. " + "The `bpd.options.bigquery.allow_large_results` option is ignored if " + "`bpd.options.compute.allow_large_results` is set.", + FutureWarning, + stacklevel=2, + ) + if self._session_started and self._allow_large_results != value: + raise ValueError( + SESSION_STARTED_MESSAGE.format(attribute="allow_large_results") + ) + + self._allow_large_results = value + @property def use_regional_endpoints(self) -> bool: - """Flag to connect to regional API endpoints. + """Flag to connect to regional API endpoints for BigQuery API and + BigQuery Storage API. .. note:: Use of regional endpoints is a feature in Preview and available only @@ -243,18 +289,16 @@ def use_regional_endpoints(self) -> bool: "us-east5", "us-east7", "us-south1", "us-west1", "us-west2", "us-west3" and "us-west4". - .. deprecated:: 0.13.0 - Use of locational endpoints is available only in selected projects. - - Requires that ``location`` is set. For supported regions, for example - ``europe-west3``, you need to specify ``location='europe-west3'`` and - ``use_regional_endpoints=True``, and then BigQuery DataFrames would - connect to the BigQuery endpoint ``bigquery.europe-west3.rep.googleapis.com``. - For not supported regions, for example ``asia-northeast1``, when you - specify ``location='asia-northeast1'`` and ``use_regional_endpoints=True``, - a different endpoint (called locational endpoint, now deprecated, used - to provide weaker promise on the request remaining within the location - during transit) ``europe-west3-bigquery.googleapis.com`` would be used. + Requires that ``location`` is set. For [supported regions](https://cloud.google.com/bigquery/docs/regional-endpoints), + for example ``europe-west3``, you need to specify + ``location='europe-west3'`` and ``use_regional_endpoints=True``, and + then BigQuery DataFrames would connect to the BigQuery endpoint + ``bigquery.europe-west3.rep.googleapis.com``. For not supported regions, + for example ``asia-northeast1``, when you specify + ``location='asia-northeast1'`` and ``use_regional_endpoints=True``, + the global endpoint ``bigquery.googleapis.com`` would be used, which + does not promise any guarantee on the request remaining within the + location during transit. Returns: bool: @@ -272,7 +316,7 @@ def use_regional_endpoints(self, value: bool): ) if value: - msg = ( + msg = bfe.format_message( "Use of regional endpoints is a feature in preview and " "available only in selected regions and projects. " ) @@ -332,7 +376,7 @@ def client_endpoints_override(self) -> dict: @client_endpoints_override.setter def client_endpoints_override(self, value: dict): - msg = ( + msg = bfe.format_message( "This is an advanced configuration option for directly setting endpoints. " "Incorrect use may lead to unexpected behavior or system instability. " "Proceed only if you fully understand its implications." @@ -345,3 +389,62 @@ def client_endpoints_override(self, value: dict): ) self._client_endpoints_override = value + + @property + def requests_transport_adapters( + self, + ) -> Sequence[Tuple[str, requests.adapters.BaseAdapter]]: + """Transport adapters for requests-based REST clients such as the + google-cloud-bigquery package. + + For more details, see the explanation in `requests guide to transport + adapters + `_. + + **Examples:** + + Increase the connection pool size using the requests `HTTPAdapter + `_. + + >>> import bigframes.pandas as bpd + >>> bpd.options.bigquery.requests_transport_adapters = ( + ... ("https://", requests.adapters.HTTPAdapter(pool_maxsize=100)), + ... ("https://", requests.adapters.HTTPAdapter(pool_maxsize=100)), + ... ) # doctest: +SKIP + + Returns: + Sequence[Tuple[str, requests.adapters.BaseAdapter]]: + Prefixes and corresponding transport adapters to `mount + `_ + in requests-based REST clients. + """ + return self._requests_transport_adapters + + @requests_transport_adapters.setter + def requests_transport_adapters( + self, value: Sequence[Tuple[str, requests.adapters.BaseAdapter]] + ) -> None: + if self._session_started and self._requests_transport_adapters != value: + raise ValueError( + SESSION_STARTED_MESSAGE.format(attribute="requests_transport_adapters") + ) + self._requests_transport_adapters = value + + @property + def enable_polars_execution(self) -> bool: + """If True, will use polars to execute some simple query plans locally.""" + return self._enable_polars_execution + + @enable_polars_execution.setter + def enable_polars_execution(self, value: bool): + if self._session_started and self._enable_polars_execution != value: + raise ValueError( + SESSION_STARTED_MESSAGE.format(attribute="enable_polars_execution") + ) + if value is True: + msg = bfe.format_message( + "Polars execution is an experimental feature, and may not be stable. Must have polars installed." + ) + warnings.warn(msg, category=bfe.PreviewWarning) + bigframes._importing.import_polars() + self._enable_polars_execution = value diff --git a/bigframes/_config/compute_options.py b/bigframes/_config/compute_options.py index 21b41eb185..7810ee897f 100644 --- a/bigframes/_config/compute_options.py +++ b/bigframes/_config/compute_options.py @@ -29,7 +29,7 @@ class ComputeOptions: >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") >>> bpd.options.compute.maximum_bytes_billed = 500 - >>> # df.to_pandas() # this should fail + >>> df.to_pandas() # this should fail # doctest: +SKIP google.api_core.exceptions.InternalServerError: 500 Query exceeded limit for bytes billed: 500. 10485760 or higher required. >>> bpd.options.compute.maximum_bytes_billed = None # reset option @@ -53,36 +53,112 @@ class ComputeOptions: >>> del bpd.options.compute.extra_query_labels["test1"] >>> bpd.options.compute.extra_query_labels {'test2': 'abc', 'test3': False} + """ - Attributes: - maximum_bytes_billed (int, Options): - Limits the bytes billed for query jobs. Queries that will have - bytes billed beyond this limit will fail (without incurring a - charge). If unspecified, this will be set to your project default. - See `maximum_bytes_billed`: https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJobConfig#google_cloud_bigquery_job_QueryJobConfig_maximum_bytes_billed. - enable_multi_query_execution (bool, Options): - If enabled, large queries may be factored into multiple smaller queries - in order to avoid generating queries that are too complex for the query - engine to handle. However this comes at the cost of increase cost and latency. - extra_query_labels (Dict[str, Any], Options): - Stores additional custom labels for query configuration. - semmantic_ops_confirmation_threshold (int, optional): - Guards against unexepcted processing of large amount of rows by semantic operators. - If the number of rows exceeds the threshold, the user will be asked to confirm - their operations to resume. The default value is 0. Set the value to None - to turn off the guard. - semantic_ops_threshold_autofail (bool): - Guards against unexepcted processing of large amount of rows by semantic operators. - When set to True, the operation automatically fails without asking for user inputs. + ai_ops_confirmation_threshold: Optional[int] = 0 """ + Guards against unexpected processing of large amount of rows by semantic operators. - maximum_bytes_billed: Optional[int] = None + If the number of rows exceeds the threshold, the user will be asked to confirm + their operations to resume. The default value is 0. Set the value to None + to turn off the guard. + + Returns: + Optional[int]: Number of rows. + """ + + ai_ops_threshold_autofail: bool = False + """ + Guards against unexpected processing of large amount of rows by semantic operators. + + When set to True, the operation automatically fails without asking for user inputs. + + Returns: + bool: True if the guard is enabled. + """ + + allow_large_results: Optional[bool] = None + """ + Specifies whether query results can exceed 10 GB. + + Defaults to False. Setting this to False (the default) restricts results to + 10 GB for potentially faster execution; BigQuery will raise an error if this + limit is exceeded. Setting to True removes this result size limit. + + + Returns: + bool | None: True if results > 10 GB are enabled. + """ enable_multi_query_execution: bool = False + """ + If enabled, large queries may be factored into multiple smaller queries. + + This is in order to avoid generating queries that are too complex for the + query engine to handle. However this comes at the cost of increase cost and + latency. + + + Returns: + bool | None: True if enabled. + """ + extra_query_labels: Dict[str, Any] = dataclasses.field( default_factory=dict, init=False ) + """ + Stores additional custom labels for query configuration. + + Returns: + Dict[str, Any] | None: Additional labels. + """ + + maximum_bytes_billed: Optional[int] = None + """ + Limits the bytes billed for query jobs. + + Queries that will have bytes billed beyond this limit will fail (without + incurring a charge). If unspecified, this will be set to your project + default. See `maximum_bytes_billed`: + https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJobConfig#google_cloud_bigquery_job_QueryJobConfig_maximum_bytes_billed. + + Returns: + int | None: Number of bytes, if set. + """ + + maximum_result_rows: Optional[int] = None + """ + Limits the number of rows in an execution result. + + When converting a BigQuery DataFrames object to a pandas DataFrame or Series + (e.g., using ``.to_pandas()``, ``.peek()``, ``.__repr__()``, direct + iteration), the data is downloaded from BigQuery to the client machine. This + option restricts the number of rows that can be downloaded. If the number + of rows to be downloaded exceeds this limit, a + ``bigframes.exceptions.MaximumResultRowsExceeded`` exception is raised. + + Returns: + int | None: Number of rows, if set. + """ + semantic_ops_confirmation_threshold: Optional[int] = 0 + """ + Deprecated. + + .. deprecated:: 1.42.0 + Semantic operators are deprecated. Please use the functions in + :mod:`bigframes.bigquery.ai` instead. + + """ + semantic_ops_threshold_autofail = False + """ + Deprecated. + + .. deprecated:: 1.42.0 + Semantic operators are deprecated. Please use the functions in + :mod:`bigframes.bigquery.ai` instead. + + """ def assign_extra_query_labels(self, **kwargs: Any) -> None: """ diff --git a/bigframes/_config/display_options.py b/bigframes/_config/display_options.py index 2af07d30a8..34c5c77d57 100644 --- a/bigframes/_config/display_options.py +++ b/bigframes/_config/display_options.py @@ -15,39 +15,29 @@ """Options for displaying objects.""" import contextlib -import dataclasses -from typing import Literal, Optional import bigframes_vendored.pandas.core.config_init as vendored_pandas_config import pandas as pd - -@dataclasses.dataclass -class DisplayOptions: - __doc__ = vendored_pandas_config.display_options_doc - - max_columns: int = 20 - max_rows: int = 25 - progress_bar: Optional[str] = "auto" - repr_mode: Literal["head", "deferred"] = "head" - - max_info_columns: int = 100 - max_info_rows: Optional[int] = 200000 - memory_usage: bool = True +DisplayOptions = vendored_pandas_config.DisplayOptions @contextlib.contextmanager -def pandas_repr(display_options: DisplayOptions): +def pandas_repr(display_options: vendored_pandas_config.DisplayOptions): """Use this when visualizing with pandas. This context manager makes sure we reset the pandas options when we're done so that we don't override pandas behavior. """ with pd.option_context( + "display.max_colwidth", + display_options.max_colwidth, "display.max_columns", display_options.max_columns, "display.max_rows", display_options.max_rows, + "display.precision", + display_options.precision, "display.show_dimensions", True, ) as pandas_context: diff --git a/bigframes/_config/experiment_options.py b/bigframes/_config/experiment_options.py index 69273aef1c..024de392c0 100644 --- a/bigframes/_config/experiment_options.py +++ b/bigframes/_config/experiment_options.py @@ -12,8 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Optional import warnings +import bigframes import bigframes.exceptions as bfe @@ -24,7 +26,7 @@ class ExperimentOptions: def __init__(self): self._semantic_operators: bool = False - self._blob: bool = False + self._ai_operators: bool = False @property def semantic_operators(self) -> bool: @@ -33,23 +35,94 @@ def semantic_operators(self) -> bool: @semantic_operators.setter def semantic_operators(self, value: bool): if value is True: - msg = ( - "Semantic operators are still under experiments, and are subject " + msg = bfe.format_message( + "Semantic operators are deprecated, and will be removed in the future" + ) + warnings.warn(msg, category=FutureWarning) + self._semantic_operators = value + + @property + def ai_operators(self) -> bool: + return self._ai_operators + + @ai_operators.setter + def ai_operators(self, value: bool): + if value is True: + msg = bfe.format_message( + "AI operators are still under experiments, and are subject " "to change in the future." ) warnings.warn(msg, category=bfe.PreviewWarning) - self._semantic_operators = value + self._ai_operators = value @property def blob(self) -> bool: - return self._blob + msg = bfe.format_message( + "BigFrames Blob is in preview now. This flag is no longer needed." + ) + warnings.warn(msg, category=bfe.ApiDeprecationWarning) + return True @blob.setter def blob(self, value: bool): - if value is True: - msg = ( - "BigFrames Blob is still under experiments. It may not work and " - "subject to change in the future." - ) - warnings.warn(msg, category=bfe.PreviewWarning) - self._blob = value + msg = bfe.format_message( + "BigFrames Blob is in preview now. This flag is no longer needed." + ) + warnings.warn(msg, category=bfe.ApiDeprecationWarning) + + @property + def blob_display(self) -> bool: + """Whether to display the blob content in notebook DataFrame preview. Default True.""" + msg = bfe.format_message( + "BigFrames Blob is in preview now. The option has been moved to bigframes.options.display.blob_display." + ) + warnings.warn(msg, category=bfe.ApiDeprecationWarning) + + return bigframes.options.display.blob_display + + @blob_display.setter + def blob_display(self, value: bool): + msg = bfe.format_message( + "BigFrames Blob is in preview now. The option has been moved to bigframes.options.display.blob_display." + ) + warnings.warn(msg, category=bfe.ApiDeprecationWarning) + + bigframes.options.display.blob_display = value + + @property + def blob_display_width(self) -> Optional[int]: + """Width in pixels that the blob constrained to.""" + msg = bfe.format_message( + "BigFrames Blob is in preview now. The option has been moved to bigframes.options.display.blob_display_width." + ) + warnings.warn(msg, category=bfe.ApiDeprecationWarning) + + return bigframes.options.display.blob_display_width + + @blob_display_width.setter + def blob_display_width(self, value: Optional[int]): + msg = bfe.format_message( + "BigFrames Blob is in preview now. The option has been moved to bigframes.options.display.blob_display_width." + ) + warnings.warn(msg, category=bfe.ApiDeprecationWarning) + + bigframes.options.display.blob_display_width = value + + @property + def blob_display_height(self) -> Optional[int]: + """Height in pixels that the blob constrained to.""" + msg = bfe.format_message( + "BigFrames Blob is in preview now. The option has been moved to bigframes.options.display.blob_display_height." + ) + warnings.warn(msg, category=bfe.ApiDeprecationWarning) + + return bigframes.options.display.blob_display_height + + @blob_display_height.setter + def blob_display_height(self, value: Optional[int]): + msg = bfe.format_message( + "BigFrames Blob is in preview now. The option has been moved to bigframes.options.display.blob_display_height." + ) + warnings.warn(msg, category=bfe.ApiDeprecationWarning) + + bigframes.options.display.blob_display_height = value diff --git a/bigframes/_config/global_options.py b/bigframes/_config/global_options.py new file mode 100644 index 0000000000..4a3da6d380 --- /dev/null +++ b/bigframes/_config/global_options.py @@ -0,0 +1,186 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Configuration for BigQuery DataFrames. Do not depend on other parts of BigQuery +DataFrames from this package. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +import threading +from typing import Optional + +import bigframes_vendored.pandas._config.config as pandas_config + +import bigframes._config.bigquery_options as bigquery_options +import bigframes._config.compute_options as compute_options +import bigframes._config.display_options as display_options +import bigframes._config.experiment_options as experiment_options +import bigframes._config.sampling_options as sampling_options + + +@dataclass +class ThreadLocalConfig(threading.local): + # If unset, global settings will be used + bigquery_options: Optional[bigquery_options.BigQueryOptions] = None + # Note: use default factory instead of default instance so each thread initializes to default values + display_options: display_options.DisplayOptions = field( + default_factory=display_options.DisplayOptions + ) + sampling_options: sampling_options.SamplingOptions = field( + default_factory=sampling_options.SamplingOptions + ) + compute_options: compute_options.ComputeOptions = field( + default_factory=compute_options.ComputeOptions + ) + experiment_options: experiment_options.ExperimentOptions = field( + default_factory=experiment_options.ExperimentOptions + ) + + +class Options: + """Global options affecting BigQuery DataFrames behavior. + + Do not construct directly. Instead, refer to + :attr:`bigframes.pandas.options`. + """ + + def __init__(self): + self.reset() + + def reset(self) -> Options: + """Reset the option settings to defaults. + + Returns: + bigframes._config.Options: Options object with default values. + """ + self._local = ThreadLocalConfig() + + # BigQuery options are special because they can only be set once per + # session, so we need an indicator as to whether we are using the + # thread-local session or the global session. + self._bigquery_options = bigquery_options.BigQueryOptions() + return self + + def _init_bigquery_thread_local(self): + """Initialize thread-local options, based on current global options.""" + + # Already thread-local, so don't reset any options that have been set + # already. No locks needed since this only modifies thread-local + # variables. + if self._local.bigquery_options is not None: + return + + self._local.bigquery_options = copy.deepcopy(self._bigquery_options) + self._local.bigquery_options._session_started = False + + @property + def bigquery(self) -> bigquery_options.BigQueryOptions: + """Options to use with the BigQuery engine. + + Returns: + bigframes._config.bigquery_options.BigQueryOptions: + Options for BigQuery engine. + """ + if self._local.bigquery_options is not None: + # The only way we can get here is if someone called + # _init_bigquery_thread_local. + return self._local.bigquery_options + + return self._bigquery_options + + @property + def display(self) -> display_options.DisplayOptions: + """Options controlling object representation. + + Returns: + bigframes._config.display_options.DisplayOptions: + Options for controlling object representation. + """ + return self._local.display_options + + @property + def sampling(self) -> sampling_options.SamplingOptions: + """Options controlling downsampling when downloading data + to memory. + + The data can be downloaded into memory explicitly + (e.g., to_pandas, to_numpy, values) or implicitly (e.g., + matplotlib plotting). This option can be overridden by + parameters in specific functions. + + Returns: + bigframes._config.sampling_options.SamplingOptions: + Options for controlling downsampling. + """ + return self._local.sampling_options + + @property + def compute(self) -> compute_options.ComputeOptions: + """Thread-local options controlling object computation. + + Returns: + bigframes._config.compute_options.ComputeOptions: + Thread-local options for controlling object computation + """ + return self._local.compute_options + + @property + def experiments(self) -> experiment_options.ExperimentOptions: + """Options controlling experiments + + Returns: + bigframes._config.experiment_options.ExperimentOptions: + Thread-local options for controlling experiments + """ + return self._local.experiment_options + + @property + def is_bigquery_thread_local(self) -> bool: + """Indicator that we're using a thread-local session. + + A thread-local session can be started by using + `with bigframes.option_context("bigquery.some_option", "some-value"):`. + + Returns: + bool: + A boolean value, where a value is True if a thread-local session + is in use; otherwise False. + """ + return self._local.bigquery_options is not None + + @property + def _allow_large_results(self) -> bool: + """The effective 'allow_large_results' setting. + + This value is `self.compute.allow_large_results` if set (not `None`), + otherwise it defaults to `self.bigquery.allow_large_results`. + + Returns: + bool: + Whether large query results are permitted. + - `True`: The BigQuery result size limit (e.g., 10 GB) is removed. + - `False`: Results are restricted to this limit (potentially faster). + BigQuery will raise an error if this limit is exceeded. + """ + if self.compute.allow_large_results is None: + return self.bigquery.allow_large_results + return self.compute.allow_large_results + + +options = Options() +option_context = pandas_config.option_context diff --git a/bigframes/_config/sampling_options.py b/bigframes/_config/sampling_options.py index ddb2a49713..107142c3ba 100644 --- a/bigframes/_config/sampling_options.py +++ b/bigframes/_config/sampling_options.py @@ -19,18 +19,46 @@ import dataclasses from typing import Literal, Optional -import bigframes_vendored.pandas.core.config_init as vendored_pandas_config - @dataclasses.dataclass class SamplingOptions: - __doc__ = vendored_pandas_config.sampling_options_doc + """ + Encapsulates the configuration for data sampling. + """ max_download_size: Optional[int] = 500 - # Enable downsampling + """ + Download size threshold in MB. Default 500. + + If value set to None, the download size won't be checked. + """ + enable_downsampling: bool = False + """ + Whether to enable downsampling. Default False. + + If max_download_size is exceeded when downloading data (e.g., to_pandas()), + the data will be downsampled if enable_downsampling is True, otherwise, an + error will be raised. + """ + sampling_method: Literal["head", "uniform"] = "uniform" + """ + Downsampling algorithms to be chosen from. Default "uniform". + + The choices are: "head": This algorithm returns a portion of the data from + the beginning. It is fast and requires minimal computations to perform the + downsampling.; "uniform": This algorithm returns uniform random samples of + the data. + """ + random_state: Optional[int] = None + """ + The seed for the uniform downsampling algorithm. Default None. + + If provided, the uniform method may take longer to execute and require more + computation. + """ def with_max_download_size(self, max_rows: Optional[int]) -> SamplingOptions: """Configures the maximum download size for data sampling in MB diff --git a/bigframes/_importing.py b/bigframes/_importing.py new file mode 100644 index 0000000000..e88bd77fe8 --- /dev/null +++ b/bigframes/_importing.py @@ -0,0 +1,35 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import importlib +from types import ModuleType + +import numpy +from packaging import version + +# Keep this in sync with setup.py +POLARS_MIN_VERSION = version.Version("1.7.0") + + +def import_polars() -> ModuleType: + polars_module = importlib.import_module("polars") + # Check for necessary methods instead of the version number because we + # can't trust the polars version until + # https://github.com/pola-rs/polars/issues/23940 is fixed. + try: + polars_module.lit(numpy.int64(100), dtype=polars_module.Int64()) + except TypeError: + raise ImportError( + f"Imported polars version is likely below the minimum version: {POLARS_MIN_VERSION}" + ) + return polars_module diff --git a/.github/.OwlBot.lock.yaml b/bigframes/_tools/__init__.py similarity index 67% rename from .github/.OwlBot.lock.yaml rename to bigframes/_tools/__init__.py index 4c0027ff1c..ea3bc209d0 100644 --- a/.github/.OwlBot.lock.yaml +++ b/bigframes/_tools/__init__.py @@ -4,14 +4,16 @@ # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -docker: - image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:04c35dc5f49f0f503a306397d6d043685f8d2bb822ab515818c4208d7fb2db3a -# created: 2025-01-16T15:24:11.364245182Z + +"""_tools is a collection of helper functions with minimal dependencies. + +Please keep the dependencies used in this subpackage to a minimum to avoid the +risk of circular dependencies. +""" diff --git a/bigframes/_tools/strings.py b/bigframes/_tools/strings.py new file mode 100644 index 0000000000..3d9402c68f --- /dev/null +++ b/bigframes/_tools/strings.py @@ -0,0 +1,66 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Helper methods for processing strings with minimal dependencies. + +Please keep the dependencies used in this subpackage to a minimum to avoid the +risk of circular dependencies. +""" + +import numpy + + +def levenshtein_distance(left: str, right: str) -> int: + """Compute the edit distance between two strings. + + This is the minumum number of substitutions, insertions, deletions + to get from left string to right string. See: + https://en.wikipedia.org/wiki/Levenshtein_distance + """ + # TODO(tswast): accelerate with numba (if available) if we end up using this + # function in contexts other than when raising an exception or there are too + # many values to compare even in that context. + + distances0 = numpy.zeros(len(right) + 1) + distances1 = numpy.zeros(len(right) + 1) + + # Maximum distance is to drop all characters and then add the other string. + distances0[:] = range(len(right) + 1) + + for left_index in range(len(left)): + # Calculate distance from distances0 to distances1. + + # Edit distance is to delete (i + 1) chars from left to match empty right + distances1[0] = left_index + 1 + # "ab" + for right_index in range(len(right)): + left_char = left[left_index] + right_char = right[right_index] + + deletion_cost = distances0[right_index + 1] + 1 + insertion_cost = distances1[right_index] + 1 + if left_char == right_char: + substitution_cost = distances0[right_index] + else: + substitution_cost = distances0[right_index] + 1 + + distances1[right_index + 1] = min( + deletion_cost, insertion_cost, substitution_cost + ) + + temp = distances0 + distances0 = distances1 + distances1 = temp + + return distances0[len(right)] diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index 56aee38bfe..f835285a21 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -16,6 +16,9 @@ such as array functions: https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions. """ +import sys + +from bigframes.bigquery import ai, ml from bigframes.bigquery._operations.approx_agg import approx_top_count from bigframes.bigquery._operations.array import ( array_agg, @@ -27,33 +30,124 @@ unix_millis, unix_seconds, ) -from bigframes.bigquery._operations.geo import st_area +from bigframes.bigquery._operations.geo import ( + st_area, + st_buffer, + st_centroid, + st_convexhull, + st_difference, + st_distance, + st_intersection, + st_isclosed, + st_length, + st_regionstats, + st_simplify, +) from bigframes.bigquery._operations.json import ( json_extract, json_extract_array, json_extract_string_array, + json_keys, + json_query, + json_query_array, json_set, + json_value, + json_value_array, parse_json, + to_json, + to_json_string, ) from bigframes.bigquery._operations.search import create_vector_index, vector_search from bigframes.bigquery._operations.sql import sql_scalar from bigframes.bigquery._operations.struct import struct +from bigframes.core import log_adapter + +_functions = [ + # approximate aggregate ops + approx_top_count, + # array ops + array_agg, + array_length, + array_to_string, + # datetime ops + unix_micros, + unix_millis, + unix_seconds, + # geo ops + st_area, + st_buffer, + st_centroid, + st_convexhull, + st_difference, + st_distance, + st_intersection, + st_isclosed, + st_length, + st_regionstats, + st_simplify, + # json ops + json_extract, + json_extract_array, + json_extract_string_array, + json_query, + json_query_array, + json_set, + json_value, + json_value_array, + parse_json, + to_json, + to_json_string, + # search ops + create_vector_index, + vector_search, + # sql ops + sql_scalar, + # struct ops + struct, +] + +_module = sys.modules[__name__] +for f in _functions: + _decorated_object = log_adapter.method_logger(f, custom_base_name="bigquery") + setattr(_module, f.__name__, _decorated_object) + del f __all__ = [ # approximate aggregate ops "approx_top_count", # array ops - "array_length", "array_agg", + "array_length", "array_to_string", + # datetime ops + "unix_micros", + "unix_millis", + "unix_seconds", # geo ops "st_area", + "st_buffer", + "st_centroid", + "st_convexhull", + "st_difference", + "st_distance", + "st_intersection", + "st_isclosed", + "st_length", + "st_regionstats", + "st_simplify", # json ops - "json_set", "json_extract", "json_extract_array", "json_extract_string_array", + "json_keys", + "json_query", + "json_query_array", + "json_set", + "json_value", + "json_value_array", "parse_json", + "to_json", + "to_json_string", # search ops "create_vector_index", "vector_search", @@ -61,8 +155,7 @@ "sql_scalar", # struct ops "struct", - # datetime ops - "unix_micros", - "unix_millis", - "unix_seconds", + # Modules / SQL namespaces + "ai", + "ml", ] diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py new file mode 100644 index 0000000000..e8c28e61f5 --- /dev/null +++ b/bigframes/bigquery/_operations/ai.py @@ -0,0 +1,704 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module integrates BigQuery built-in AI functions for use with Series/DataFrame objects, +such as AI.GENERATE_BOOL: +https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-ai-generate-bool""" + +from __future__ import annotations + +import json +from typing import Any, Iterable, List, Literal, Mapping, Tuple, Union + +import pandas as pd + +from bigframes import clients, dataframe, dtypes +from bigframes import pandas as bpd +from bigframes import series, session +from bigframes.core import convert, log_adapter +from bigframes.ml import core as ml_core +from bigframes.operations import ai_ops, output_schemas + +PROMPT_TYPE = Union[ + str, + series.Series, + pd.Series, + List[Union[str, series.Series, pd.Series]], + Tuple[Union[str, series.Series, pd.Series], ...], +] + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def generate( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, + endpoint: str | None = None, + request_type: Literal["dedicated", "shared", "unspecified"] = "unspecified", + model_params: Mapping[Any, Any] | None = None, + output_schema: Mapping[str, str] | None = None, +) -> series.Series: + """ + Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> country = bpd.Series(["Japan", "Canada"]) + >>> bbq.ai.generate(("What's the capital city of ", country, " one word only")) + 0 {'result': 'Tokyo\\n', 'full_response': '{"cand... + 1 {'result': 'Ottawa\\n', 'full_response': '{"can... + dtype: struct>, status: string>[pyarrow] + + >>> bbq.ai.generate(("What's the capital city of ", country, " one word only")).struct.field("result") + 0 Tokyo\\n + 1 Ottawa\\n + Name: result, dtype: string + + You get structured output when the `output_schema` parameter is set: + + >>> animals = bpd.Series(["Rabbit", "Spider"]) + >>> bbq.ai.generate(animals, output_schema={"number_of_legs": "INT64", "is_herbivore": "BOOL"}) + 0 {'is_herbivore': True, 'number_of_legs': 4, 'f... + 1 {'is_herbivore': False, 'number_of_legs': 8, '... + dtype: struct>, status: string>[pyarrow] + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the query uses your end-user credential. + endpoint (str, optional): + Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any + generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and + uses the full endpoint of the model. If you don't specify an ENDPOINT value, BigQuery ML selects a recent stable + version of Gemini to use. + request_type (Literal["dedicated", "shared", "unspecified"]): + Specifies the type of inference request to send to the Gemini model. The request type determines what quota the request uses. + * "dedicated": function only uses Provisioned Throughput quota. The function returns the error Provisioned throughput is not + purchased or is not active if Provisioned Throughput quota isn't available. + * "shared": the function only uses dynamic shared quota (DSQ), even if you have purchased Provisioned Throughput quota. + * "unspecified": If you haven't purchased Provisioned Throughput quota, the function uses DSQ quota. + If you have purchased Provisioned Throughput quota, the function uses the Provisioned Throughput quota first. + If requests exceed the Provisioned Throughput quota, the overflow traffic uses DSQ quota. + model_params (Mapping[Any, Any]): + Provides additional parameters to the model. The MODEL_PARAMS value must conform to the generateContent request body format. + output_schema (Mapping[str, str]): + A mapping value that specifies the schema of the output, in the form {field_name: data_type}. Supported data types include + `STRING`, `INT64`, `FLOAT64`, `BOOL`, `ARRAY`, and `STRUCT`. + + Returns: + bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: + * "result": a STRING value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. + If you specify an output schema then result is replaced by your custom schema. + * "full_response": a JSON value containing the response from the projects.locations.endpoints.generateContent call to the model. + The generated text is in the text element. + * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + if output_schema is None: + output_schema_str = None + else: + output_schema_str = ", ".join( + [f"{name} {sql_type}" for name, sql_type in output_schema.items()] + ) + # Validate user input + output_schemas.parse_sql_fields(output_schema_str) + + operator = ai_ops.AIGenerate( + prompt_context=tuple(prompt_context), + connection_id=connection_id, + endpoint=endpoint, + request_type=request_type, + model_params=json.dumps(model_params) if model_params else None, + output_schema=output_schema_str, + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def generate_bool( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, + endpoint: str | None = None, + request_type: Literal["dedicated", "shared", "unspecified"] = "unspecified", + model_params: Mapping[Any, Any] | None = None, +) -> series.Series: + """ + Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> df = bpd.DataFrame({ + ... "col_1": ["apple", "bear", "pear"], + ... "col_2": ["fruit", "animal", "animal"] + ... }) + >>> bbq.ai.generate_bool((df["col_1"], " is a ", df["col_2"])) + 0 {'result': True, 'full_response': '{"candidate... + 1 {'result': True, 'full_response': '{"candidate... + 2 {'result': False, 'full_response': '{"candidat... + dtype: struct>, status: string>[pyarrow] + + >>> bbq.ai.generate_bool((df["col_1"], " is a ", df["col_2"])).struct.field("result") + 0 True + 1 True + 2 False + Name: result, dtype: boolean + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the query uses your end-user credential. + endpoint (str, optional): + Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any + generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and + uses the full endpoint of the model. If you don't specify an ENDPOINT value, BigQuery ML selects a recent stable + version of Gemini to use. + request_type (Literal["dedicated", "shared", "unspecified"]): + Specifies the type of inference request to send to the Gemini model. The request type determines what quota the request uses. + * "dedicated": function only uses Provisioned Throughput quota. The function returns the error Provisioned throughput is not + purchased or is not active if Provisioned Throughput quota isn't available. + * "shared": the function only uses dynamic shared quota (DSQ), even if you have purchased Provisioned Throughput quota. + * "unspecified": If you haven't purchased Provisioned Throughput quota, the function uses DSQ quota. + If you have purchased Provisioned Throughput quota, the function uses the Provisioned Throughput quota first. + If requests exceed the Provisioned Throughput quota, the overflow traffic uses DSQ quota. + model_params (Mapping[Any, Any]): + Provides additional parameters to the model. The MODEL_PARAMS value must conform to the generateContent request body format. + + Returns: + bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: + * "result": a BOOL value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. + * "full_response": a JSON value containing the response from the projects.locations.endpoints.generateContent call to the model. + The generated text is in the text element. + * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIGenerateBool( + prompt_context=tuple(prompt_context), + connection_id=connection_id, + endpoint=endpoint, + request_type=request_type, + model_params=json.dumps(model_params) if model_params else None, + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def generate_int( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, + endpoint: str | None = None, + request_type: Literal["dedicated", "shared", "unspecified"] = "unspecified", + model_params: Mapping[Any, Any] | None = None, +) -> series.Series: + """ + Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> animal = bpd.Series(["Kangaroo", "Rabbit", "Spider"]) + >>> bbq.ai.generate_int(("How many legs does a ", animal, " have?")) + 0 {'result': 2, 'full_response': '{"candidates":... + 1 {'result': 4, 'full_response': '{"candidates":... + 2 {'result': 8, 'full_response': '{"candidates":... + dtype: struct>, status: string>[pyarrow] + + >>> bbq.ai.generate_int(("How many legs does a ", animal, " have?")).struct.field("result") + 0 2 + 1 4 + 2 8 + Name: result, dtype: Int64 + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the query uses your end-user credential. + endpoint (str, optional): + Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any + generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and + uses the full endpoint of the model. If you don't specify an ENDPOINT value, BigQuery ML selects a recent stable + version of Gemini to use. + request_type (Literal["dedicated", "shared", "unspecified"]): + Specifies the type of inference request to send to the Gemini model. The request type determines what quota the request uses. + * "dedicated": function only uses Provisioned Throughput quota. The function returns the error Provisioned throughput is not + purchased or is not active if Provisioned Throughput quota isn't available. + * "shared": the function only uses dynamic shared quota (DSQ), even if you have purchased Provisioned Throughput quota. + * "unspecified": If you haven't purchased Provisioned Throughput quota, the function uses DSQ quota. + If you have purchased Provisioned Throughput quota, the function uses the Provisioned Throughput quota first. + If requests exceed the Provisioned Throughput quota, the overflow traffic uses DSQ quota. + model_params (Mapping[Any, Any]): + Provides additional parameters to the model. The MODEL_PARAMS value must conform to the generateContent request body format. + + Returns: + bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: + * "result": an integer (INT64) value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. + * "full_response": a JSON value containing the response from the projects.locations.endpoints.generateContent call to the model. + The generated text is in the text element. + * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIGenerateInt( + prompt_context=tuple(prompt_context), + connection_id=connection_id, + endpoint=endpoint, + request_type=request_type, + model_params=json.dumps(model_params) if model_params else None, + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def generate_double( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, + endpoint: str | None = None, + request_type: Literal["dedicated", "shared", "unspecified"] = "unspecified", + model_params: Mapping[Any, Any] | None = None, +) -> series.Series: + """ + Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> animal = bpd.Series(["Kangaroo", "Rabbit", "Spider"]) + >>> bbq.ai.generate_double(("How many legs does a ", animal, " have?")) + 0 {'result': 2.0, 'full_response': '{"candidates... + 1 {'result': 4.0, 'full_response': '{"candidates... + 2 {'result': 8.0, 'full_response': '{"candidates... + dtype: struct>, status: string>[pyarrow] + + >>> bbq.ai.generate_double(("How many legs does a ", animal, " have?")).struct.field("result") + 0 2.0 + 1 4.0 + 2 8.0 + Name: result, dtype: Float64 + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the query uses your end-user credential. + endpoint (str, optional): + Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any + generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and + uses the full endpoint of the model. If you don't specify an ENDPOINT value, BigQuery ML selects a recent stable + version of Gemini to use. + request_type (Literal["dedicated", "shared", "unspecified"]): + Specifies the type of inference request to send to the Gemini model. The request type determines what quota the request uses. + * "dedicated": function only uses Provisioned Throughput quota. The function returns the error Provisioned throughput is not + purchased or is not active if Provisioned Throughput quota isn't available. + * "shared": the function only uses dynamic shared quota (DSQ), even if you have purchased Provisioned Throughput quota. + * "unspecified": If you haven't purchased Provisioned Throughput quota, the function uses DSQ quota. + If you have purchased Provisioned Throughput quota, the function uses the Provisioned Throughput quota first. + If requests exceed the Provisioned Throughput quota, the overflow traffic uses DSQ quota. + model_params (Mapping[Any, Any]): + Provides additional parameters to the model. The MODEL_PARAMS value must conform to the generateContent request body format. + + Returns: + bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: + * "result": an DOUBLE value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. + * "full_response": a JSON value containing the response from the projects.locations.endpoints.generateContent call to the model. + The generated text is in the text element. + * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIGenerateDouble( + prompt_context=tuple(prompt_context), + connection_id=connection_id, + endpoint=endpoint, + request_type=request_type, + model_params=json.dumps(model_params) if model_params else None, + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def if_( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, +) -> series.Series: + """ + Evaluates the prompt to True or False. Compared to `ai.generate_bool()`, this function + provides optimization such that not all rows are evaluated with the LLM. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> us_state = bpd.Series(["Massachusetts", "Illinois", "Hawaii"]) + >>> bbq.ai.if_((us_state, " has a city called Springfield")) + 0 True + 1 True + 2 False + dtype: boolean + + >>> us_state[bbq.ai.if_((us_state, " has a city called Springfield"))] + 0 Massachusetts + 1 Illinois + dtype: string + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + + Returns: + bigframes.series.Series: A new series of bools. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIIf( + prompt_context=tuple(prompt_context), + connection_id=_resolve_connection_id(series_list[0], connection_id), + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def classify( + input: PROMPT_TYPE, + categories: tuple[str, ...] | list[str], + *, + connection_id: str | None = None, +) -> series.Series: + """ + Classifies a given input into one of the specified categories. It will always return one of the provided categories best fit the prompt input. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> df = bpd.DataFrame({'creature': ['Cat', 'Salmon']}) + >>> df['type'] = bbq.ai.classify(df['creature'], ['Mammal', 'Fish']) + >>> df + creature type + 0 Cat Mammal + 1 Salmon Fish + + [2 rows x 2 columns] + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + input (str | Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the input to send to the model. The Series can be BigFrames Series + or pandas Series. + categories (tuple[str, ...] | list[str]): + Categories to classify the input into. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + + Returns: + bigframes.series.Series: A new series of strings. + """ + + prompt_context, series_list = _separate_context_and_series(input) + assert len(series_list) > 0 + + operator = ai_ops.AIClassify( + prompt_context=tuple(prompt_context), + categories=tuple(categories), + connection_id=_resolve_connection_id(series_list[0], connection_id), + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def score( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, +) -> series.Series: + """ + Computes a score based on rubrics described in natural language. It will return a double value. + There is no fixed range for the score returned. To get high quality results, provide a scoring + rubric with examples in the prompt. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> animal = bpd.Series(["Tiger", "Rabbit", "Blue Whale"]) + >>> bbq.ai.score(("Rank the relative weights of ", animal, " on the scale from 1 to 3")) # doctest: +SKIP + 0 2.0 + 1 1.0 + 2 3.0 + dtype: Float64 + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + + Returns: + bigframes.series.Series: A new series of double (float) values. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIScore( + prompt_context=tuple(prompt_context), + connection_id=_resolve_connection_id(series_list[0], connection_id), + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def forecast( + df: dataframe.DataFrame | pd.DataFrame, + *, + data_col: str, + timestamp_col: str, + model: str = "TimesFM 2.0", + id_cols: Iterable[str] | None = None, + horizon: int = 10, + confidence_level: float = 0.95, + context_window: int | None = None, +) -> dataframe.DataFrame: + """ + Forecast time series at future horizon. Using Google Research's open source TimesFM(https://github.com/google-research/timesfm) model. + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + df (DataFrame): + The dataframe that contains the data that you want to forecast. It could be either a BigFrames Dataframe or + a pandas DataFrame. If it's a pandas DataFrame, the global BigQuery session will be used to load the data. + data_col (str): + A str value that specifies the name of the data column. The data column contains the data to forecast. + The data column must use one of the following data types: INT64, NUMERIC and FLOAT64 + timestamp_col (str): + A str value that specified the name of the time points column. + The time points column provides the time points used to generate the forecast. + The time points column must use one of the following data types: TIMESTAMP, DATE and DATETIME + model (str, default "TimesFM 2.0"): + A str value that specifies the name of the model. TimesFM 2.0 is the only supported value, and is the default value. + id_cols (Iterable[str], optional): + An iterable of str value that specifies the names of one or more ID columns. Each ID identifies a unique time series to forecast. + Specify one or more values for this argument in order to forecast multiple time series using a single query. + The columns that you specify must use one of the following data types: STRING, INT64, ARRAY and ARRAY + horizon (int, default 10): + An int value that specifies the number of time points to forecast. The default value is 10. The valid input range is [1, 10,000]. + confidence_level (float, default 0.95): + A FLOAT64 value that specifies the percentage of the future values that fall in the prediction interval. + The default value is 0.95. The valid input range is [0, 1). + context_window (int, optional): + An int value that specifies the context window length used by BigQuery ML's built-in TimesFM model. + The context window length determines how many of the most recent data points from the input time series are use by the model. + If you don't specify a value, the AI.FORECAST function automatically chooses the smallest possible context window length to use + that is still large enough to cover the number of time series data points in your input data. + + Returns: + DataFrame: + The forecast dataframe matches that of the BigQuery AI.FORECAST function. + See: https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-ai-forecast + + Raises: + ValueError: when any column ID does not exist in the dataframe. + """ + + if isinstance(df, pd.DataFrame): + # Load the pandas DataFrame with global session + df = bpd.read_pandas(df) + + columns = [timestamp_col, data_col] + if id_cols: + columns += id_cols + for column in columns: + if column not in df.columns: + raise ValueError(f"Column `{column}` not found") + + options: dict[str, Union[int, float, str, Iterable[str]]] = { + "data_col": data_col, + "timestamp_col": timestamp_col, + "model": model, + "horizon": horizon, + "confidence_level": confidence_level, + } + if id_cols: + options["id_cols"] = id_cols + if context_window: + options["context_window"] = context_window + + return ml_core.BaseBqml(df._session).ai_forecast(input_data=df, options=options) + + +def _separate_context_and_series( + prompt: PROMPT_TYPE, +) -> Tuple[List[str | None], List[series.Series]]: + """ + Returns the two values. The first value is the prompt with all series replaced by None. The second value is all the series + in the prompt. The original item order is kept. + For example: + Input: ("str1", series1, "str2", "str3", series2) + Output: ["str1", None, "str2", "str3", None], [series1, series2] + """ + if not isinstance(prompt, (str, list, tuple, series.Series)): + raise ValueError(f"Unsupported prompt type: {type(prompt)}") + + if isinstance(prompt, str): + return [None], [series.Series([prompt])] + + if isinstance(prompt, series.Series): + if prompt.dtype == dtypes.OBJ_REF_DTYPE: + # Multi-model support + return [None], [prompt.blob.read_url()] + return [None], [prompt] + + prompt_context: List[str | None] = [] + series_list: List[series.Series | pd.Series] = [] + + session = None + for item in prompt: + if isinstance(item, str): + prompt_context.append(item) + + elif isinstance(item, (series.Series, pd.Series)): + prompt_context.append(None) + + if isinstance(item, series.Series) and session is None: + # Use the first available BF session if there's any. + session = item._session + series_list.append(item) + + else: + raise TypeError(f"Unsupported type in prompt: {type(item)}") + + if not series_list: + raise ValueError("Please provide at least one Series in the prompt") + + converted_list = [_convert_series(s, session) for s in series_list] + + return prompt_context, converted_list + + +def _convert_series( + s: series.Series | pd.Series, session: session.Session | None +) -> series.Series: + result = convert.to_bf_series(s, default_index=None, session=session) + + if result.dtype == dtypes.OBJ_REF_DTYPE: + # Support multimodel + return result.blob.read_url() + return result + + +def _resolve_connection_id(series: series.Series, connection_id: str | None): + return clients.get_canonical_bq_connection_id( + connection_id or series._session._bq_connection, + series._session._project, + series._session._location, + ) diff --git a/bigframes/bigquery/_operations/approx_agg.py b/bigframes/bigquery/_operations/approx_agg.py index 696f8f5a66..73b6fdbb73 100644 --- a/bigframes/bigquery/_operations/approx_agg.py +++ b/bigframes/bigquery/_operations/approx_agg.py @@ -40,7 +40,6 @@ def approx_top_count( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["apple", "apple", "pear", "pear", "pear", "banana"]) >>> bbq.approx_top_count(s, number=2) [{'value': 'pear', 'count': 3}, {'value': 'apple', 'count': 2}] diff --git a/bigframes/bigquery/_operations/array.py b/bigframes/bigquery/_operations/array.py index 4af1416127..6f9dd20b54 100644 --- a/bigframes/bigquery/_operations/array.py +++ b/bigframes/bigquery/_operations/array.py @@ -40,7 +40,6 @@ def array_length(series: series.Series) -> series.Series: >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([[1, 2, 8, 3], [], [3, 4]]) >>> bbq.array_length(s) @@ -78,8 +77,6 @@ def array_agg( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> import numpy as np - >>> bpd.options.display.progress_bar = None For a SeriesGroupBy object: @@ -128,8 +125,6 @@ def array_to_string(series: series.Series, delimiter: str) -> series.Series: >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([["H", "i", "!"], ["Hello", "World"], np.nan, [], ["Hi"]]) >>> bbq.array_to_string(s, delimiter=", ") diff --git a/bigframes/bigquery/_operations/datetime.py b/bigframes/bigquery/_operations/datetime.py index f8767336dd..99467beb06 100644 --- a/bigframes/bigquery/_operations/datetime.py +++ b/bigframes/bigquery/_operations/datetime.py @@ -21,10 +21,8 @@ def unix_seconds(input: series.Series) -> series.Series: **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timestamp("1970-01-02", tz="UTC"), pd.Timestamp("1970-01-03", tz="UTC")]) >>> bbq.unix_seconds(s) @@ -48,10 +46,8 @@ def unix_millis(input: series.Series) -> series.Series: **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timestamp("1970-01-02", tz="UTC"), pd.Timestamp("1970-01-03", tz="UTC")]) >>> bbq.unix_millis(s) @@ -75,10 +71,8 @@ def unix_micros(input: series.Series) -> series.Series: **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timestamp("1970-01-02", tz="UTC"), pd.Timestamp("1970-01-03", tz="UTC")]) >>> bbq.unix_micros(s) diff --git a/bigframes/bigquery/_operations/geo.py b/bigframes/bigquery/_operations/geo.py index 7b8e47e2da..f0fda99a16 100644 --- a/bigframes/bigquery/_operations/geo.py +++ b/bigframes/bigquery/_operations/geo.py @@ -14,7 +14,13 @@ from __future__ import annotations +import json +from typing import Mapping, Optional, Union + +import shapely # type: ignore + from bigframes import operations as ops +import bigframes.dataframe import bigframes.geopandas import bigframes.series @@ -24,17 +30,19 @@ """ -def st_area(series: bigframes.series.Series) -> bigframes.series.Series: +def st_area( + series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], +) -> bigframes.series.Series: """ Returns the area in square meters covered by the polygons in the input - GEOGRAPHY. + `GEOGRAPHY`. If geography_expression is a point or a line, returns zero. If geography_expression is a collection, returns the area of the polygons in the collection; if the collection doesn't contain polygons, returns zero. - ..note:: + .. note:: BigQuery's Geography functions, like `st_area`, interpret the geometry data type as a point set on the Earth's surface. A point set is a set of points, lines, and polygons on the WGS84 reference spheroid, with @@ -47,7 +55,6 @@ def st_area(series: bigframes.series.Series) -> bigframes.series.Series: >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Polygon, LineString, Point - >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ @@ -84,6 +91,10 @@ def st_area(series: bigframes.series.Series) -> bigframes.series.Series: 4 0.0 dtype: Float64 + Args: + series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries): + A series containing geography objects. + Returns: bigframes.pandas.Series: Series of float representing the areas. @@ -91,3 +102,657 @@ def st_area(series: bigframes.series.Series) -> bigframes.series.Series: series = series._apply_unary_op(ops.geo_area_op) series.name = None return series + + +def st_buffer( + series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], + buffer_radius: float, + num_seg_quarter_circle: float = 8.0, + use_spheroid: bool = False, +) -> bigframes.series.Series: + """ + Computes a `GEOGRAPHY` that represents all points whose distance from the + input `GEOGRAPHY` is less than or equal to `distance` meters. + + .. note:: + BigQuery's Geography functions, like `st_buffer`, interpret the geometry + data type as a point set on the Earth's surface. A point set is a set + of points, lines, and polygons on the WGS84 reference spheroid, with + geodesic edges. See: https://cloud.google.com/bigquery/docs/geospatial-data + + **Examples:** + + >>> import bigframes.geopandas + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> from shapely.geometry import Point + + >>> series = bigframes.geopandas.GeoSeries( + ... [ + ... Point(0, 0), + ... Point(1, 1), + ... ] + ... ) + >>> series + 0 POINT (0 0) + 1 POINT (1 1) + dtype: geometry + + >>> buffer = bbq.st_buffer(series, 100) + >>> bbq.st_area(buffer) > 0 + 0 True + 1 True + dtype: boolean + + Args: + series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries): + A series containing geography objects. + buffer_radius (float): + The distance in meters. + num_seg_quarter_circle (float, optional): + Specifies the number of segments that are used to approximate a + quarter circle. The default value is 8.0. + use_spheroid (bool, optional): + Determines how this function measures distance. If use_spheroid is + FALSE, the function measures distance on the surface of a perfect + sphere. The use_spheroid parameter currently only supports the + value FALSE. The default value of use_spheroid is FALSE. + + Returns: + bigframes.pandas.Series: + A series of geography objects representing the buffered geometries. + """ + op = ops.GeoStBufferOp( + buffer_radius=buffer_radius, + num_seg_quarter_circle=num_seg_quarter_circle, + use_spheroid=use_spheroid, + ) + series = series._apply_unary_op(op) + series.name = None + return series + + +def st_centroid( + series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], +) -> bigframes.series.Series: + """ + Computes the geometric centroid of a `GEOGRAPHY` type. + + For `POINT` and `MULTIPOINT` types, this is the arithmetic mean of the + input coordinates. For `LINESTRING` and `POLYGON` types, this is the + center of mass. For `GEOMETRYCOLLECTION` types, this is the center of + mass of the collection's elements. + + .. note:: + BigQuery's Geography functions, like `st_centroid`, interpret the geometry + data type as a point set on the Earth's surface. A point set is a set + of points, lines, and polygons on the WGS84 reference spheroid, with + geodesic edges. See: https://cloud.google.com/bigquery/docs/geospatial-data + + **Examples:** + + >>> import bigframes.geopandas + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> from shapely.geometry import Polygon, LineString, Point + + >>> series = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0.0, 0.0), (0.1, 0.1), (0.0, 0.1)]), + ... LineString([(0, 0), (1, 1), (0, 1)]), + ... Point(0, 1), + ... ] + ... ) + >>> series + 0 POLYGON ((0 0, 0.1 0.1, 0 0.1, 0 0)) + 1 LINESTRING (0 0, 1 1, 0 1) + 2 POINT (0 1) + dtype: geometry + + >>> bbq.st_centroid(series) + 0 POINT (0.03333 0.06667) + 1 POINT (0.49998 0.70712) + 2 POINT (0 1) + dtype: geometry + + Args: + series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries): + A series containing geography objects. + + Returns: + bigframes.pandas.Series: + A series of geography objects representing the centroids. + """ + series = series._apply_unary_op(ops.geo_st_centroid_op) + series.name = None + return series + + +def st_convexhull( + series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], +) -> bigframes.series.Series: + """ + Computes the convex hull of a `GEOGRAPHY` type. + + The convex hull is the smallest convex set that contains all of the + points in the input `GEOGRAPHY`. + + .. note:: + BigQuery's Geography functions, like `st_convexhull`, interpret the geometry + data type as a point set on the Earth's surface. A point set is a set + of points, lines, and polygons on the WGS84 reference spheroid, with + geodesic edges. See: https://cloud.google.com/bigquery/docs/geospatial-data + + **Examples:** + + >>> import bigframes.geopandas + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> from shapely.geometry import Polygon, LineString, Point + + >>> series = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0.0, 0.0), (0.1, 0.1), (0.0, 0.1)]), + ... LineString([(0, 0), (1, 1), (0, 1)]), + ... Point(0, 1), + ... ] + ... ) + >>> series + 0 POLYGON ((0 0, 0.1 0.1, 0 0.1, 0 0)) + 1 LINESTRING (0 0, 1 1, 0 1) + 2 POINT (0 1) + dtype: geometry + + >>> bbq.st_convexhull(series) + 0 POLYGON ((0 0, 0.1 0.1, 0 0.1, 0 0)) + 1 POLYGON ((0 0, 1 1, 0 1, 0 0)) + 2 POINT (0 1) + dtype: geometry + + Args: + series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries): + A series containing geography objects. + + Returns: + bigframes.pandas.Series: + A series of geography objects representing the convex hulls. + """ + series = series._apply_unary_op(ops.geo_st_convexhull_op) + series.name = None + return series + + +def st_difference( + series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], + other: Union[ + bigframes.series.Series, + bigframes.geopandas.GeoSeries, + shapely.geometry.base.BaseGeometry, + ], +) -> bigframes.series.Series: + """ + Returns a `GEOGRAPHY` that represents the point set difference of + `geography_1` and `geography_2`. Therefore, the result consists of the part + of `geography_1` that doesn't intersect with `geography_2`. + + If `geometry_1` is completely contained in `geometry_2`, then `ST_DIFFERENCE` + returns an empty `GEOGRAPHY`. + + .. note:: + BigQuery's Geography functions, like `st_difference`, interpret the geometry + data type as a point set on the Earth's surface. A point set is a set + of points, lines, and polygons on the WGS84 reference spheroid, with + geodesic edges. See: https://cloud.google.com/bigquery/docs/geospatial-data + + **Examples:** + + >>> import bigframes as bpd + >>> import bigframes.bigquery as bbq + >>> import bigframes.geopandas + >>> from shapely.geometry import Polygon, LineString, Point + + We can check two GeoSeries against each other, row by row: + + >>> s1 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (2, 2), (0, 2)]), + ... Polygon([(0, 0), (2, 2), (0, 2)]), + ... LineString([(0, 0), (2, 2)]), + ... LineString([(2, 0), (0, 2)]), + ... Point(0, 1), + ... ], + ... ) + >>> s2 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (1, 1), (0, 1)]), + ... LineString([(1, 0), (1, 3)]), + ... LineString([(2, 0), (0, 2)]), + ... Point(1, 1), + ... Point(0, 1), + ... ], + ... index=range(1, 6), + ... ) + + >>> s1 + 0 POLYGON ((0 0, 2 2, 0 2, 0 0)) + 1 POLYGON ((0 0, 2 2, 0 2, 0 0)) + 2 LINESTRING (0 0, 2 2) + 3 LINESTRING (2 0, 0 2) + 4 POINT (0 1) + dtype: geometry + + >>> s2 + 1 POLYGON ((0 0, 1 1, 0 1, 0 0)) + 2 LINESTRING (1 0, 1 3) + 3 LINESTRING (2 0, 0 2) + 4 POINT (1 1) + 5 POINT (0 1) + dtype: geometry + + >>> bbq.st_difference(s1, s2) + 0 None + 1 POLYGON ((0.99954 1, 2 2, 0 2, 0 1, 0.99954 1)) + 2 LINESTRING (0 0, 1 1.00046, 2 2) + 3 GEOMETRYCOLLECTION EMPTY + 4 POINT (0 1) + 5 None + dtype: geometry + + Additionally, we can check difference of a GeoSeries against a single shapely geometry: + + >>> polygon = Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]) + >>> bbq.st_difference(s1, polygon) + 0 POLYGON ((1.97082 2.00002, 0 2, 0 0, 1.97082 2... + 1 POLYGON ((1.97082 2.00002, 0 2, 0 0, 1.97082 2... + 2 GEOMETRYCOLLECTION EMPTY + 3 LINESTRING (0.99265 1.00781, 0 2) + 4 POINT (0 1) + dtype: geometry + + Args: + series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries): + A series containing geography objects. + other (bigframes.pandas.Series | bigframes.geopandas.GeoSeries | shapely.Geometry): + The series or geometric object to subtract from the geography + objects in ``series``. + + Returns: + bigframes.series.Series: + A GeoSeries of the points in each aligned geometry that are not + in other. + """ + return series._apply_binary_op(other, ops.geo_st_difference_op) + + +def st_distance( + series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], + other: Union[ + bigframes.series.Series, + bigframes.geopandas.GeoSeries, + shapely.geometry.base.BaseGeometry, + ], + *, + use_spheroid: bool = False, +) -> bigframes.series.Series: + """ + Returns the shortest distance in meters between two non-empty + ``GEOGRAPHY`` objects. + + **Examples:** + + >>> import bigframes as bpd + >>> import bigframes.bigquery as bbq + >>> import bigframes.geopandas + >>> from shapely.geometry import Polygon, LineString, Point + + We can check two GeoSeries against each other, row by row. + + >>> s1 = bigframes.geopandas.GeoSeries( + ... [ + ... Point(0, 0), + ... Point(0.00001, 0), + ... Point(0.00002, 0), + ... ], + ... ) + >>> s2 = bigframes.geopandas.GeoSeries( + ... [ + ... Point(0.00001, 0), + ... Point(0.00003, 0), + ... Point(0.00005, 0), + ... ], + ... ) + + >>> bbq.st_distance(s1, s2, use_spheroid=True) + 0 1.113195 + 1 2.22639 + 2 3.339585 + dtype: Float64 + + We can also calculate the distance of each geometry and a single shapely geometry: + + >>> bbq.st_distance(s2, Point(0.00001, 0)) + 0 0.0 + 1 2.223902 + 2 4.447804 + dtype: Float64 + + Args: + series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries): + A series containing geography objects. + other (bigframes.pandas.Series | bigframes.geopandas.GeoSeries | shapely.Geometry): + The series or geometric object to calculate the distance in meters + to form the geography objects in ``series``. + use_spheroid (optional, default ``False``): + Determines how this function measures distance. If ``use_spheroid`` + is False, the function measures distance on the surface of a perfect + sphere. If ``use_spheroid`` is True, the function measures distance + on the surface of the `WGS84 spheroid + `_. The + default value of ``use_spheroid`` is False. + + Returns: + bigframes.pandas.Series: + The Series (elementwise) of the smallest distance between + each aligned geometry with other. + """ + return series._apply_binary_op( + other, ops.GeoStDistanceOp(use_spheroid=use_spheroid) + ) + + +def st_intersection( + series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], + other: Union[ + bigframes.series.Series, + bigframes.geopandas.GeoSeries, + shapely.geometry.base.BaseGeometry, + ], +) -> bigframes.series.Series: + """ + Returns a `GEOGRAPHY` that represents the point set intersection of the two + input `GEOGRAPHYs`. Thus, every point in the intersection appears in both + `geography_1` and `geography_2`. + + .. note:: + BigQuery's Geography functions, like `st_intersection`, interpret the geometry + data type as a point set on the Earth's surface. A point set is a set + of points, lines, and polygons on the WGS84 reference spheroid, with + geodesic edges. See: https://cloud.google.com/bigquery/docs/geospatial-data + + **Examples:** + + >>> import bigframes as bpd + >>> import bigframes.bigquery as bbq + >>> import bigframes.geopandas + >>> from shapely.geometry import Polygon, LineString, Point + + We can check two GeoSeries against each other, row by row. + + >>> s1 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (2, 2), (0, 2)]), + ... Polygon([(0, 0), (2, 2), (0, 2)]), + ... LineString([(0, 0), (2, 2)]), + ... LineString([(2, 0), (0, 2)]), + ... Point(0, 1), + ... ], + ... ) + >>> s2 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (1, 1), (0, 1)]), + ... LineString([(1, 0), (1, 3)]), + ... LineString([(2, 0), (0, 2)]), + ... Point(1, 1), + ... Point(0, 1), + ... ], + ... index=range(1, 6), + ... ) + + >>> s1 + 0 POLYGON ((0 0, 2 2, 0 2, 0 0)) + 1 POLYGON ((0 0, 2 2, 0 2, 0 0)) + 2 LINESTRING (0 0, 2 2) + 3 LINESTRING (2 0, 0 2) + 4 POINT (0 1) + dtype: geometry + + >>> s2 + 1 POLYGON ((0 0, 1 1, 0 1, 0 0)) + 2 LINESTRING (1 0, 1 3) + 3 LINESTRING (2 0, 0 2) + 4 POINT (1 1) + 5 POINT (0 1) + dtype: geometry + + >>> bbq.st_intersection(s1, s2) + 0 None + 1 POLYGON ((0 0, 0.99954 1, 0 1, 0 0)) + 2 POINT (1 1.00046) + 3 LINESTRING (2 0, 0 2) + 4 GEOMETRYCOLLECTION EMPTY + 5 None + dtype: geometry + + We can also do intersection of each geometry and a single shapely geometry: + + >>> bbq.st_intersection(s1, Polygon([(0, 0), (1, 1), (0, 1)])) + 0 POLYGON ((0 0, 0.99954 1, 0 1, 0 0)) + 1 POLYGON ((0 0, 0.99954 1, 0 1, 0 0)) + 2 LINESTRING (0 0, 0.99954 1) + 3 GEOMETRYCOLLECTION EMPTY + 4 POINT (0 1) + dtype: geometry + + Args: + series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries): + A series containing geography objects. + other (bigframes.pandas.Series | bigframes.geopandas.GeoSeries | shapely.Geometry): + The series or geometric object to intersect with the geography + objects in ``series``. + + Returns: + bigframes.geopandas.GeoSeries: + The Geoseries (elementwise) of the intersection of points in + each aligned geometry with other. + """ + return series._apply_binary_op(other, ops.geo_st_intersection_op) + + +def st_isclosed( + series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], +) -> bigframes.series.Series: + """ + Returns TRUE for a non-empty Geography, where each element in the + Geography has an empty boundary. + + .. note:: + BigQuery's Geography functions, like `st_isclosed`, interpret the geometry + data type as a point set on the Earth's surface. A point set is a set + of points, lines, and polygons on the WGS84 reference spheroid, with + geodesic edges. See: https://cloud.google.com/bigquery/docs/geospatial-data + + **Examples:** + + >>> import bigframes.geopandas + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> from shapely.geometry import Point, LineString, Polygon + + >>> series = bigframes.geopandas.GeoSeries( + ... [ + ... Point(0, 0), # Point + ... LineString([(0, 0), (1, 1)]), # Open LineString + ... LineString([(0, 0), (1, 1), (0, 1), (0, 0)]), # Closed LineString + ... Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + ... None, + ... ] + ... ) + >>> series + 0 POINT (0 0) + 1 LINESTRING (0 0, 1 1) + 2 LINESTRING (0 0, 1 1, 0 1, 0 0) + 3 POLYGON ((0 0, 1 1, 0 1, 0 0)) + 4 None + dtype: geometry + + >>> bbq.st_isclosed(series) + 0 True + 1 False + 2 True + 3 False + 4 + dtype: boolean + + Args: + series (bigframes.pandas.Series | bigframes.geopandas.GeoSeries): + A series containing geography objects. + + Returns: + bigframes.pandas.Series: + Series of booleans indicating whether each geometry is closed. + """ + series = series._apply_unary_op(ops.geo_st_isclosed_op) + series.name = None + return series + + +def st_length( + series: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], + *, + use_spheroid: bool = False, +) -> bigframes.series.Series: + """Returns the total length in meters of the lines in the input GEOGRAPHY. + + If a series element is a point or a polygon, returns zero for that row. + If a series element is a collection, returns the length of the lines + in the collection; if the collection doesn't contain lines, returns + zero. + + The optional use_spheroid parameter determines how this function + measures distance. If use_spheroid is FALSE, the function measures + distance on the surface of a perfect sphere. + + The use_spheroid parameter currently only supports the value FALSE. The + default value of use_spheroid is FALSE. See: + https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions#st_length + + **Examples:** + + >>> import bigframes.geopandas + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> from shapely.geometry import Polygon, LineString, Point, GeometryCollection + + >>> series = bigframes.geopandas.GeoSeries( + ... [ + ... LineString([(0, 0), (1, 0)]), # Length will be approx 1 degree in meters + ... Polygon([(0.0, 0.0), (0.1, 0.1), (0.0, 0.1)]), # Length is 0 + ... Point(0, 1), # Length is 0 + ... GeometryCollection([LineString([(0,0),(0,1)]), Point(1,1)]) # Length of LineString only + ... ] + ... ) + + >>> result = bbq.st_length(series) + >>> result + 0 111195.101177 + 1 0.0 + 2 0.0 + 3 111195.101177 + dtype: Float64 + + Args: + series (bigframes.series.Series | bigframes.geopandas.GeoSeries): + A series containing geography objects. + use_spheroid (bool, optional): + Determines how this function measures distance. + If FALSE (default), measures distance on a perfect sphere. + Currently, only FALSE is supported. + + Returns: + bigframes.series.Series: + Series of floats representing the lengths in meters. + """ + series = series._apply_unary_op(ops.GeoStLengthOp(use_spheroid=use_spheroid)) + series.name = None + return series + + +def st_regionstats( + geography: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], + raster_id: str, + band: Optional[str] = None, + include: Optional[str] = None, + options: Optional[Mapping[str, Union[str, int, float]]] = None, +) -> bigframes.series.Series: + """Returns statistics summarizing the pixel values of the raster image + referenced by raster_id that intersect with geography. + + The statistics include the count, minimum, maximum, sum, standard + deviation, mean, and area of the valid pixels of the raster band named + band_name. Google Earth Engine computes the results of the function call. + + See: https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions#st_regionstats + + Args: + geography (bigframes.series.Series | bigframes.geopandas.GeoSeries): + A series of geography objects to intersect with the raster image. + raster_id (str): + A string that identifies a raster image. The following formats are + supported. A URI from an image table provided by Google Earth Engine + in BigQuery sharing (formerly Analytics Hub). A URI for a readable + GeoTIFF raster file. A Google Earth Engine asset path that + references public catalog data or project-owned assets with read + access. + band (Optional[str]): + A string in one of the following formats: + A single band within the raster image specified by raster_id. A + formula to compute a value from the available bands in the raster + image. The formula uses the Google Earth Engine image expression + syntax. Bands can be referenced by their name, band_name, in + expressions. If you don't specify a band, the first band of the + image is used. + include (Optional[str]): + An optional string formula that uses the Google Earth Engine image + expression syntax to compute a pixel weight. The formula should + return values from 0 to 1. Values outside this range are set to the + nearest limit, either 0 or 1. A value of 0 means that the pixel is + invalid and it's excluded from analysis. A positive value means that + a pixel is valid. Values between 0 and 1 represent proportional + weights for calculations, such as weighted means. + options (Mapping[str, Union[str, int, float]], optional): + A dictionary of options to pass to the function. See the BigQuery + documentation for a list of available options. + + Returns: + bigframes.pandas.Series: + A STRUCT Series containing the computed statistics. + """ + op = ops.GeoStRegionStatsOp( + raster_id=raster_id, + band=band, + include=include, + options=json.dumps(options) if options else None, + ) + return geography._apply_unary_op(op) + + +def st_simplify( + geography: "bigframes.series.Series", + tolerance_meters: float, +) -> "bigframes.series.Series": + """Returns a simplified version of the input geography. + + Args: + geography (bigframes.series.Series): + A Series containing GEOGRAPHY data. + tolerance_meters (float): + A float64 value indicating the tolerance in meters. + + Returns: + a Series containing the simplified GEOGRAPHY data. + """ + return geography._apply_unary_op( + ops.GeoStSimplifyOp(tolerance_meters=tolerance_meters) + ) diff --git a/bigframes/bigquery/_operations/json.py b/bigframes/bigquery/_operations/json.py index 0223811ebc..0fc184b2fc 100644 --- a/bigframes/bigquery/_operations/json.py +++ b/bigframes/bigquery/_operations/json.py @@ -22,9 +22,11 @@ from __future__ import annotations from typing import Any, cast, Optional, Sequence, Tuple, Union +import warnings import bigframes.core.utils as utils import bigframes.dtypes +import bigframes.exceptions as bfe import bigframes.operations as ops import bigframes.series as series @@ -47,13 +49,11 @@ def json_set( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.read_gbq("SELECT JSON '{\\\"a\\\": 1}' AS data")["data"] >>> bbq.json_set(s, json_path_value_pairs=[("$.a", 100), ("$.b", "hi")]) 0 {"a":100,"b":"hi"} - Name: data, dtype: dbjson + Name: data, dtype: extension>[pyarrow] Args: input (bigframes.series.Series): @@ -87,15 +87,18 @@ def json_extract( input: series.Series, json_path: str, ) -> series.Series: - """Extracts a JSON value and converts it to a SQL JSON-formatted `STRING` or `JSON` - value. This function uses single quotes and brackets to escape invalid JSONPath - characters in JSON keys. + """Extracts a JSON value and converts it to a SQL JSON-formatted ``STRING`` or + ``JSON`` value. This function uses single quotes and brackets to escape invalid + JSONPath characters in JSON keys. + + .. deprecated:: 2.5.0 + The ``json_extract`` is deprecated and will be removed in a future version. + Use ``json_query`` instead. **Examples:** >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['{"class": {"students": [{"id": 5}, {"id": 12}]}}']) >>> bbq.json_extract(s, json_path="$.class") @@ -111,6 +114,11 @@ def json_extract( Returns: bigframes.series.Series: A new Series with the JSON or JSON-formatted STRING. """ + msg = ( + "The `json_extract` is deprecated and will be removed in a future version. " + "Use `json_query` instead." + ) + warnings.warn(bfe.format_message(msg), category=UserWarning) return input._apply_unary_op(ops.JSONExtract(json_path=json_path)) @@ -122,11 +130,14 @@ def json_extract_array( `STRING` or `JSON` values. This function uses single quotes and brackets to escape invalid JSONPath characters in JSON keys. + .. deprecated:: 2.5.0 + The ``json_extract_array`` is deprecated and will be removed in a future version. + Use ``json_query_array`` instead. + **Examples:** >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['[1, 2, 3]', '[4, 5]']) >>> bbq.json_extract_array(s) @@ -161,6 +172,11 @@ def json_extract_array( Returns: bigframes.series.Series: A new Series with the parsed arrays from the input. """ + msg = ( + "The `json_extract_array` is deprecated and will be removed in a future version. " + "Use `json_query_array` instead." + ) + warnings.warn(bfe.format_message(msg), category=UserWarning) return input._apply_unary_op(ops.JSONExtractArray(json_path=json_path)) @@ -176,11 +192,14 @@ def json_extract_string_array( values in the array. This function uses single quotes and brackets to escape invalid JSONPath characters in JSON keys. + .. deprecated:: 2.6.0 + The ``json_extract_string_array`` is deprecated and will be removed in a future version. + Use ``json_value_array`` instead. + **Examples:** >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['[1, 2, 3]', '[4, 5]']) >>> bbq.json_extract_string_array(s) @@ -213,6 +232,11 @@ def json_extract_string_array( Returns: bigframes.series.Series: A new Series with the parsed arrays from the input. """ + msg = ( + "The `json_extract_string_array` is deprecated and will be removed in a future version. " + "Use `json_value_array` instead." + ) + warnings.warn(bfe.format_message(msg), category=UserWarning) array_series = input._apply_unary_op( ops.JSONExtractStringArray(json_path=json_path) ) @@ -231,6 +255,267 @@ def json_extract_string_array( return array_series +def json_query( + input: series.Series, + json_path: str, +) -> series.Series: + """Extracts a JSON value and converts it to a SQL JSON-formatted ``STRING`` + or ``JSON`` value. This function uses double quotes to escape invalid JSONPath + characters in JSON keys. For example: ``"a.b"``. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> s = bpd.Series(['{"class": {"students": [{"id": 5}, {"id": 12}]}}']) + >>> bbq.json_query(s, json_path="$.class") + 0 {"students":[{"id":5},{"id":12}]} + dtype: string + + Args: + input (bigframes.series.Series): + The Series containing JSON data (as native JSON objects or JSON-formatted strings). + json_path (str): + The JSON path identifying the data that you want to obtain from the input. + + Returns: + bigframes.series.Series: A new Series with the JSON or JSON-formatted STRING. + """ + return input._apply_unary_op(ops.JSONQuery(json_path=json_path)) + + +def json_query_array( + input: series.Series, + json_path: str = "$", +) -> series.Series: + """Extracts a JSON array and converts it to a SQL array of JSON-formatted + `STRING` or `JSON` values. This function uses double quotes to escape invalid + JSONPath characters in JSON keys. For example: `"a.b"`. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> s = bpd.Series(['[1, 2, 3]', '[4, 5]']) + >>> bbq.json_query_array(s) + 0 ['1' '2' '3'] + 1 ['4' '5'] + dtype: list[pyarrow] + + >>> s = bpd.Series([ + ... '{"fruits": [{"name": "apple"}, {"name": "cherry"}]}', + ... '{"fruits": [{"name": "guava"}, {"name": "grapes"}]}' + ... ]) + >>> bbq.json_query_array(s, "$.fruits") + 0 ['{"name":"apple"}' '{"name":"cherry"}'] + 1 ['{"name":"guava"}' '{"name":"grapes"}'] + dtype: list[pyarrow] + + >>> s = bpd.Series([ + ... '{"fruits": {"color": "red", "names": ["apple","cherry"]}}', + ... '{"fruits": {"color": "green", "names": ["guava", "grapes"]}}' + ... ]) + >>> bbq.json_query_array(s, "$.fruits.names") + 0 ['"apple"' '"cherry"'] + 1 ['"guava"' '"grapes"'] + dtype: list[pyarrow] + + Args: + input (bigframes.series.Series): + The Series containing JSON data (as native JSON objects or JSON-formatted strings). + json_path (str): + The JSON path identifying the data that you want to obtain from the input. + + Returns: + bigframes.series.Series: A new Series with the parsed arrays from the input. + """ + return input._apply_unary_op(ops.JSONQueryArray(json_path=json_path)) + + +def json_value( + input: series.Series, + json_path: str = "$", +) -> series.Series: + """Extracts a JSON scalar value and converts it to a SQL ``STRING`` value. In + addtion, this function: + - Removes the outermost quotes and unescapes the values. + - Returns a SQL ``NULL`` if a non-scalar value is selected. + - Uses double quotes to escape invalid ``JSON_PATH`` characters in JSON keys. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> s = bpd.Series(['{"name": "Jakob", "age": "6"}', '{"name": "Jakob", "age": []}']) + >>> bbq.json_value(s, json_path="$.age") + 0 6 + 1 + dtype: string + + Args: + input (bigframes.series.Series): + The Series containing JSON data (as native JSON objects or JSON-formatted strings). + json_path (str): + The JSON path identifying the data that you want to obtain from the input. + + Returns: + bigframes.series.Series: A new Series with the JSON-formatted STRING. + """ + return input._apply_unary_op(ops.JSONValue(json_path=json_path)) + + +def json_value_array( + input: series.Series, + json_path: str = "$", +) -> series.Series: + """ + Extracts a JSON array of scalar values and converts it to a SQL ``ARRAY`` + value. In addition, this function: + + - Removes the outermost quotes and unescapes the values. + - Returns a SQL ``NULL`` if the selected value isn't an array or not an array + containing only scalar values. + - Uses double quotes to escape invalid ``JSON_PATH`` characters in JSON keys. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> s = bpd.Series(['[1, 2, 3]', '[4, 5]']) + >>> bbq.json_value_array(s) + 0 ['1' '2' '3'] + 1 ['4' '5'] + dtype: list[pyarrow] + + >>> s = bpd.Series([ + ... '{"fruits": ["apples", "oranges", "grapes"]', + ... '{"fruits": ["guava", "grapes"]}' + ... ]) + >>> bbq.json_value_array(s, "$.fruits") + 0 ['apples' 'oranges' 'grapes'] + 1 ['guava' 'grapes'] + dtype: list[pyarrow] + + >>> s = bpd.Series([ + ... '{"fruits": {"color": "red", "names": ["apple","cherry"]}}', + ... '{"fruits": {"color": "green", "names": ["guava", "grapes"]}}' + ... ]) + >>> bbq.json_value_array(s, "$.fruits.names") + 0 ['apple' 'cherry'] + 1 ['guava' 'grapes'] + dtype: list[pyarrow] + + Args: + input (bigframes.series.Series): + The Series containing JSON data (as native JSON objects or JSON-formatted strings). + json_path (str): + The JSON path identifying the data that you want to obtain from the input. + + Returns: + bigframes.series.Series: A new Series with the parsed arrays from the input. + """ + return input._apply_unary_op(ops.JSONValueArray(json_path=json_path)) + + +def json_keys( + input: series.Series, + max_depth: Optional[int] = None, +) -> series.Series: + """Returns all keys in the root of a JSON object as an ARRAY of STRINGs. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> s = bpd.Series(['{"b": {"c": 2}, "a": 1}'], dtype="json") + >>> bbq.json_keys(s) + 0 ['a' 'b' 'b.c'] + dtype: list[pyarrow] + + Args: + input (bigframes.series.Series): + The Series containing JSON data. + max_depth (int, optional): + Specifies the maximum depth of nested fields to search for keys. If not + provided, searched keys at all levels. + + Returns: + bigframes.series.Series: A new Series containing arrays of keys from the input JSON. + """ + return input._apply_unary_op(ops.JSONKeys(max_depth=max_depth)) + + +def to_json( + input: series.Series, +) -> series.Series: + """Converts a series with a JSON value to a JSON-formatted STRING value. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> s = bpd.Series([1, 2, 3]) + >>> bbq.to_json(s) + 0 1 + 1 2 + 2 3 + dtype: extension>[pyarrow] + + >>> s = bpd.Series([{"int": 1, "str": "pandas"}, {"int": 2, "str": "numpy"}]) + >>> bbq.to_json(s) + 0 {"int":1,"str":"pandas"} + 1 {"int":2,"str":"numpy"} + dtype: extension>[pyarrow] + + Args: + input (bigframes.series.Series): + The Series containing JSON or JSON-formatted string values. + + Returns: + bigframes.series.Series: A new Series with the JSON value. + """ + return input._apply_unary_op(ops.ToJSON()) + + +def to_json_string( + input: series.Series, +) -> series.Series: + """Converts a series to a JSON-formatted STRING value. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> s = bpd.Series([1, 2, 3]) + >>> bbq.to_json_string(s) + 0 1 + 1 2 + 2 3 + dtype: string + + >>> s = bpd.Series([{"int": 1, "str": "pandas"}, {"int": 2, "str": "numpy"}]) + >>> bbq.to_json_string(s) + 0 {"int":1,"str":"pandas"} + 1 {"int":2,"str":"numpy"} + dtype: string + + Args: + input (bigframes.series.Series): + The Series to be converted. + + Returns: + bigframes.series.Series: A new Series with the JSON-formatted STRING value. + """ + return input._apply_unary_op(ops.ToJSONString()) + + @utils.preview(name="The JSON-related API `parse_json`") def parse_json( input: series.Series, @@ -245,7 +530,6 @@ def parse_json( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['{"class": {"students": [{"id": 5}, {"id": 12}]}}']) >>> s @@ -253,7 +537,7 @@ def parse_json( dtype: string >>> bbq.parse_json(s) 0 {"class":{"students":[{"id":5},{"id":12}]}} - dtype: dbjson + dtype: extension>[pyarrow] Args: input (bigframes.series.Series): diff --git a/bigframes/bigquery/_operations/ml.py b/bigframes/bigquery/_operations/ml.py new file mode 100644 index 0000000000..073be0ef2b --- /dev/null +++ b/bigframes/bigquery/_operations/ml.py @@ -0,0 +1,395 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import cast, Mapping, Optional, Union + +import bigframes_vendored.constants +import google.cloud.bigquery +import pandas as pd + +import bigframes.core.log_adapter as log_adapter +import bigframes.core.sql.ml +import bigframes.dataframe as dataframe +import bigframes.ml.base +import bigframes.session + + +# Helper to convert DataFrame to SQL string +def _to_sql(df_or_sql: Union[pd.DataFrame, dataframe.DataFrame, str]) -> str: + import bigframes.pandas as bpd + + if isinstance(df_or_sql, str): + return df_or_sql + + if isinstance(df_or_sql, pd.DataFrame): + bf_df = bpd.read_pandas(df_or_sql) + else: + bf_df = cast(dataframe.DataFrame, df_or_sql) + + # Cache dataframes to make sure base table is not a snapshot. + # Cached dataframe creates a full copy, never uses snapshot. + # This is a workaround for internal issue b/310266666. + bf_df.cache() + sql, _, _ = bf_df._to_sql_query(include_index=False) + return sql + + +def _get_model_name_and_session( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + # Other dataframe arguments to extract session from + *dataframes: Optional[Union[pd.DataFrame, dataframe.DataFrame, str]], +) -> tuple[str, Optional[bigframes.session.Session]]: + if isinstance(model, pd.Series): + try: + model_ref = model["modelReference"] + model_name = f"{model_ref['projectId']}.{model_ref['datasetId']}.{model_ref['modelId']}" # type: ignore + except KeyError: + raise ValueError("modelReference must be present in the pandas Series.") + elif isinstance(model, str): + model_name = model + else: + if model._bqml_model is None: + raise ValueError("Model must be fitted to be used in ML operations.") + return model._bqml_model.model_name, model._bqml_model.session + + session = None + for df in dataframes: + if isinstance(df, dataframe.DataFrame): + session = df._session + break + + return model_name, session + + +def _get_model_metadata( + *, + bqclient: google.cloud.bigquery.Client, + model_name: str, +) -> pd.Series: + model_metadata = bqclient.get_model(model_name) + model_dict = model_metadata.to_api_repr() + return pd.Series(model_dict) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def create_model( + model_name: str, + *, + replace: bool = False, + if_not_exists: bool = False, + # TODO(tswast): Also support bigframes.ml transformer classes and/or + # bigframes.pandas functions? + transform: Optional[list[str]] = None, + input_schema: Optional[Mapping[str, str]] = None, + output_schema: Optional[Mapping[str, str]] = None, + connection_name: Optional[str] = None, + options: Optional[Mapping[str, Union[str, int, float, bool, list]]] = None, + training_data: Optional[Union[pd.DataFrame, dataframe.DataFrame, str]] = None, + custom_holiday: Optional[Union[pd.DataFrame, dataframe.DataFrame, str]] = None, + session: Optional[bigframes.session.Session] = None, +) -> pd.Series: + """ + Creates a BigQuery ML model. + + See the `BigQuery ML CREATE MODEL DDL syntax + `_ + for additional reference. + + Args: + model_name (str): + The name of the model in BigQuery. + replace (bool, default False): + Whether to replace the model if it already exists. + if_not_exists (bool, default False): + Whether to ignore the error if the model already exists. + transform (list[str], optional): + A list of SQL transformations for the TRANSFORM clause, which + specifies the preprocessing steps to apply to the input data. + input_schema (Mapping[str, str], optional): + The INPUT clause, which specifies the schema of the input data. + output_schema (Mapping[str, str], optional): + The OUTPUT clause, which specifies the schema of the output data. + connection_name (str, optional): + The connection to use for the model. + options (Mapping[str, Union[str, int, float, bool, list]], optional): + The OPTIONS clause, which specifies the model options. + training_data (Union[bigframes.pandas.DataFrame, str], optional): + The query or DataFrame to use for training the model. + custom_holiday (Union[bigframes.pandas.DataFrame, str], optional): + The query or DataFrame to use for custom holiday data. + session (bigframes.session.Session, optional): + The session to use. If not provided, the default session is used. + + Returns: + pandas.Series: + A Series with object dtype containing the model metadata. Reference + the `BigQuery Model REST API reference + `_ + for available fields. + + """ + import bigframes.pandas as bpd + + training_data_sql = _to_sql(training_data) if training_data is not None else None + custom_holiday_sql = _to_sql(custom_holiday) if custom_holiday is not None else None + + # Determine session from DataFrames if not provided + if session is None: + # Try to get session from inputs + dfs = [ + obj + for obj in [training_data, custom_holiday] + if isinstance(obj, dataframe.DataFrame) + ] + if dfs: + session = dfs[0]._session + + sql = bigframes.core.sql.ml.create_model_ddl( + model_name=model_name, + replace=replace, + if_not_exists=if_not_exists, + transform=transform, + input_schema=input_schema, + output_schema=output_schema, + connection_name=connection_name, + options=options, + training_data=training_data_sql, + custom_holiday=custom_holiday_sql, + ) + + if session is None: + bpd.read_gbq_query(sql) + session = bpd.get_global_session() + assert ( + session is not None + ), f"Missing connection to BigQuery. Please report how you encountered this error at {bigframes_vendored.constants.FEEDBACK_LINK}." + else: + session.read_gbq_query(sql) + + return _get_model_metadata(bqclient=session.bqclient, model_name=model_name) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def evaluate( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + input_: Optional[Union[pd.DataFrame, dataframe.DataFrame, str]] = None, + *, + perform_aggregation: Optional[bool] = None, + horizon: Optional[int] = None, + confidence_level: Optional[float] = None, +) -> dataframe.DataFrame: + """ + Evaluates a BigQuery ML model. + + See the `BigQuery ML EVALUATE function syntax + `_ + for additional reference. + + Args: + model (bigframes.ml.base.BaseEstimator or str): + The model to evaluate. + input_ (Union[bigframes.pandas.DataFrame, str], optional): + The DataFrame or query to use for evaluation. If not provided, the + evaluation data from training is used. + perform_aggregation (bool, optional): + A BOOL value that indicates the level of evaluation for forecasting + accuracy. If you specify TRUE, then the forecasting accuracy is on + the time series level. If you specify FALSE, the forecasting + accuracy is on the timestamp level. The default value is TRUE. + horizon (int, optional): + An INT64 value that specifies the number of forecasted time points + against which the evaluation metrics are computed. The default value + is the horizon value specified in the CREATE MODEL statement for the + time series model, or 1000 if unspecified. When evaluating multiple + time series at the same time, this parameter applies to each time + series. + confidence_level (float, optional): + A FLOAT64 value that specifies the percentage of the future values + that fall in the prediction interval. The default value is 0.95. The + valid input range is ``[0, 1)``. + + Returns: + bigframes.pandas.DataFrame: + The evaluation results. + """ + import bigframes.pandas as bpd + + model_name, session = _get_model_name_and_session(model, input_) + table_sql = _to_sql(input_) if input_ is not None else None + + sql = bigframes.core.sql.ml.evaluate( + model_name=model_name, + table=table_sql, + perform_aggregation=perform_aggregation, + horizon=horizon, + confidence_level=confidence_level, + ) + + if session is None: + return bpd.read_gbq_query(sql) + else: + return session.read_gbq_query(sql) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def predict( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + input_: Union[pd.DataFrame, dataframe.DataFrame, str], + *, + threshold: Optional[float] = None, + keep_original_columns: Optional[bool] = None, + trial_id: Optional[int] = None, +) -> dataframe.DataFrame: + """ + Runs prediction on a BigQuery ML model. + + See the `BigQuery ML PREDICT function syntax + `_ + for additional reference. + + Args: + model (bigframes.ml.base.BaseEstimator or str): + The model to use for prediction. + input_ (Union[bigframes.pandas.DataFrame, str]): + The DataFrame or query to use for prediction. + threshold (float, optional): + The threshold to use for classification models. + keep_original_columns (bool, optional): + Whether to keep the original columns in the output. + trial_id (int, optional): + An INT64 value that identifies the hyperparameter tuning trial that + you want the function to evaluate. The function uses the optimal + trial by default. Only specify this argument if you ran + hyperparameter tuning when creating the model. + + Returns: + bigframes.pandas.DataFrame: + The prediction results. + """ + import bigframes.pandas as bpd + + model_name, session = _get_model_name_and_session(model, input_) + table_sql = _to_sql(input_) + + sql = bigframes.core.sql.ml.predict( + model_name=model_name, + table=table_sql, + threshold=threshold, + keep_original_columns=keep_original_columns, + trial_id=trial_id, + ) + + if session is None: + return bpd.read_gbq_query(sql) + else: + return session.read_gbq_query(sql) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def explain_predict( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + input_: Union[pd.DataFrame, dataframe.DataFrame, str], + *, + top_k_features: Optional[int] = None, + threshold: Optional[float] = None, + integrated_gradients_num_steps: Optional[int] = None, + approx_feature_contrib: Optional[bool] = None, +) -> dataframe.DataFrame: + """ + Runs explainable prediction on a BigQuery ML model. + + See the `BigQuery ML EXPLAIN_PREDICT function syntax + `_ + for additional reference. + + Args: + model (bigframes.ml.base.BaseEstimator or str): + The model to use for prediction. + input_ (Union[bigframes.pandas.DataFrame, str]): + The DataFrame or query to use for prediction. + top_k_features (int, optional): + The number of top features to return. + threshold (float, optional): + The threshold for binary classification models. + integrated_gradients_num_steps (int, optional): + an INT64 value that specifies the number of steps to sample between + the example being explained and its baseline. This value is used to + approximate the integral in integrated gradients attribution + methods. Increasing the value improves the precision of feature + attributions, but can be slower and more computationally expensive. + approx_feature_contrib (bool, optional): + A BOOL value that indicates whether to use an approximate feature + contribution method in the XGBoost model explanation. + + Returns: + bigframes.pandas.DataFrame: + The prediction results with explanations. + """ + import bigframes.pandas as bpd + + model_name, session = _get_model_name_and_session(model, input_) + table_sql = _to_sql(input_) + + sql = bigframes.core.sql.ml.explain_predict( + model_name=model_name, + table=table_sql, + top_k_features=top_k_features, + threshold=threshold, + integrated_gradients_num_steps=integrated_gradients_num_steps, + approx_feature_contrib=approx_feature_contrib, + ) + + if session is None: + return bpd.read_gbq_query(sql) + else: + return session.read_gbq_query(sql) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def global_explain( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + *, + class_level_explain: Optional[bool] = None, +) -> dataframe.DataFrame: + """ + Gets global explanations for a BigQuery ML model. + + See the `BigQuery ML GLOBAL_EXPLAIN function syntax + `_ + for additional reference. + + Args: + model (bigframes.ml.base.BaseEstimator or str): + The model to get explanations from. + class_level_explain (bool, optional): + Whether to return class-level explanations. + + Returns: + bigframes.pandas.DataFrame: + The global explanation results. + """ + import bigframes.pandas as bpd + + model_name, session = _get_model_name_and_session(model) + sql = bigframes.core.sql.ml.global_explain( + model_name=model_name, + class_level_explain=class_level_explain, + ) + + if session is None: + return bpd.read_gbq_query(sql) + else: + return session.read_gbq_query(sql) diff --git a/bigframes/bigquery/_operations/search.py b/bigframes/bigquery/_operations/search.py index 9a1e4b5ac9..b65eed2475 100644 --- a/bigframes/bigquery/_operations/search.py +++ b/bigframes/bigquery/_operations/search.py @@ -20,7 +20,6 @@ import google.cloud.bigquery as bigquery -import bigframes.core.sql import bigframes.ml.utils as utils if typing.TYPE_CHECKING: @@ -99,6 +98,7 @@ def vector_search( distance_type: Optional[Literal["euclidean", "cosine", "dot_product"]] = None, fraction_lists_to_search: Optional[float] = None, use_brute_force: Optional[bool] = None, + allow_large_results: Optional[bool] = None, ) -> dataframe.DataFrame: """ Conduct vector search which searches embeddings to find semantically similar entities. @@ -111,7 +111,6 @@ def vector_search( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None DataFrame embeddings for which to find nearest neighbors. The ``ARRAY`` column is used as the search query: @@ -163,12 +162,12 @@ def vector_search( ... query=search_query, ... distance_type="cosine", ... query_column_to_search="another_embedding", - ... top_k=2) + ... top_k=2).sort_values("id") query_id embedding another_embedding id my_embedding distance - 1 cat [3. 5.2] [3.3 5.2] 2 [2. 4.] 0.005181 - 0 dog [1. 2.] [0.7 2.2] 4 [1. 3.2] 0.000013 1 cat [3. 5.2] [3.3 5.2] 1 [1. 2.] 0.005181 + 1 cat [3. 5.2] [3.3 5.2] 2 [2. 4.] 0.005181 0 dog [1. 2.] [0.7 2.2] 3 [1.5 7. ] 0.004697 + 0 dog [1. 2.] [0.7 2.2] 4 [1. 3.2] 0.000013 [4 rows x 6 columns] @@ -199,6 +198,10 @@ def vector_search( use_brute_force (bool): Determines whether to use brute force search by skipping the vector index if one is available. Default to False. + allow_large_results (bool, optional): + Whether to allow large query results. If ``True``, the query + results can be larger than the maximum response size. + Defaults to ``bpd.options.compute.allow_large_results``. Returns: bigframes.dataframe.DataFrame: A DataFrame containing vector search result. @@ -236,9 +239,11 @@ def vector_search( options=options, ) if index_col_ids is not None: - df = query._session.read_gbq(sql, index_col=index_col_ids) + df = query._session.read_gbq_query( + sql, index_col=index_col_ids, allow_large_results=allow_large_results + ) df.index.names = index_labels else: - df = query._session.read_gbq(sql) + df = query._session.read_gbq_query(sql, allow_large_results=allow_large_results) return df diff --git a/bigframes/bigquery/_operations/sql.py b/bigframes/bigquery/_operations/sql.py index 7ccf63fcda..295412fd75 100644 --- a/bigframes/bigquery/_operations/sql.py +++ b/bigframes/bigquery/_operations/sql.py @@ -20,8 +20,7 @@ import google.cloud.bigquery -import bigframes.core.sql -import bigframes.dataframe +import bigframes.core.compile.sqlglot.sqlglot_ir as sqlglot_ir import bigframes.dtypes import bigframes.operations import bigframes.series @@ -37,9 +36,6 @@ def sql_scalar( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> import pandas as pd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1.5", "2.5", "3.5"]) >>> s = s.astype(pd.ArrowDtype(pa.decimal128(38, 9))) @@ -72,16 +68,16 @@ def sql_scalar( # Another benefit of this is that if there is a syntax error in the SQL # template, then this will fail with an error earlier in the process, # aiding users in debugging. - base_series = columns[0] - literals = [ - bigframes.dtypes.bigframes_dtype_to_literal(column.dtype) for column in columns + literals_sql = [ + sqlglot_ir._literal(None, column.dtype).sql(dialect="bigquery") + for column in columns ] - literals_sql = [bigframes.core.sql.simple_literal(literal) for literal in literals] + select_sql = sql_template.format(*literals_sql) + dry_run_sql = f"SELECT {select_sql}" # Use the executor directly, because we want the original column IDs, not # the user-friendly column names that block.to_sql_query() would produce. - select_sql = sql_template.format(*literals_sql) - dry_run_sql = f"SELECT {select_sql}" + base_series = columns[0] bqclient = base_series._session.bqclient job = bqclient.query( dry_run_sql, job_config=google.cloud.bigquery.QueryJobConfig(dry_run=True) diff --git a/bigframes/bigquery/_operations/struct.py b/bigframes/bigquery/_operations/struct.py index 7cb826351c..a6304677ef 100644 --- a/bigframes/bigquery/_operations/struct.py +++ b/bigframes/bigquery/_operations/struct.py @@ -39,7 +39,6 @@ def struct(value: dataframe.DataFrame) -> series.Series: >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq >>> import bigframes.series as series - >>> bpd.options.display.progress_bar = None >>> srs = series.Series([{"version": 1, "project": "pandas"}, {"version": 2, "project": "numpy"},]) >>> df = srs.struct.explode() diff --git a/bigframes/bigquery/ai.py b/bigframes/bigquery/ai.py new file mode 100644 index 0000000000..3af52205a6 --- /dev/null +++ b/bigframes/bigquery/ai.py @@ -0,0 +1,39 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module integrates BigQuery built-in AI functions for use with Series/DataFrame objects, +such as AI.GENERATE_BOOL: +https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-ai-generate-bool""" + +from bigframes.bigquery._operations.ai import ( + classify, + forecast, + generate, + generate_bool, + generate_double, + generate_int, + if_, + score, +) + +__all__ = [ + "classify", + "forecast", + "generate", + "generate_bool", + "generate_double", + "generate_int", + "if_", + "score", +] diff --git a/bigframes/bigquery/ml.py b/bigframes/bigquery/ml.py new file mode 100644 index 0000000000..93b0670ba5 --- /dev/null +++ b/bigframes/bigquery/ml.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module exposes `BigQuery ML +`_ functions +by directly mapping to the equivalent function names in SQL syntax. + +For an interface more familiar to Scikit-Learn users, see :mod:`bigframes.ml`. +""" + +from bigframes.bigquery._operations.ml import ( + create_model, + evaluate, + explain_predict, + global_explain, + predict, +) + +__all__ = [ + "create_model", + "evaluate", + "predict", + "explain_predict", + "global_explain", +] diff --git a/bigframes/blob/_functions.py b/bigframes/blob/_functions.py index 1099535712..3dfe38811b 100644 --- a/bigframes/blob/_functions.py +++ b/bigframes/blob/_functions.py @@ -14,14 +14,21 @@ from dataclasses import dataclass import inspect -from typing import Callable, Iterable +import typing +from typing import Callable, Iterable, Union import google.cloud.bigquery as bigquery import bigframes.session import bigframes.session._io.bigquery as bf_io_bigquery -_PYTHON_TO_BQ_TYPES = {int: "INT64", float: "FLOAT64", str: "STRING", bytes: "BYTES"} +_PYTHON_TO_BQ_TYPES = { + int: "INT64", + float: "FLOAT64", + str: "STRING", + bytes: "BYTES", + bool: "BOOL", +} @dataclass(frozen=True) @@ -42,12 +49,18 @@ def __init__( session: bigframes.session.Session, connection: str, max_batching_rows: int, + container_cpu: Union[float, int], + container_memory: str, ): self._func = func_def.func self._requirements = func_def.requirements self._session = session self._connection = connection - self._max_batching_rows = max_batching_rows + self._max_batching_rows = ( + int(max_batching_rows) if max_batching_rows > 1 else max_batching_rows + ) + self._container_cpu = container_cpu + self._container_memory = container_memory def _input_bq_signature(self): sig = inspect.signature(self._func) @@ -58,13 +71,21 @@ def _input_bq_signature(self): def _output_bq_type(self): sig = inspect.signature(self._func) + return_annotation = sig.return_annotation + origin = typing.get_origin(return_annotation) + if origin is Union: + args = typing.get_args(return_annotation) + if len(args) == 2 and args[1] is type(None): + return _PYTHON_TO_BQ_TYPES[args[0]] return _PYTHON_TO_BQ_TYPES[sig.return_annotation] def _create_udf(self): """Create Python UDF in BQ. Return name of the UDF.""" - udf_name = str(self._session._loader._storage_manager._random_table()) + udf_name = str( + self._session._anon_dataset_manager.generate_unique_resource_id() + ) - func_body = inspect.getsource(self._func) + func_body = "import typing\n" + inspect.getsource(self._func) func_name = self._func.__name__ packages = str(list(self._requirements)) @@ -72,7 +93,7 @@ def _create_udf(self): CREATE OR REPLACE FUNCTION `{udf_name}`({self._input_bq_signature()}) RETURNS {self._output_bq_type()} LANGUAGE python WITH CONNECTION `{self._connection}` -OPTIONS (entry_point='{func_name}', runtime_version='python-3.11', packages={packages}, max_batching_rows={self._max_batching_rows}) +OPTIONS (entry_point='{func_name}', runtime_version='python-3.11', packages={packages}, max_batching_rows={self._max_batching_rows}, container_cpu={self._container_cpu}, container_memory='{self._container_memory}') AS r\"\"\" @@ -87,6 +108,11 @@ def _create_udf(self): sql, job_config=bigquery.QueryJobConfig(), metrics=self._session._metrics, + location=None, + project=None, + timeout=None, + query_with_job=True, + publisher=self._session._publisher, ) return udf_name @@ -94,66 +120,193 @@ def _create_udf(self): def udf(self): """Create and return the UDF object.""" udf_name = self._create_udf() + + # TODO(b/404605969): remove cleanups when UDF fixes dataset deletion. + self._session._function_session._update_temp_artifacts(udf_name, "") return self._session.read_gbq_function(udf_name) +def exif_func(src_obj_ref_rt: str, verbose: bool) -> str: + try: + import io + import json + + from PIL import ExifTags, Image + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + + response = session.get(src_url, timeout=30) + response.raise_for_status() + bts = response.content + + image = Image.open(io.BytesIO(bts)) + exif_data = image.getexif() + exif_dict = {} + + if exif_data: + for tag, value in exif_data.items(): + tag_name = ExifTags.TAGS.get(tag, tag) + # Convert non-serializable types to strings + try: + json.dumps(value) + exif_dict[tag_name] = value + except (TypeError, ValueError): + exif_dict[tag_name] = str(value) + + if verbose: + return json.dumps({"status": "", "content": json.dumps(exif_dict)}) + else: + return json.dumps(exif_dict) + + except Exception as e: + # Return error as JSON with error field + error_result = {"status": f"{type(e).__name__}: {str(e)}", "content": "{}"} + if verbose: + return json.dumps(error_result) + else: + return "{}" + + +exif_func_def = FunctionDef(exif_func, ["pillow", "requests"]) + + # Blur images. Takes ObjectRefRuntime as JSON string. Outputs ObjectRefRuntime JSON string. def image_blur_func( - src_obj_ref_rt: str, dst_obj_ref_rt: str, ksize_x: int, ksize_y: int -) -> str: - import json + src_obj_ref_rt: str, + dst_obj_ref_rt: str, + ksize_x: int, + ksize_y: int, + ext: str, + verbose: bool, +) -> typing.Optional[str]: + try: + import json - import cv2 as cv # type: ignore - import numpy as np - import requests + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] + ext = ext or ".jpeg" - response = requests.get(src_url) - bts = response.content + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) - bts = cv.imencode(".jpeg", img_blurred)[1].tobytes() + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] - requests.put( - url=dst_url, - data=bts, - headers={ - "Content-Type": "image/jpeg", - }, - ) + response = session.get(src_url, timeout=30) + response.raise_for_status() # Raise exception for HTTP errors + bts = response.content - return dst_obj_ref_rt + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) -image_blur_def = FunctionDef(image_blur_func, ["opencv-python", "numpy", "requests"]) + img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) + success, encoded = cv.imencode(ext, img_blurred) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") -def image_blur_to_bytes_func(src_obj_ref_rt: str, ksize_x: int, ksize_y: int) -> bytes: - import json + bts = encoded.tobytes() - import cv2 as cv # type: ignore - import numpy as np - import requests + ext = ext.replace(".", "") + ext_mappings = {"jpg": "jpeg", "tif": "tiff"} + ext = ext_mappings.get(ext, ext) + content_type = "image/" + ext - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + put_response = session.put( + url=dst_url, + data=bts, + headers={"Content-Type": content_type}, + timeout=30, + ) + put_response.raise_for_status() - response = requests.get(src_url) - bts = response.content + if verbose: + return json.dumps({"status": "", "content": dst_obj_ref_rt}) + else: + return dst_obj_ref_rt - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) - bts = cv.imencode(".jpeg", img_blurred)[1].tobytes() + except Exception as e: + if verbose: + error_result = { + "status": f"Error: {type(e).__name__}: {str(e)}", + "content": "", + } + return json.dumps(error_result) + else: + return None - return bts + +image_blur_def = FunctionDef(image_blur_func, ["opencv-python", "numpy", "requests"]) + + +def image_blur_to_bytes_func( + src_obj_ref_rt: str, ksize_x: int, ksize_y: int, ext: str, verbose: bool +) -> str: + import base64 + import json + + try: + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + + ext = ext or ".jpeg" + + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + + response = session.get(src_url, timeout=30) + response.raise_for_status() + bts = response.content + + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) + img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) + success, encoded = cv.imencode(ext, img_blurred) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + content = encoded.tobytes() + + encoded_content = base64.b64encode(content).decode("utf-8") + result_dict = {"status": "", "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] + + except Exception as e: + status = f"Error: {type(e).__name__}: {str(e)}" + encoded_content = base64.b64encode(b"").decode("utf-8") + result_dict = {"status": status, "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_blur_to_bytes_def = FunctionDef( @@ -168,36 +321,74 @@ def image_resize_func( dsize_y: int, fx: float, fy: float, -) -> str: - import json - - import cv2 as cv # type: ignore - import numpy as np - import requests - - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) - - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] - - response = requests.get(src_url) - bts = response.content - - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) - bts = cv.imencode(".jpeg", img_resized)[1].tobytes() + ext: str, + verbose: bool, +) -> typing.Optional[str]: + try: + import json + + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + + ext = ext or ".jpeg" + + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) + + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] + + response = session.get(src_url, timeout=30) + response.raise_for_status() + bts = response.content + + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) + img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) + + success, encoded = cv.imencode(ext, img_resized) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + bts = encoded.tobytes() + + ext = ext.replace(".", "") + ext_mappings = {"jpg": "jpeg", "tif": "tiff"} + ext = ext_mappings.get(ext, ext) + content_type = "image/" + ext + + put_response = session.put( + url=dst_url, + data=bts, + headers={ + "Content-Type": content_type, + }, + timeout=30, + ) + put_response.raise_for_status() - requests.put( - url=dst_url, - data=bts, - headers={ - "Content-Type": "image/jpeg", - }, - ) + if verbose: + return json.dumps({"status": "", "content": dst_obj_ref_rt}) + else: + return dst_obj_ref_rt - return dst_obj_ref_rt + except Exception as e: + if verbose: + error_result = { + "status": f"Error: {type(e).__name__}: {str(e)}", + "content": "", + } + return json.dumps(error_result) + else: + return None image_resize_def = FunctionDef( @@ -211,25 +402,57 @@ def image_resize_to_bytes_func( dsize_y: int, fx: float, fy: float, -) -> bytes: + ext: str, + verbose: bool, +) -> str: + import base64 import json - import cv2 as cv # type: ignore - import numpy as np - import requests - - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - - response = requests.get(src_url) - bts = response.content - - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) - bts = cv.imencode(".jpeg", img_resized)[1].tobytes() - - return bts + try: + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + + ext = ext or ".jpeg" + + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + + response = session.get(src_url, timeout=30) + response.raise_for_status() + bts = response.content + + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) + img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) + success, encoded = cv.imencode(ext, img_resized) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + content = encoded.tobytes() + + encoded_content = base64.b64encode(content).decode("utf-8") + result_dict = {"status": "", "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] + + except Exception as e: + status = f"Error: {type(e).__name__}: {str(e)}" + encoded_content = base64.b64encode(b"").decode("utf-8") + result_dict = {"status": status, "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_resize_to_bytes_def = FunctionDef( @@ -238,46 +461,88 @@ def image_resize_to_bytes_func( def image_normalize_func( - src_obj_ref_rt: str, dst_obj_ref_rt: str, alpha: float, beta: float, norm_type: str -) -> str: - import json - - import cv2 as cv # type: ignore - import numpy as np - import requests - - norm_type_mapping = { - "inf": cv.NORM_INF, - "l1": cv.NORM_L1, - "l2": cv.NORM_L2, - "minmax": cv.NORM_MINMAX, - } - - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) - - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] - - response = requests.get(src_url) - bts = response.content + src_obj_ref_rt: str, + dst_obj_ref_rt: str, + alpha: float, + beta: float, + norm_type: str, + ext: str, + verbose: bool, +) -> typing.Optional[str]: + try: + import json + + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + + ext = ext or ".jpeg" + + norm_type_mapping = { + "inf": cv.NORM_INF, + "l1": cv.NORM_L1, + "l2": cv.NORM_L2, + "minmax": cv.NORM_MINMAX, + } + + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) + + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] + + response = session.get(src_url, timeout=30) + response.raise_for_status() + bts = response.content + + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) + img_normalized = cv.normalize( + img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] + ) - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_normalized = cv.normalize( - img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] - ) - bts = cv.imencode(".jpeg", img_normalized)[1].tobytes() + success, encoded = cv.imencode(ext, img_normalized) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + bts = encoded.tobytes() + + ext = ext.replace(".", "") + ext_mappings = {"jpg": "jpeg", "tif": "tiff"} + ext = ext_mappings.get(ext, ext) + content_type = "image/" + ext + + put_response = session.put( + url=dst_url, + data=bts, + headers={ + "Content-Type": content_type, + }, + timeout=30, + ) + put_response.raise_for_status() - requests.put( - url=dst_url, - data=bts, - headers={ - "Content-Type": "image/jpeg", - }, - ) + if verbose: + return json.dumps({"status": "", "content": dst_obj_ref_rt}) + else: + return dst_obj_ref_rt - return dst_obj_ref_rt + except Exception as e: + if verbose: + error_result = { + "status": f"Error: {type(e).__name__}: {str(e)}", + "content": "", + } + return json.dumps(error_result) + else: + return None image_normalize_def = FunctionDef( @@ -286,35 +551,71 @@ def image_normalize_func( def image_normalize_to_bytes_func( - src_obj_ref_rt: str, alpha: float, beta: float, norm_type: str -) -> bytes: + src_obj_ref_rt: str, + alpha: float, + beta: float, + norm_type: str, + ext: str, + verbose: bool, +) -> str: + import base64 import json - import cv2 as cv # type: ignore - import numpy as np - import requests - - norm_type_mapping = { - "inf": cv.NORM_INF, - "l1": cv.NORM_L1, - "l2": cv.NORM_L2, - "minmax": cv.NORM_MINMAX, - } - - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + try: + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + + ext = ext or ".jpeg" + + norm_type_mapping = { + "inf": cv.NORM_INF, + "l1": cv.NORM_L1, + "l2": cv.NORM_L2, + "minmax": cv.NORM_MINMAX, + } + + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + + response = session.get(src_url, timeout=30) + response.raise_for_status() + bts = response.content + + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) + img_normalized = cv.normalize( + img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] + ) + success, encoded = cv.imencode(ext, img_normalized) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + content = encoded.tobytes() - response = requests.get(src_url) - bts = response.content + encoded_content = base64.b64encode(content).decode("utf-8") + result_dict = {"status": "", "content": encoded_content} - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_normalized = cv.normalize( - img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] - ) - bts = cv.imencode(".jpeg", img_normalized)[1].tobytes() + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] - return bts + except Exception as e: + status = f"Error: {type(e).__name__}: {str(e)}" + encoded_content = base64.b64encode(b"").decode("utf-8") + result_dict = {"status": status, "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_normalize_to_bytes_def = FunctionDef( @@ -323,74 +624,105 @@ def image_normalize_to_bytes_func( # Extracts all text from a PDF url -def pdf_extract_func(src_obj_ref_rt: str) -> str: - import io - import json +def pdf_extract_func(src_obj_ref_rt: str, verbose: bool) -> str: + try: + import io + import json - from pypdf import PdfReader # type: ignore - import requests + from pypdf import PdfReader # type: ignore + import requests + from requests import adapters - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) - response = requests.get(src_url, stream=True) - response.raise_for_status() - pdf_bytes = response.content + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - pdf_file = io.BytesIO(pdf_bytes) - reader = PdfReader(pdf_file, strict=False) + response = session.get(src_url, timeout=30, stream=True) + response.raise_for_status() + pdf_bytes = response.content - all_text = "" - for page in reader.pages: - page_extract_text = page.extract_text() - if page_extract_text: - all_text += page_extract_text - return all_text + pdf_file = io.BytesIO(pdf_bytes) + reader = PdfReader(pdf_file, strict=False) + all_text = "" + for page in reader.pages: + page_extract_text = page.extract_text() + if page_extract_text: + all_text += page_extract_text -pdf_extract_def = FunctionDef(pdf_extract_func, ["pypdf", "requests"]) + result_dict = {"status": "", "content": all_text} + except Exception as e: + result_dict = {"status": str(e), "content": ""} -# Extracts text from a PDF url and chunks it simultaneously -def pdf_chunk_func(src_obj_ref_rt: str, chunk_size: int, overlap_size: int) -> str: - import io - import json + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] + + +pdf_extract_def = FunctionDef( + pdf_extract_func, ["pypdf>=5.3.1,<6.0.0", "requests", "cryptography==43.0.3"] +) - from pypdf import PdfReader # type: ignore - import requests - - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - - response = requests.get(src_url, stream=True) - response.raise_for_status() - pdf_bytes = response.content - - pdf_file = io.BytesIO(pdf_bytes) - reader = PdfReader(pdf_file, strict=False) - - # extract and chunk text simultaneously - all_text_chunks = [] - curr_chunk = "" - for page in reader.pages: - page_text = page.extract_text() - if page_text: - curr_chunk += page_text - # split the accumulated text into chunks of a specific size with overlaop - # this loop implements a sliding window approach to create chunks - while len(curr_chunk) >= chunk_size: - split_idx = curr_chunk.rfind(" ", 0, chunk_size) - if split_idx == -1: - split_idx = chunk_size - actual_chunk = curr_chunk[:split_idx] - all_text_chunks.append(actual_chunk) - overlap = curr_chunk[split_idx + 1 : split_idx + 1 + overlap_size] - curr_chunk = overlap + curr_chunk[split_idx + 1 + overlap_size :] - if curr_chunk: - all_text_chunks.append(curr_chunk) - - all_text_json_string = json.dumps(all_text_chunks) - return all_text_json_string - - -pdf_chunk_def = FunctionDef(pdf_chunk_func, ["pypdf", "requests"]) + +# Extracts text from a PDF url and chunks it simultaneously +def pdf_chunk_func( + src_obj_ref_rt: str, chunk_size: int, overlap_size: int, verbose: bool +) -> str: + try: + import io + import json + + from pypdf import PdfReader # type: ignore + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + + response = session.get(src_url, timeout=30, stream=True) + response.raise_for_status() + pdf_bytes = response.content + + pdf_file = io.BytesIO(pdf_bytes) + reader = PdfReader(pdf_file, strict=False) + # extract and chunk text simultaneously + all_text_chunks = [] + curr_chunk = "" + for page in reader.pages: + page_text = page.extract_text() + if page_text: + curr_chunk += page_text + # split the accumulated text into chunks of a specific size with overlaop + # this loop implements a sliding window approach to create chunks + while len(curr_chunk) >= chunk_size: + split_idx = curr_chunk.rfind(" ", 0, chunk_size) + if split_idx == -1: + split_idx = chunk_size + actual_chunk = curr_chunk[:split_idx] + all_text_chunks.append(actual_chunk) + overlap = curr_chunk[split_idx + 1 : split_idx + 1 + overlap_size] + curr_chunk = overlap + curr_chunk[split_idx + 1 + overlap_size :] + if curr_chunk: + all_text_chunks.append(curr_chunk) + + result_dict = {"status": "", "content": all_text_chunks} + + except Exception as e: + result_dict = {"status": str(e), "content": []} + + if verbose: + return json.dumps(result_dict) + else: + return json.dumps(result_dict["content"]) + + +pdf_chunk_def = FunctionDef( + pdf_chunk_func, ["pypdf>=5.3.1,<6.0.0", "requests", "cryptography==43.0.3"] +) diff --git a/bigframes/clients.py b/bigframes/clients.py index c6e1d47909..e6ddd5c6cb 100644 --- a/bigframes/clients.py +++ b/bigframes/clients.py @@ -17,32 +17,58 @@ from __future__ import annotations import logging +import textwrap import time from typing import cast, Optional import google.api_core.exceptions import google.api_core.retry from google.cloud import bigquery_connection_v1, resourcemanager_v3 -from google.iam.v1 import iam_policy_pb2, policy_pb2 +from google.iam.v1 import policy_pb2 logger = logging.getLogger(__name__) -def resolve_full_bq_connection_name( - connection_name: str, default_project: str, default_location: str +def get_canonical_bq_connection_id( + connection_id: str, default_project: str, default_location: str ) -> str: - """Retrieve the full connection name of the form ... - Use default project, location or connection_id when any of them are missing.""" - if connection_name.count(".") == 2: - return connection_name - - if connection_name.count(".") == 1: - return f"{default_project}.{connection_name}" - - if connection_name.count(".") == 0: - return f"{default_project}.{default_location}.{connection_name}" - - raise ValueError(f"Invalid connection name format: {connection_name}.") + """ + Retrieve the full connection id of the form + ... + Use default project, location or connection_id when any of them are missing. + """ + + if "/" in connection_id: + fields = connection_id.split("/") + if ( + len(fields) == 6 + and fields[0] == "projects" + and fields[2] == "locations" + and fields[4] == "connections" + ): + return ".".join((fields[1], fields[3], fields[5])) + else: + if connection_id.count(".") == 2: + return connection_id + + if connection_id.count(".") == 1: + return f"{default_project}.{connection_id}" + + if connection_id.count(".") == 0: + return f"{default_project}.{default_location}.{connection_id}" + + raise ValueError( + textwrap.dedent( + f""" + Invalid connection id format: {connection_id}. + Only the following formats are supported: + .., + ., + , + projects//locations//connections/ + """ + ).strip() + ) class BqConnectionManager: @@ -60,7 +86,11 @@ def __init__( self._cloud_resource_manager_client = cloud_resource_manager_client def create_bq_connection( - self, project_id: str, location: str, connection_id: str, iam_role: str + self, + project_id: str, + location: str, + connection_id: str, + iam_role: Optional[str] = None, ): """Create the BQ connection if not exist. In addition, try to add the IAM role to the connection to ensure required permissions. @@ -80,7 +110,7 @@ def create_bq_connection( ) if service_account_id: logger.info( - f"Connector {project_id}.{location}.{connection_id} already exists" + f"BQ connection {project_id}.{location}.{connection_id} already exists" ) else: connection_name, service_account_id = self._create_bq_connection( @@ -90,20 +120,34 @@ def create_bq_connection( f"Created BQ connection {connection_name} with service account id: {service_account_id}" ) service_account_id = cast(str, service_account_id) + # Ensure IAM role on the BQ connection # https://cloud.google.com/bigquery/docs/reference/standard-sql/remote-functions#grant_permission_on_function - self._ensure_iam_binding(project_id, service_account_id, iam_role) - - # Introduce retries to accommodate transient errors like etag mismatch, - # which can be caused by concurrent operation on the same resource, and - # manifests with message like: - # google.api_core.exceptions.Aborted: 409 There were concurrent policy - # changes. Please retry the whole read-modify-write with exponential - # backoff. The request's ETag '\007\006\003,\264\304\337\272' did not match - # the current policy's ETag '\007\006\003,\3750&\363'. + if iam_role: + try: + self._ensure_iam_binding(project_id, service_account_id, iam_role) + except google.api_core.exceptions.PermissionDenied as ex: + ex.message = f"Failed ensuring IAM binding (role={iam_role}, service-account={service_account_id}). {ex.message}" + raise + + # Introduce retries to accommodate transient errors like: + # (1) Etag mismatch, + # which can be caused by concurrent operation on the same resource, and + # manifests with message like: + # google.api_core.exceptions.Aborted: 409 There were concurrent policy + # changes. Please retry the whole read-modify-write with exponential + # backoff. The request's ETag '\007\006\003,\264\304\337\272' did not + # match the current policy's ETag '\007\006\003,\3750&\363'. + # (2) Connection creation, + # for which sometimes it takes a bit for its service account to reflect + # across APIs (e.g. b/397662004, b/386838767), before which, an attempt + # to set an IAM policy for the service account may throw an error like: + # google.api_core.exceptions.InvalidArgument: 400 Service account + # bqcx-*@gcp-sa-bigquery-condel.iam.gserviceaccount.com does not exist. @google.api_core.retry.Retry( predicate=google.api_core.retry.if_exception_type( - google.api_core.exceptions.Aborted + google.api_core.exceptions.Aborted, + google.api_core.exceptions.InvalidArgument, ), initial=10, maximum=20, @@ -117,7 +161,9 @@ def _ensure_iam_binding( project = f"projects/{project_id}" service_account = f"serviceAccount:{service_account_id}" role = f"roles/{iam_role}" - request = iam_policy_pb2.GetIamPolicyRequest(resource=project) + request = { + "resource": project + } # Use a dictionary to avoid problematic google.iam namespace package. policy = self._cloud_resource_manager_client.get_iam_policy(request=request) # Check if the binding already exists, and if does, do nothing more @@ -129,7 +175,10 @@ def _ensure_iam_binding( # Create a new binding new_binding = policy_pb2.Binding(role=role, members=[service_account]) policy.bindings.append(new_binding) - request = iam_policy_pb2.SetIamPolicyRequest(resource=project, policy=policy) + request = { + "resource": project, + "policy": policy, + } # Use a dictionary to avoid problematic google.iam namespace package. self._cloud_resource_manager_client.set_iam_policy(request=request) # We would wait for the IAM policy change to take effect diff --git a/bigframes/constants.py b/bigframes/constants.py index dbc24401a7..b6e0b8b221 100644 --- a/bigframes/constants.py +++ b/bigframes/constants.py @@ -18,6 +18,7 @@ """ import datetime +import textwrap DEFAULT_EXPIRATION = datetime.timedelta(days=7) @@ -95,12 +96,40 @@ } ) -# https://cloud.google.com/storage/docs/locational-endpoints -LEP_ENABLED_BIGQUERY_LOCATIONS = frozenset( +REP_NOT_ENABLED_BIGQUERY_LOCATIONS = frozenset( ALL_BIGQUERY_LOCATIONS - REP_ENABLED_BIGQUERY_LOCATIONS ) +LOCATION_NEEDED_FOR_REP_MESSAGE = textwrap.dedent( + """ + Must set location to use regional endpoints. + You can do it via bigframaes.pandas.options.bigquery.location. + The supported locations can be found at + https://cloud.google.com/bigquery/docs/regional-endpoints#supported-locations. + """ +).strip() + +REP_NOT_SUPPORTED_MESSAGE = textwrap.dedent( + """ + Support for regional endpoints for BigQuery and BigQuery Storage APIs may + not be available in the location {location}. For the supported APIs and + locations see https://cloud.google.com/bigquery/docs/regional-endpoints. + If you have the (deprecated) locational endpoints enabled in your project + (which requires your project to be allowlisted), you can override the + endpoints directly by doing the following: + bigframes.pandas.options.bigquery.client_endpoints_override = {{ + "bqclient": "https://{location}-bigquery.googleapis.com", + "bqconnectionclient": "{location}-bigqueryconnection.googleapis.com", + "bqstoragereadclient": "{location}-bigquerystorage.googleapis.com" + }} + """ +).strip() + # BigQuery default is 10000, leave 100 for overhead MAX_COLUMNS = 9900 +# BigQuery has 1 MB query size limit. Don't want to take up more than a few % of that inlining a table. +# Also must assume that text encoding as literals is much less efficient than in-memory representation. +MAX_INLINE_BYTES = 5000 + SUGGEST_PEEK_PREVIEW = "Use .peek(n) to preview n arbitrary rows." diff --git a/bigframes/core/agg_expressions.py b/bigframes/core/agg_expressions.py new file mode 100644 index 0000000000..125e3fef63 --- /dev/null +++ b/bigframes/core/agg_expressions.py @@ -0,0 +1,233 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import abc +import dataclasses +import functools +import itertools +import typing +from typing import Callable, Mapping, Tuple, TypeVar + +from bigframes import dtypes +from bigframes.core import expression, window_spec +import bigframes.core.identifiers as ids +import bigframes.operations.aggregations as agg_ops + +TExpression = TypeVar("TExpression", bound="Aggregation") + + +@dataclasses.dataclass(frozen=True) +class Aggregation(expression.Expression): + """Represents windowing or aggregation over a column.""" + + op: agg_ops.WindowOp = dataclasses.field() + + @property + def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: + return tuple( + itertools.chain.from_iterable( + map(lambda x: x.column_references, self.inputs) + ) + ) + + @functools.cached_property + def is_resolved(self) -> bool: + return all(input.is_resolved for input in self.inputs) + + @functools.cached_property + def output_type(self) -> dtypes.ExpressionType: + if not self.is_resolved: + raise ValueError(f"Type of expression {self.op} has not been fixed.") + + input_types = [input.output_type for input in self.inputs] + + return self.op.output_type(*input_types) + + @property + @abc.abstractmethod + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + ... + + @property + def children(self) -> Tuple[expression.Expression, ...]: + return self.inputs + + @property + def free_variables(self) -> typing.Tuple[str, ...]: + return tuple( + itertools.chain.from_iterable(map(lambda x: x.free_variables, self.inputs)) + ) + + @property + def is_const(self) -> bool: + return all(child.is_const for child in self.inputs) + + @functools.cached_property + def is_scalar_expr(self) -> bool: + return False + + @abc.abstractmethod + def replace_args(self: TExpression, *arg) -> TExpression: + ... + + def transform_children( + self: TExpression, t: Callable[[expression.Expression], expression.Expression] + ) -> TExpression: + return self.replace_args(*(t(arg) for arg in self.inputs)) + + def bind_variables( + self: TExpression, + bindings: Mapping[str, expression.Expression], + allow_partial_bindings: bool = False, + ) -> TExpression: + return self.transform_children( + lambda x: x.bind_variables(bindings, allow_partial_bindings) + ) + + def bind_refs( + self: TExpression, + bindings: Mapping[ids.ColumnId, expression.Expression], + allow_partial_bindings: bool = False, + ) -> TExpression: + return self.transform_children( + lambda x: x.bind_refs(bindings, allow_partial_bindings) + ) + + +@dataclasses.dataclass(frozen=True) +class NullaryAggregation(Aggregation): + op: agg_ops.NullaryWindowOp = dataclasses.field() + + @property + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + return () + + def replace_args(self, *arg) -> NullaryAggregation: + return self + + +@dataclasses.dataclass(frozen=True) +class UnaryAggregation(Aggregation): + op: agg_ops.UnaryWindowOp + arg: expression.Expression + + @property + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + return (self.arg,) + + def replace_args(self, arg: expression.Expression) -> UnaryAggregation: + return UnaryAggregation( + self.op, + arg, + ) + + +@dataclasses.dataclass(frozen=True) +class BinaryAggregation(Aggregation): + op: agg_ops.BinaryAggregateOp = dataclasses.field() + left: expression.Expression = dataclasses.field() + right: expression.Expression = dataclasses.field() + + @property + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + return (self.left, self.right) + + def replace_args( + self, larg: expression.Expression, rarg: expression.Expression + ) -> BinaryAggregation: + return BinaryAggregation(self.op, larg, rarg) + + +@dataclasses.dataclass(frozen=True) +class WindowExpression(expression.Expression): + analytic_expr: Aggregation + window: window_spec.WindowSpec + + @property + def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: + return tuple( + itertools.chain.from_iterable( + map(lambda x: x.column_references, self.inputs) + ) + ) + + @functools.cached_property + def is_resolved(self) -> bool: + return all(input.is_resolved for input in self.inputs) + + @property + def output_type(self) -> dtypes.ExpressionType: + return self.analytic_expr.output_type + + @property + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + # TODO: Maybe make the window spec itself an expression? + return (self.analytic_expr, *self.window.expressions) + + @property + def children(self) -> Tuple[expression.Expression, ...]: + return self.inputs + + @property + def free_variables(self) -> typing.Tuple[str, ...]: + return tuple( + itertools.chain.from_iterable(map(lambda x: x.free_variables, self.inputs)) + ) + + @property + def is_const(self) -> bool: + return all(child.is_const for child in self.inputs) + + @functools.cached_property + def is_scalar_expr(self) -> bool: + return False + + def transform_children( + self: WindowExpression, + t: Callable[[expression.Expression], expression.Expression], + ) -> WindowExpression: + return WindowExpression( + t(self.analytic_expr), # type: ignore + self.window.transform_exprs(t), + ) + + def bind_variables( + self: WindowExpression, + bindings: Mapping[str, expression.Expression], + allow_partial_bindings: bool = False, + ) -> WindowExpression: + return self.transform_children( + lambda x: x.bind_variables(bindings, allow_partial_bindings) + ) + + def bind_refs( + self: WindowExpression, + bindings: Mapping[ids.ColumnId, expression.Expression], + allow_partial_bindings: bool = False, + ) -> WindowExpression: + return self.transform_children( + lambda x: x.bind_refs(bindings, allow_partial_bindings) + ) diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index dc9b8e3b9b..7901243e4b 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -16,30 +16,30 @@ from dataclasses import dataclass import datetime import functools -import io import typing -from typing import Iterable, List, Optional, Sequence, Tuple -import warnings +from typing import Iterable, List, Mapping, Optional, Sequence, Tuple import google.cloud.bigquery import pandas import pyarrow as pa -import pyarrow.feather as pa_feather +from bigframes.core import ( + agg_expressions, + bq_data, + expression_factoring, + join_def, + local_data, +) import bigframes.core.expression as ex import bigframes.core.guid import bigframes.core.identifiers as ids -import bigframes.core.join_def as join_def -import bigframes.core.local_data as local_data import bigframes.core.nodes as nodes from bigframes.core.ordering import OrderingExpression import bigframes.core.ordering as orderings import bigframes.core.schema as schemata import bigframes.core.tree_properties -import bigframes.core.utils from bigframes.core.window_spec import WindowSpec import bigframes.dtypes -import bigframes.exceptions as bfe import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops @@ -60,24 +60,20 @@ class ArrayValue: @classmethod def from_pyarrow(cls, arrow_table: pa.Table, session: Session): - adapted_table = local_data.adapt_pa_table(arrow_table) - schema = local_data.arrow_schema_to_bigframes(adapted_table.schema) + data_source = local_data.ManagedArrowTable.from_pyarrow(arrow_table) + return cls.from_managed(source=data_source, session=session) - iobytes = io.BytesIO() - pa_feather.write_feather(adapted_table, iobytes) - # Scan all columns by default, we define this list as it can be pruned while preserving source_def + @classmethod + def from_managed(cls, source: local_data.ManagedArrowTable, session: Session): scan_list = nodes.ScanList( tuple( - nodes.ScanItem(ids.ColumnId(item.column), item.dtype, item.column) - for item in schema.items + nodes.ScanItem(ids.ColumnId(item.column), item.column) + for item in source.schema.items ) ) - node = nodes.ReadLocalNode( - iobytes.getvalue(), - data_schema=schema, + source, session=session, - n_rows=arrow_table.num_rows, scan_list=scan_list, ) return cls(node) @@ -96,24 +92,19 @@ def from_range(cls, start, end, step): def from_table( cls, table: google.cloud.bigquery.Table, - schema: schemata.ArraySchema, session: Session, *, + columns: Optional[Sequence[str]] = None, predicate: Optional[str] = None, at_time: Optional[datetime.datetime] = None, primary_key: Sequence[str] = (), offsets_col: Optional[str] = None, + n_rows: Optional[int] = None, ): if offsets_col and primary_key: raise ValueError("must set at most one of 'offests', 'primary_key'") - if any(i.field_type == "JSON" for i in table.schema if i.name in schema.names): - msg = ( - "Interpreting JSON column(s) as the `db_dtypes.dbjson` extension type is" - "in preview; this behavior may change in future versions." - ) - warnings.warn(msg, bfe.PreviewWarning) # define data source only for needed columns, this makes row-hashing cheaper - table_def = nodes.GbqTable.from_table(table, columns=schema.names) + table_def = bq_data.GbqTable.from_table(table, columns=columns or ()) # create ordering from info ordering = None @@ -124,18 +115,33 @@ def from_table( [ids.ColumnId(key_part) for key_part in primary_key] ) + bf_schema = schemata.ArraySchema.from_bq_table(table, columns=columns) # Scan all columns by default, we define this list as it can be pruned while preserving source_def scan_list = nodes.ScanList( tuple( - nodes.ScanItem(ids.ColumnId(item.column), item.dtype, item.column) - for item in schema.items + nodes.ScanItem(ids.ColumnId(item.column), item.column) + for item in bf_schema.items ) ) - source_def = nodes.BigqueryDataSource( - table=table_def, at_time=at_time, sql_predicate=predicate, ordering=ordering + source_def = bq_data.BigqueryDataSource( + table=table_def, + schema=bf_schema, + at_time=at_time, + sql_predicate=predicate, + ordering=ordering, + n_rows=n_rows, ) + return cls.from_bq_data_source(source_def, scan_list, session) + + @classmethod + def from_bq_data_source( + cls, + source: bq_data.BigqueryDataSource, + scan_list: nodes.ScanList, + session: Session, + ): node = nodes.ReadTableNode( - source=source_def, + source=source, scan_list=scan_list, table_session=session, ) @@ -173,43 +179,22 @@ def order_ambiguous(self) -> bool: def supports_fast_peek(self) -> bool: return bigframes.core.tree_properties.can_fast_peek(self.node) - def as_cached( - self: ArrayValue, - cache_table: google.cloud.bigquery.Table, - ordering: Optional[orderings.RowOrdering], - ) -> ArrayValue: - """ - Replace the node with an equivalent one that references a table where the value has been materialized to. - """ - table = nodes.GbqTable.from_table(cache_table) - source = nodes.BigqueryDataSource(table, ordering=ordering) - # Assumption: GBQ cached table uses field name as bq column name - scan_list = nodes.ScanList( - tuple( - nodes.ScanItem(field.id, field.dtype, field.id.name) - for field in self.node.fields - ) - ) - node = nodes.CachedTableNode( - original_node=self.node, - source=source, - table_session=self.session, - scan_list=scan_list, - ) - return ArrayValue(node) - - def _try_evaluate_local(self): - """Use only for unit testing paths - not fully featured. Will throw exception if fails.""" - import bigframes.core.compile - - return bigframes.core.compile.test_only_try_evaluate(self.node) - def get_column_type(self, key: str) -> bigframes.dtypes.Dtype: return self.schema.get_type(key) def row_count(self) -> ArrayValue: """Get number of rows in ArrayValue as a single-entry ArrayValue.""" - return ArrayValue(nodes.RowCountNode(child=self.node)) + return ArrayValue( + nodes.AggregateNode( + child=self.node, + aggregations=( + ( + agg_expressions.NullaryAggregation(agg_ops.size_op), + ids.ColumnId(bigframes.core.guid.generate_guid()), + ), + ), + ) + ) # Operations def filter_by_id(self, predicate_id: str, keep_null: bool = False) -> ArrayValue: @@ -237,9 +222,6 @@ def reversed(self) -> ArrayValue: def slice( self, start: Optional[int], stop: Optional[int], step: Optional[int] ) -> ArrayValue: - if self.node.order_ambiguous and not (self.session._strictly_ordered): - msg = "Window ordering may be ambiguous, this can cause unstable results." - warnings.warn(msg, bfe.AmbiguousWindowWarning) return ArrayValue( nodes.SliceNode( self.node, @@ -254,17 +236,6 @@ def promote_offsets(self) -> Tuple[ArrayValue, str]: Convenience function to promote copy of column offsets to a value column. Can be used to reset index. """ col_id = self._gen_namespaced_uid() - if self.node.order_ambiguous and not (self.session._strictly_ordered): - if not self.session._allows_ambiguity: - raise ValueError( - "Generating offsets not supported in partial ordering mode" - ) - else: - msg = ( - "Window ordering may be ambiguous, this can cause unstable results." - ) - warnings.warn(msg, category=bfe.AmbiguousWindowWarning) - return ( ArrayValue( nodes.PromoteOffsetsNode(child=self.node, col_id=ids.ColumnId(col_id)) @@ -294,6 +265,97 @@ def compute_values(self, assignments: Sequence[ex.Expression]): col_ids, ) + def compute_general_expression(self, assignments: Sequence[ex.Expression]): + """ + Applies arbitrary column expressions to the current execution block. + + This method transforms the logical plan by applying a sequence of expressions that + preserve the length of the input columns. It supports both scalar operations + and window functions. Each expression is assigned a unique internal column identifier. + + Args: + assignments (Sequence[ex.Expression]): A sequence of expression objects + representing the transformations to apply to the columns. + + Returns: + Tuple[ArrayValue, Tuple[str, ...]]: A tuple containing: + - An `ArrayValue` wrapping the new root node of the updated logical plan. + - A tuple of strings representing the unique column IDs generated for + each expression in the assignments. + """ + named_exprs = [ + nodes.ColumnDef(expr, ids.ColumnId.unique()) for expr in assignments + ] + # TODO: Push this to rewrite later to go from block expression to planning form + new_root = expression_factoring.apply_col_exprs_to_plan(self.node, named_exprs) + + target_ids = tuple(named_expr.id for named_expr in named_exprs) + return (ArrayValue(new_root), target_ids) + + def compute_general_reduction( + self, + assignments: Sequence[ex.Expression], + by_column_ids: typing.Sequence[str] = (), + *, + dropna: bool = False, + ): + """ + Applies arbitrary aggregation expressions to the block, optionally grouped by keys. + + This method handles reduction operations (e.g., sum, mean, count) that collapse + multiple input rows into a single scalar value per group. If grouping keys are + provided, the operation is performed per group; otherwise, it is a global reduction. + + Note: Intermediate aggregations (those that are inputs to further aggregations) + must be windowizable. Notably excluded are approx quantile, top count ops. + + Args: + assignments (Sequence[ex.Expression]): A sequence of aggregation expressions + to be calculated. + by_column_ids (typing.Sequence[str], optional): A sequence of column IDs + to use as grouping keys. Defaults to an empty tuple (global reduction). + dropna (bool, optional): If True, rows containing null values in the + `by_column_ids` columns will be filtered out before the reduction + is applied. Defaults to False. + + Returns: + ArrayValue: + The new root node representing the aggregation/group-by result. + """ + plan = self.node + + # shortcircuit to keep things simple if all aggs are simple + # TODO: Fully unify paths once rewriters are strong enough to simplify complexity from full path + def _is_direct_agg(agg_expr): + return isinstance(agg_expr, agg_expressions.Aggregation) and all( + isinstance(child, (ex.DerefOp, ex.ScalarConstantExpression)) + for child in agg_expr.children + ) + + if all(_is_direct_agg(agg) for agg in assignments): + agg_defs = tuple((agg, ids.ColumnId.unique()) for agg in assignments) + return ArrayValue( + nodes.AggregateNode( + child=self.node, + aggregations=agg_defs, # type: ignore + by_column_ids=tuple(map(ex.deref, by_column_ids)), + dropna=dropna, + ) + ) + + if dropna: + for col_id in by_column_ids: + plan = nodes.FilterNode(plan, ops.notnull_op.as_expr(col_id)) + + named_exprs = [ + nodes.ColumnDef(expr, ids.ColumnId.unique()) for expr in assignments + ] + # TODO: Push this to rewrite later to go from block expression to planning form + new_root = expression_factoring.apply_agg_exprs_to_plan( + plan, named_exprs, grouping_keys=[ex.deref(by) for by in by_column_ids] + ) + return ArrayValue(new_root) + def project_to_id(self, expression: ex.Expression): array_val, ids = self.compute_values( [expression], @@ -342,12 +404,27 @@ def create_constant( return self.project_to_id(ex.const(value, dtype)) - def select_columns(self, column_ids: typing.Sequence[str]) -> ArrayValue: + def select_columns( + self, column_ids: typing.Sequence[str], allow_renames: bool = False + ) -> ArrayValue: # This basically just drops and reorders columns - logically a no-op except as a final step - selections = ( - bigframes.core.nodes.AliasedRef.identity(ids.ColumnId(col_id)) - for col_id in column_ids - ) + selections = [] + seen = set() + + for id in column_ids: + if id not in seen: + ref = nodes.AliasedRef.identity(ids.ColumnId(id)) + elif allow_renames: + ref = nodes.AliasedRef( + ex.deref(id), ids.ColumnId(bigframes.core.guid.generate_guid()) + ) + else: + raise ValueError( + "Must set allow_renames=True to select columns repeatedly" + ) + selections.append(ref) + seen.add(id) + return ArrayValue( nodes.SelectionNode( child=self.node, @@ -355,6 +432,20 @@ def select_columns(self, column_ids: typing.Sequence[str]) -> ArrayValue: ) ) + def rename_columns(self, col_id_overrides: Mapping[str, str]) -> ArrayValue: + if not col_id_overrides: + return self + output_ids = [col_id_overrides.get(id, id) for id in self.node.schema.names] + return ArrayValue( + nodes.SelectionNode( + self.node, + tuple( + nodes.AliasedRef(ex.DerefOp(old_id), ids.ColumnId(out_id)) + for old_id, out_id in zip(self.node.ids, output_ids) + ), + ) + ) + def drop_columns(self, columns: Iterable[str]) -> ArrayValue: return self.select_columns( [col_id for col_id in self.column_ids if col_id not in columns] @@ -362,7 +453,7 @@ def drop_columns(self, columns: Iterable[str]) -> ArrayValue: def aggregate( self, - aggregations: typing.Sequence[typing.Tuple[ex.Aggregation, str]], + aggregations: typing.Sequence[typing.Tuple[agg_expressions.Aggregation, str]], by_column_ids: typing.Sequence[str] = (), dropna: bool = True, ) -> ArrayValue: @@ -383,58 +474,38 @@ def aggregate( ) ) - def project_window_op( + def project_window_expr( self, - column_name: str, - op: agg_ops.UnaryWindowOp, - window_spec: WindowSpec, - *, - never_skip_nulls=False, - skip_reproject_unsafe: bool = False, - ) -> Tuple[ArrayValue, str]: - """ - Creates a new expression based on this expression with unary operation applied to one column. - column_name: the id of the input column present in the expression - op: the windowable operator to apply to the input column - window_spec: a specification of the window over which to apply the operator - output_name: the id to assign to the output of the operator, by default will replace input col if distinct output id not provided - never_skip_nulls: will disable null skipping for operators that would otherwise do so - skip_reproject_unsafe: skips the reprojection step, can be used when performing many non-dependent window operations, user responsible for not nesting window expressions, or using outputs as join, filter or aggregation keys before a reprojection - """ - # TODO: Support non-deterministic windowing - if window_spec.row_bounded or not op.order_independent: - if self.node.order_ambiguous and not self.session._strictly_ordered: - if not self.session._allows_ambiguity: - raise ValueError( - "Generating offsets not supported in partial ordering mode" - ) - else: - msg = "Window ordering may be ambiguous, this can cause unstable results." - warnings.warn(msg, category=bfe.AmbiguousWindowWarning) + expressions: Sequence[agg_expressions.Aggregation], + window: WindowSpec, + ): + id_strings = [self._gen_namespaced_uid() for _ in expressions] + agg_exprs = tuple( + nodes.ColumnDef(expression, ids.ColumnId(id_str)) + for expression, id_str in zip(expressions, id_strings) + ) - output_name = self._gen_namespaced_uid() return ( ArrayValue( nodes.WindowOpNode( child=self.node, - expression=ex.UnaryAggregation(op, ex.deref(column_name)), - window_spec=window_spec, - output_name=ids.ColumnId(output_name), - never_skip_nulls=never_skip_nulls, - skip_reproject_unsafe=skip_reproject_unsafe, + agg_exprs=agg_exprs, + window_spec=window, ) ), - output_name, + id_strings, ) def isin( - self, other: ArrayValue, lcol: str, rcol: str + self, + other: ArrayValue, + lcol: str, ) -> typing.Tuple[ArrayValue, str]: + assert len(other.column_ids) == 1 node = nodes.InNode( self.node, other.node, ex.deref(lcol), - ex.deref(rcol), indicator_col=ids.ColumnId.unique(), ) return ArrayValue(node), node.indicator_col.name @@ -444,7 +515,16 @@ def relational_join( other: ArrayValue, conditions: typing.Tuple[typing.Tuple[str, str], ...] = (), type: typing.Literal["inner", "outer", "left", "right", "cross"] = "inner", + propogate_order: Optional[bool] = None, ) -> typing.Tuple[ArrayValue, typing.Tuple[dict[str, str], dict[str, str]]]: + for lcol, rcol in conditions: + ltype = self.get_column_type(lcol) + rtype = other.get_column_type(rcol) + if not bigframes.dtypes.can_compare(ltype, rtype): + raise TypeError( + f"Cannot join with non-comparable join key types: {ltype}, {rtype}" + ) + l_mapping = { # Identity mapping, only rename right side lcol.name: lcol.name for lcol in self.node.ids } @@ -457,6 +537,7 @@ def relational_join( for l_col, r_col in conditions ), type=type, + propogate_order=propogate_order or self.session._strictly_ordered, ) return ArrayValue(join_node), (l_mapping, r_mapping) diff --git a/bigframes/core/backports.py b/bigframes/core/backports.py new file mode 100644 index 0000000000..09ba09731c --- /dev/null +++ b/bigframes/core/backports.py @@ -0,0 +1,33 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Helpers for working across versions of different depenencies.""" + +from typing import List + +import pyarrow + + +def pyarrow_struct_type_fields(struct_type: pyarrow.StructType) -> List[pyarrow.Field]: + """StructType.fields was added in pyarrow 18. + + See: https://arrow.apache.org/docs/18.0/python/generated/pyarrow.StructType.html + """ + + if hasattr(struct_type, "fields"): + return struct_type.fields + + return [ + struct_type.field(field_index) for field_index in range(struct_type.num_fields) + ] diff --git a/bigframes/core/bigframe_node.py b/bigframes/core/bigframe_node.py index 32c7f92912..7e40248a00 100644 --- a/bigframes/core/bigframe_node.py +++ b/bigframes/core/bigframe_node.py @@ -20,34 +20,15 @@ import functools import itertools import typing -from typing import Callable, Dict, Generator, Iterable, Mapping, Set, Tuple +from typing import Callable, Dict, Generator, Iterable, Mapping, Sequence, Tuple -from bigframes.core import identifiers -import bigframes.core.guid +from bigframes.core import expression, field, identifiers import bigframes.core.schema as schemata import bigframes.dtypes -if typing.TYPE_CHECKING: - import bigframes.session - COLUMN_SET = frozenset[identifiers.ColumnId] - -@dataclasses.dataclass(frozen=True) -class Field: - id: identifiers.ColumnId - dtype: bigframes.dtypes.Dtype - # Best effort, nullable=True if not certain - nullable: bool = True - - def with_nullable(self) -> Field: - return Field(self.id, self.dtype, nullable=True) - - def with_nonnull(self) -> Field: - return Field(self.id, self.dtype, nullable=False) - - def with_id(self, id: identifiers.ColumnId) -> Field: - return Field(id, self.dtype, nullable=self.nullable) +T = typing.TypeVar("T") @dataclasses.dataclass(eq=False, frozen=True) @@ -161,7 +142,7 @@ def roots(self) -> typing.Set[BigFrameNode]: # TODO: Store some local data lazily for select, aggregate nodes. @property @abc.abstractmethod - def fields(self) -> Iterable[Field]: + def fields(self) -> Sequence[field.Field]: ... @property @@ -291,9 +272,16 @@ def _dtype_lookup(self) -> dict[identifiers.ColumnId, bigframes.dtypes.Dtype]: return {field.id: field.dtype for field in self.fields} @functools.cached_property - def field_by_id(self) -> Mapping[identifiers.ColumnId, Field]: + def field_by_id(self) -> Mapping[identifiers.ColumnId, field.Field]: return {field.id: field for field in self.fields} + @property + def _node_expressions( + self, + ) -> Sequence[expression.Expression]: + """List of expressions. Intended for checking engine compatibility with used ops.""" + return () + # Plan algorithms def unique_nodes( self: BigFrameNode, @@ -308,33 +296,31 @@ def unique_nodes( seen.add(item) stack.extend(item.child_nodes) - def edges( + def iter_nodes_topo( self: BigFrameNode, - ) -> Generator[Tuple[BigFrameNode, BigFrameNode], None, None]: - for item in self.unique_nodes(): - for child in item.child_nodes: - yield (item, child) - - def iter_nodes_topo(self: BigFrameNode) -> Generator[BigFrameNode, None, None]: - """Returns nodes from bottom up.""" - queue = collections.deque( - [node for node in self.unique_nodes() if not node.child_nodes] - ) - + ) -> Generator[BigFrameNode, None, None]: + """Returns nodes in reverse topological order, using Kahn's algorithm.""" child_to_parents: Dict[ - BigFrameNode, Set[BigFrameNode] - ] = collections.defaultdict(set) - for parent, child in self.edges(): - child_to_parents[child].add(parent) - - yielded = set() + BigFrameNode, list[BigFrameNode] + ] = collections.defaultdict(list) + out_degree: Dict[BigFrameNode, int] = collections.defaultdict(int) + + queue: collections.deque["BigFrameNode"] = collections.deque() + for node in list(self.unique_nodes()): + num_children = len(node.child_nodes) + out_degree[node] = num_children + if num_children == 0: + queue.append(node) + for child in node.child_nodes: + child_to_parents[child].append(node) while queue: item = queue.popleft() yield item - yielded.add(item) - for parent in child_to_parents[item]: - if set(parent.child_nodes).issubset(yielded): + parents = child_to_parents.get(item, []) + for parent in parents: + out_degree[parent] -= 1 + if out_degree[parent] == 0: queue.append(parent) def top_down( @@ -382,3 +368,14 @@ def bottom_up( results[node] = result return results[self] + + def reduce_up(self, reduction: Callable[[BigFrameNode, Tuple[T, ...]], T]) -> T: + """Apply a bottom-up reduction to the tree.""" + results: dict[BigFrameNode, T] = {} + for node in list(self.iter_nodes_topo()): + # child nodes have already been transformed + child_results = tuple(results[child] for child in node.child_nodes) + result = reduction(node, child_results) + results[node] = result + + return results[self] diff --git a/bigframes/core/block_transforms.py b/bigframes/core/block_transforms.py index 8ef3aa123b..5c6395d171 100644 --- a/bigframes/core/block_transforms.py +++ b/bigframes/core/block_transforms.py @@ -21,6 +21,7 @@ import pandas as pd import bigframes.constants +from bigframes.core import agg_expressions import bigframes.core as core import bigframes.core.blocks as blocks import bigframes.core.expression as ex @@ -66,40 +67,39 @@ def indicate_duplicates( if keep not in ["first", "last", False]: raise ValueError("keep must be one of 'first', 'last', or False'") + rownums = agg_expressions.WindowExpression( + agg_expressions.NullaryAggregation( + agg_ops.RowNumberOp(), + ), + window=windows.unbound(grouping_keys=tuple(columns)), + ) + count = agg_expressions.WindowExpression( + agg_expressions.NullaryAggregation( + agg_ops.SizeOp(), + ), + window=windows.unbound(grouping_keys=tuple(columns)), + ) + if keep == "first": # Count how many copies occur up to current copy of value # Discard this value if there are copies BEFORE - window_spec = windows.cumulative_rows( - grouping_keys=tuple(columns), - ) + predicate = ops.gt_op.as_expr(rownums, ex.const(0)) elif keep == "last": # Count how many copies occur up to current copy of values # Discard this value if there are copies AFTER - window_spec = windows.inverse_cumulative_rows( - grouping_keys=tuple(columns), - ) + predicate = ops.lt_op.as_expr(rownums, ops.sub_op.as_expr(count, ex.const(1))) else: # keep == False # Count how many copies of the value occur in entire series. # Discard this value if there are copies ANYWHERE - window_spec = windows.unbound(grouping_keys=tuple(columns)) - block, dummy = block.create_constant(1) - # use row number as will work even with partial ordering - block, val_count_col_id = block.apply_window_op( - dummy, - agg_ops.sum_op, - window_spec=window_spec, - ) - block, duplicate_indicator = block.project_expr( - ops.gt_op.as_expr(val_count_col_id, ex.const(1)) + predicate = ops.gt_op.as_expr(count, ex.const(1)) + + block = block.project_block_exprs( + [predicate], + labels=[None], ) return ( - block.drop_columns( - ( - dummy, - val_count_col_id, - ) - ), - duplicate_indicator, + block, + block.value_columns[-1], ) @@ -129,12 +129,12 @@ def quantile( window_spec=window, ) quantile_cols.append(quantile_col) - block, _ = block.aggregate( - grouping_column_ids, + block = block.aggregate( tuple( - ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col)) + agg_expressions.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col)) for col in quantile_cols ), + grouping_column_ids, column_labels=pd.Index(labels), dropna=dropna, ) @@ -212,8 +212,8 @@ def _interpolate_column( if interpolate_method not in ["linear", "nearest", "ffill"]: raise ValueError("interpolate method not supported") window_ordering = (ordering.OrderingExpression(ex.deref(x_values)),) - backwards_window = windows.rows(following=0, ordering=window_ordering) - forwards_window = windows.rows(preceding=0, ordering=window_ordering) + backwards_window = windows.rows(end=0, ordering=window_ordering) + forwards_window = windows.rows(start=0, ordering=window_ordering) # Note, this method may block, notnull = block.apply_unary_op(column, ops.notnull_op) @@ -231,13 +231,11 @@ def _interpolate_column( masked_offsets, agg_ops.LastNonNullOp(), backwards_window, - skip_reproject_unsafe=True, ) block, next_value_offset = block.apply_window_op( masked_offsets, agg_ops.FirstNonNullOp(), forwards_window, - skip_reproject_unsafe=True, ) if interpolate_method == "linear": @@ -354,24 +352,28 @@ def value_counts( normalize: bool = False, sort: bool = True, ascending: bool = False, - dropna: bool = True, + drop_na: bool = True, + grouping_keys: typing.Sequence[str] = (), ): - block, dummy = block.create_constant(1) - block, agg_ids = block.aggregate( - by_column_ids=columns, - aggregations=[ex.UnaryAggregation(agg_ops.count_op, ex.deref(dummy))], - dropna=dropna, - ) - count_id = agg_ids[0] + if grouping_keys and drop_na: + # only need this if grouping_keys is involved, otherwise the drop_na in the aggregation will handle it for us + block = dropna(block, columns, how="any") + block = block.aggregate( + aggregations=[agg_expressions.NullaryAggregation(agg_ops.size_op)], + by_column_ids=(*grouping_keys, *columns), + dropna=drop_na and not grouping_keys, + ) + count_id = block.value_columns[0] if normalize: - unbound_window = windows.unbound() + unbound_window = windows.unbound(grouping_keys=tuple(grouping_keys)) block, total_count_id = block.apply_window_op( count_id, agg_ops.sum_op, unbound_window ) block, count_id = block.apply_binary_op(count_id, total_count_id, ops.div_op) if sort: - block = block.order_by( + order_parts = [ordering.ascending_over(id) for id in grouping_keys] + order_parts.extend( [ ordering.OrderingExpression( ex.deref(count_id), @@ -381,6 +383,7 @@ def value_counts( ) ] ) + block = block.order_by(order_parts) return block.select_column(count_id).with_column_labels( ["proportion" if normalize else "count"] ) @@ -393,15 +396,18 @@ def pct_change(block: blocks.Block, periods: int = 1) -> blocks.Block: window_spec = windows.unbound() original_columns = block.value_columns - block, shift_columns = block.multi_apply_window_op( - original_columns, agg_ops.ShiftOp(periods), window_spec=window_spec - ) exprs = [] - for original_col, shifted_col in zip(original_columns, shift_columns): - change_expr = ops.sub_op.as_expr(original_col, shifted_col) - pct_change_expr = ops.div_op.as_expr(change_expr, shifted_col) + for original_col in original_columns: + shift_expr = agg_expressions.WindowExpression( + agg_expressions.UnaryAggregation( + agg_ops.ShiftOp(periods), ex.deref(original_col) + ), + window_spec, + ) + change_expr = ops.sub_op.as_expr(original_col, shift_expr) + pct_change_expr = ops.div_op.as_expr(change_expr, shift_expr) exprs.append(pct_change_expr) - return block.project_exprs(exprs, labels=column_labels, drop=True) + return block.project_block_exprs(exprs, labels=column_labels, drop=True) def rank( @@ -409,6 +415,9 @@ def rank( method: str = "average", na_option: str = "keep", ascending: bool = True, + grouping_cols: tuple[str, ...] = (), + columns: tuple[str, ...] = (), + pct: bool = False, ): if method not in ["average", "min", "max", "first", "dense"]: raise ValueError( @@ -417,18 +426,13 @@ def rank( if na_option not in ["keep", "top", "bottom"]: raise ValueError("na_option must be one of 'keep', 'top', or 'bottom'") - columns = block.value_columns - labels = block.column_labels - # Step 1: Calculate row numbers for each row - # Identify null values to be treated according to na_option param - rownum_col_ids = [] - nullity_col_ids = [] + columns = columns or tuple(col for col in block.value_columns) + labels = [block.col_id_to_label[id] for id in columns] + + result_exprs = [] for col in columns: - block, nullity_col_id = block.apply_unary_op( - col, - ops.isnull_op, - ) - nullity_col_ids.append(nullity_col_id) + # Step 1: Calculate row numbers for each row + # Identify null values to be treated according to na_option param window_ordering = ( ordering.OrderingExpression( ex.deref(col), @@ -439,63 +443,73 @@ def rank( ), ) # Count_op ignores nulls, so if na_option is "top" or "bottom", we instead count the nullity columns, where nulls have been mapped to bools - block, rownum_id = block.apply_window_op( - col if na_option == "keep" else nullity_col_id, - agg_ops.dense_rank_op if method == "dense" else agg_ops.count_op, - window_spec=windows.unbound(ordering=window_ordering) - if method == "dense" - else windows.rows(following=0, ordering=window_ordering), - skip_reproject_unsafe=(col != columns[-1]), + target_expr = ( + ex.deref(col) if na_option == "keep" else ops.isnull_op.as_expr(col) ) - rownum_col_ids.append(rownum_id) - - # Step 2: Apply aggregate to groups of like input values. - # This step is skipped for method=='first' or 'dense' - if method in ["average", "min", "max"]: - agg_op = { - "average": agg_ops.mean_op, - "min": agg_ops.min_op, - "max": agg_ops.max_op, - }[method] - post_agg_rownum_col_ids = [] - for i in range(len(columns)): - block, result_id = block.apply_window_op( - rownum_col_ids[i], - agg_op, - window_spec=windows.unbound(grouping_keys=(columns[i],)), - skip_reproject_unsafe=(i < (len(columns) - 1)), + window_op = agg_ops.dense_rank_op if method == "dense" else agg_ops.count_op + window_spec = ( + windows.unbound(grouping_keys=grouping_cols, ordering=window_ordering) + if method == "dense" + else windows.rows( + end=0, ordering=window_ordering, grouping_keys=grouping_cols ) - post_agg_rownum_col_ids.append(result_id) - rownum_col_ids = post_agg_rownum_col_ids - - # Step 3: post processing: mask null values and cast to float - if method in ["min", "max", "first", "dense"]: - # Pandas rank always produces Float64, so must cast for aggregation types that produce ints - return ( - block.select_columns(rownum_col_ids) - .multi_apply_unary_op(ops.AsTypeOp(pd.Float64Dtype())) - .with_column_labels(labels) ) - if na_option == "keep": - # For na_option "keep", null inputs must produce null outputs - exprs = [] - for i in range(len(columns)): - exprs.append( - ops.where_op.as_expr( - ex.const(pd.NA, dtype=pd.Float64Dtype()), - nullity_col_ids[i], - rownum_col_ids[i], - ) + result_expr: ex.Expression = agg_expressions.WindowExpression( + agg_expressions.UnaryAggregation(window_op, target_expr), window_spec + ) + if pct: + result_expr = ops.div_op.as_expr( + result_expr, + agg_expressions.WindowExpression( + agg_expressions.UnaryAggregation(agg_ops.max_op, result_expr), + windows.unbound(grouping_keys=grouping_cols), + ), + ) + # Step 2: Apply aggregate to groups of like input values. + # This step is skipped for method=='first' or 'dense' + if method in ["average", "min", "max"]: + agg_op = { + "average": agg_ops.mean_op, + "min": agg_ops.min_op, + "max": agg_ops.max_op, + }[method] + result_expr = agg_expressions.WindowExpression( + agg_expressions.UnaryAggregation(agg_op, result_expr), + windows.unbound(grouping_keys=(col, *grouping_cols)), + ) + # Pandas masks all values where any grouping column is null + # Note: we use pd.NA instead of float('nan') + if grouping_cols: + predicate = functools.reduce( + ops.and_op.as_expr, + [ops.notnull_op.as_expr(column_id) for column_id in grouping_cols], + ) + result_expr = ops.where_op.as_expr( + result_expr, + predicate, + ex.const(None), ) - return block.project_exprs(exprs, labels=labels, drop=True) - return block.select_columns(rownum_col_ids).with_column_labels(labels) + # Step 3: post processing: mask null values and cast to float + if method in ["min", "max", "first", "dense"]: + # Pandas rank always produces Float64, so must cast for aggregation types that produce ints + result_expr = ops.AsTypeOp(pd.Float64Dtype()).as_expr(result_expr) + elif na_option == "keep": + # For na_option "keep", null inputs must produce null outputs + result_expr = ops.where_op.as_expr( + ex.const(pd.NA, dtype=pd.Float64Dtype()), + ops.isnull_op.as_expr(col), + result_expr, + ) + result_exprs.append(result_expr) + return block.project_block_exprs(result_exprs, labels=labels, drop=True) def dropna( block: blocks.Block, column_ids: typing.Sequence[str], - how: typing.Literal["all", "any"] = "any", + how: str = "any", + thresh: typing.Optional[int] = None, subset: Optional[typing.Sequence[str]] = None, ): """ @@ -504,17 +518,38 @@ def dropna( if subset is None: subset = column_ids + # Predicates to check for non-null values in the subset of columns predicates = [ ops.notnull_op.as_expr(column_id) for column_id in column_ids if column_id in subset ] + if len(predicates) == 0: return block - if how == "any": - predicate = functools.reduce(ops.and_op.as_expr, predicates) - else: # "all" - predicate = functools.reduce(ops.or_op.as_expr, predicates) + + if thresh is not None: + # Handle single predicate case + if len(predicates) == 1: + count_expr = ops.AsTypeOp(pd.Int64Dtype()).as_expr(predicates[0]) + else: + # Sum the boolean expressions to count non-null values + count_expr = functools.reduce( + lambda a, b: ops.add_op.as_expr( + ops.AsTypeOp(pd.Int64Dtype()).as_expr(a), + ops.AsTypeOp(pd.Int64Dtype()).as_expr(b), + ), + predicates, + ) + # Filter rows where count >= thresh + predicate = ops.ge_op.as_expr(count_expr, ex.const(thresh)) + else: + # Only handle 'how' parameter when thresh is not specified + if how == "any": + predicate = functools.reduce(ops.and_op.as_expr, predicates) + else: # "all" + predicate = functools.reduce(ops.or_op.as_expr, predicates) + return block.filter(predicate) @@ -587,40 +622,14 @@ def skew( original_columns = skew_column_ids column_labels = block.select_columns(original_columns).column_labels - block, delta3_ids = _mean_delta_to_power( - block, 3, original_columns, grouping_column_ids - ) # counts, moment3 for each column aggregations = [] - for i, col in enumerate(original_columns): - count_agg = ex.UnaryAggregation( - agg_ops.count_op, - ex.deref(col), - ) - moment3_agg = ex.UnaryAggregation( - agg_ops.mean_op, - ex.deref(delta3_ids[i]), - ) - variance_agg = ex.UnaryAggregation( - agg_ops.PopVarOp(), - ex.deref(col), - ) - aggregations.extend([count_agg, moment3_agg, variance_agg]) + for col in original_columns: + aggregations.append(skew_expr(ex.deref(col))) - block, agg_ids = block.aggregate( - by_column_ids=grouping_column_ids, aggregations=aggregations + block = block.aggregate( + aggregations, grouping_column_ids, column_labels=column_labels ) - - skew_ids = [] - for i, col in enumerate(original_columns): - # Corresponds to order of aggregations in preceding loop - count_id, moment3_id, var_id = agg_ids[i * 3 : (i * 3) + 3] - block, skew_id = _skew_from_moments_and_count( - block, count_id, moment3_id, var_id - ) - skew_ids.append(skew_id) - - block = block.select_columns(skew_ids).with_column_labels(column_labels) if not grouping_column_ids: # When ungrouped, transpose result row into a series # perform transpose last, so as to not invalidate cache @@ -637,32 +646,14 @@ def kurt( ) -> blocks.Block: original_columns = skew_column_ids column_labels = block.select_columns(original_columns).column_labels - - block, delta4_ids = _mean_delta_to_power( - block, 4, original_columns, grouping_column_ids - ) # counts, moment4 for each column - aggregations = [] - for i, col in enumerate(original_columns): - count_agg = ex.UnaryAggregation(agg_ops.count_op, ex.deref(col)) - moment4_agg = ex.UnaryAggregation(agg_ops.mean_op, ex.deref(delta4_ids[i])) - variance_agg = ex.UnaryAggregation(agg_ops.PopVarOp(), ex.deref(col)) - aggregations.extend([count_agg, moment4_agg, variance_agg]) - - block, agg_ids = block.aggregate( - by_column_ids=grouping_column_ids, aggregations=aggregations - ) - - kurt_ids = [] - for i, col in enumerate(original_columns): - # Corresponds to order of aggregations in preceding loop - count_id, moment4_id, var_id = agg_ids[i * 3 : (i * 3) + 3] - block, kurt_id = _kurt_from_moments_and_count( - block, count_id, moment4_id, var_id - ) - kurt_ids.append(kurt_id) + kurt_exprs = [] + for col in original_columns: + kurt_exprs.append(kurt_expr(ex.deref(col))) - block = block.select_columns(kurt_ids).with_column_labels(column_labels) + block = block.aggregate( + kurt_exprs, grouping_column_ids, column_labels=column_labels + ) if not grouping_column_ids: # When ungrouped, transpose result row into a series # perform transpose last, so as to not invalidate cache @@ -672,39 +663,56 @@ def kurt( return block +def skew_expr(expr: ex.Expression) -> ex.Expression: + delta3_expr = _mean_delta_to_power(3, expr) + count_agg = agg_expressions.UnaryAggregation( + agg_ops.count_op, + expr, + ) + moment3_agg = agg_expressions.UnaryAggregation( + agg_ops.mean_op, + delta3_expr, + ) + variance_agg = agg_expressions.UnaryAggregation( + agg_ops.PopVarOp(), + expr, + ) + return _skew_from_moments_and_count(count_agg, moment3_agg, variance_agg) + + +def kurt_expr(expr: ex.Expression) -> ex.Expression: + delta_4_expr = _mean_delta_to_power(4, expr) + count_agg = agg_expressions.UnaryAggregation(agg_ops.count_op, expr) + moment4_agg = agg_expressions.UnaryAggregation(agg_ops.mean_op, delta_4_expr) + variance_agg = agg_expressions.UnaryAggregation(agg_ops.PopVarOp(), expr) + return _kurt_from_moments_and_count(count_agg, moment4_agg, variance_agg) + + def _mean_delta_to_power( - block: blocks.Block, n_power: int, - column_ids: typing.Sequence[str], - grouping_column_ids: typing.Sequence[str], -) -> typing.Tuple[blocks.Block, typing.Sequence[str]]: + col_expr: ex.Expression, +) -> ex.Expression: """Calculate (x-mean(x))^n. Useful for calculating moment statistics such as skew and kurtosis.""" - window = windows.unbound(grouping_keys=tuple(grouping_column_ids)) - block, mean_ids = block.multi_apply_window_op(column_ids, agg_ops.mean_op, window) - delta_ids = [] - for val_id, mean_val_id in zip(column_ids, mean_ids): - delta = ops.sub_op.as_expr(val_id, mean_val_id) - delta_power = ops.pow_op.as_expr(delta, ex.const(n_power)) - block, delta_power_id = block.project_expr(delta_power) - delta_ids.append(delta_power_id) - return block, delta_ids + mean_expr = agg_expressions.UnaryAggregation(agg_ops.mean_op, col_expr) + delta = ops.sub_op.as_expr(col_expr, mean_expr) + return ops.pow_op.as_expr(delta, ex.const(n_power)) def _skew_from_moments_and_count( - block: blocks.Block, count_id: str, moment3_id: str, moment2_id: str -) -> typing.Tuple[blocks.Block, str]: + count: ex.Expression, moment3: ex.Expression, moment2: ex.Expression +) -> ex.Expression: # Calculate skew using count, third moment and population variance # See G1 estimator: # https://en.wikipedia.org/wiki/Skewness#Sample_skewness moments_estimator = ops.div_op.as_expr( - moment3_id, ops.pow_op.as_expr(moment2_id, ex.const(3 / 2)) + moment3, ops.pow_op.as_expr(moment2, ex.const(3 / 2)) ) - countminus1 = ops.sub_op.as_expr(count_id, ex.const(1)) - countminus2 = ops.sub_op.as_expr(count_id, ex.const(2)) + countminus1 = ops.sub_op.as_expr(count, ex.const(1)) + countminus2 = ops.sub_op.as_expr(count, ex.const(2)) adjustment = ops.div_op.as_expr( ops.unsafe_pow_op.as_expr( - ops.mul_op.as_expr(count_id, countminus1), ex.const(1 / 2) + ops.mul_op.as_expr(count, countminus1), ex.const(1 / 2) ), countminus2, ) @@ -713,14 +721,14 @@ def _skew_from_moments_and_count( # Need to produce NA if have less than 3 data points cleaned_skew = ops.where_op.as_expr( - skew, ops.ge_op.as_expr(count_id, ex.const(3)), ex.const(None) + skew, ops.ge_op.as_expr(count, ex.const(3)), ex.const(None) ) - return block.project_expr(cleaned_skew) + return cleaned_skew def _kurt_from_moments_and_count( - block: blocks.Block, count_id: str, moment4_id: str, moment2_id: str -) -> typing.Tuple[blocks.Block, str]: + count: ex.Expression, moment4: ex.Expression, moment2: ex.Expression +) -> ex.Expression: # Kurtosis is often defined as the second standardize moment: moment(4)/moment(2)**2 # Pandas however uses Fisher’s estimator, implemented below # numerator = (count + 1) * (count - 1) * moment4 @@ -729,28 +737,26 @@ def _kurt_from_moments_and_count( # kurtosis = (numerator / denominator) - adjustment numerator = ops.mul_op.as_expr( - moment4_id, + moment4, ops.mul_op.as_expr( - ops.sub_op.as_expr(count_id, ex.const(1)), - ops.add_op.as_expr(count_id, ex.const(1)), + ops.sub_op.as_expr(count, ex.const(1)), + ops.add_op.as_expr(count, ex.const(1)), ), ) # Denominator - countminus2 = ops.sub_op.as_expr(count_id, ex.const(2)) - countminus3 = ops.sub_op.as_expr(count_id, ex.const(3)) + countminus2 = ops.sub_op.as_expr(count, ex.const(2)) + countminus3 = ops.sub_op.as_expr(count, ex.const(3)) # Denominator denominator = ops.mul_op.as_expr( - ops.unsafe_pow_op.as_expr(moment2_id, ex.const(2)), + ops.unsafe_pow_op.as_expr(moment2, ex.const(2)), ops.mul_op.as_expr(countminus2, countminus3), ) # Adjustment adj_num = ops.mul_op.as_expr( - ops.unsafe_pow_op.as_expr( - ops.sub_op.as_expr(count_id, ex.const(1)), ex.const(2) - ), + ops.unsafe_pow_op.as_expr(ops.sub_op.as_expr(count, ex.const(1)), ex.const(2)), ex.const(3), ) adj_denom = ops.mul_op.as_expr(countminus2, countminus3) @@ -761,9 +767,9 @@ def _kurt_from_moments_and_count( # Need to produce NA if have less than 4 data points cleaned_kurt = ops.where_op.as_expr( - kurt, ops.ge_op.as_expr(count_id, ex.const(4)), ex.const(None) + kurt, ops.ge_op.as_expr(count, ex.const(4)), ex.const(None) ) - return block.project_expr(cleaned_kurt) + return cleaned_kurt def align( diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 10970b24e8..0f98f582c2 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -27,17 +27,16 @@ import functools import itertools import random -import textwrap import typing from typing import ( Iterable, + Iterator, List, Literal, Mapping, Optional, Sequence, Tuple, - TYPE_CHECKING, Union, ) import warnings @@ -49,29 +48,27 @@ import pyarrow as pa from bigframes import session -import bigframes._config.sampling_options as sampling_options +from bigframes._config import sampling_options import bigframes.constants +from bigframes.core import agg_expressions, local_data import bigframes.core as core -import bigframes.core.compile.googlesql as googlesql +import bigframes.core.agg_expressions as ex_types import bigframes.core.expression as ex import bigframes.core.expression as scalars import bigframes.core.guid as guid import bigframes.core.identifiers import bigframes.core.join_def as join_defs import bigframes.core.ordering as ordering -import bigframes.core.schema as bf_schema -import bigframes.core.sql as sql +import bigframes.core.pyarrow_utils as pyarrow_utils import bigframes.core.utils as utils import bigframes.core.window_spec as windows import bigframes.dtypes import bigframes.exceptions as bfe -import bigframes.features import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops -import bigframes.session._io.pandas as io_pandas - -if TYPE_CHECKING: - import bigframes.session.executor +from bigframes.session import dry_runs, execution_spec +from bigframes.session import executor as executors +from bigframes.session._io import pandas as io_pandas # Type constraint for wherever column labels are used Label = typing.Hashable @@ -98,14 +95,30 @@ LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] -class BlockHolder(typing.Protocol): +class PandasBatches(Iterator[pd.DataFrame]): """Interface for mutable objects with state represented by a block value object.""" - def _set_block(self, block: Block): - """Set the underlying block value of the object""" + def __init__( + self, + pandas_batches: Iterator[pd.DataFrame], + total_rows: Optional[int] = 0, + *, + total_bytes_processed: Optional[int] = 0, + ): + self._dataframes: Iterator[pd.DataFrame] = pandas_batches + self._total_rows: Optional[int] = total_rows + self._total_bytes_processed: Optional[int] = total_bytes_processed + + @property + def total_rows(self) -> Optional[int]: + return self._total_rows + + @property + def total_bytes_processed(self) -> Optional[int]: + return self._total_bytes_processed - def _get_block(self) -> Block: - """Get the underlying block value of the object""" + def __next__(self) -> pd.DataFrame: + return next(self._dataframes) @dataclasses.dataclass() @@ -113,6 +126,7 @@ class MaterializationOptions: downsampling: sampling_options.SamplingOptions = dataclasses.field( default_factory=sampling_options.SamplingOptions ) + allow_large_results: Optional[bool] = None ordered: bool = True @@ -137,9 +151,6 @@ def __init__( f"'index_columns' (size {len(index_columns)}) and 'index_labels' (size {len(index_labels)}) must have equal length" ) - if len(index_columns) == 0: - msg = "Creating object with Null Index. Null Index is a preview feature." - warnings.warn(msg, category=bfe.NullIndexPreviewWarning) self._index_columns = tuple(index_columns) # Index labels don't need complicated hierarchical access so can store as tuple self._index_labels = ( @@ -167,6 +178,38 @@ def __init__( self._stats_cache[" ".join(self.index_columns)] = {} self._transpose_cache: Optional[Block] = transpose_cache + self._view_ref: Optional[bigquery.TableReference] = None + self._view_ref_dry_run: Optional[bigquery.TableReference] = None + + @classmethod + def from_pyarrow( + cls, + data: pa.Table, + session: bigframes.Session, + ) -> Block: + column_labels = data.column_names + + # TODO(tswast): Use array_value.promote_offsets() instead once that node is + # supported by the local engine. + offsets_col = bigframes.core.guid.generate_guid() + index_ids = [offsets_col] + index_labels = [None] + + # TODO(https://github.com/googleapis/python-bigquery-dataframes/issues/859): + # Allow users to specify the "total ordering" column(s) or allow multiple + # such columns. + data = pyarrow_utils.append_offsets(data, offsets_col=offsets_col) + + # from_pyarrow will normalize the types for us. + managed_data = local_data.ManagedArrowTable.from_pyarrow(data) + array_value = core.ArrayValue.from_managed(managed_data, session=session) + block = cls( + array_value, + column_labels=column_labels, + index_columns=index_ids, + index_labels=index_labels, + ) + return block @classmethod def from_local( @@ -187,8 +230,8 @@ def from_local( pd_data = pd_data.set_axis(column_ids, axis=1) pd_data = pd_data.reset_index(names=index_ids) - as_pyarrow = pa.Table.from_pandas(pd_data, preserve_index=False) - array_value = core.ArrayValue.from_pyarrow(as_pyarrow, session=session) + managed_data = local_data.ManagedArrowTable.from_pandas(pd_data) + array_value = core.ArrayValue.from_managed(managed_data, session=session) block = cls( array_value, column_labels=column_labels, @@ -205,6 +248,10 @@ def from_local( pass return block + @property + def has_index(self) -> bool: + return len(self._index_columns) > 0 + @property def index(self) -> BlockIndexProperties: """Row identities for values in the Block.""" @@ -213,18 +260,21 @@ def index(self) -> BlockIndexProperties: @functools.cached_property def shape(self) -> typing.Tuple[int, int]: """Returns dimensions as (length, width) tuple.""" - - row_count_expr = self.expr.row_count() - - # Support in-memory engines for hermetic unit tests. - if self.expr.session is None: + # Support zero-query for hermetic unit tests. + if self.expr.session is None and self.expr.node.row_count: try: - row_count = row_count_expr._try_evaluate_local().squeeze() - return (row_count, len(self.value_columns)) + return self.expr.node.row_count except Exception: pass - row_count = self.session._executor.get_row_count(self.expr) + row_count = ( + self.session._executor.execute( + self.expr.row_count(), + execution_spec.ExecutionSpec(promise_under_10gb=True, ordered=False), + ) + .batches() + .to_py_scalar() + ) return (row_count, len(self.value_columns)) @property @@ -354,63 +404,95 @@ def reversed(self) -> Block: index_labels=self.index.names, ) - def reset_index(self, drop: bool = True) -> Block: + def reset_index( + self, + level: LevelsType = None, + drop: bool = True, + *, + col_level: Union[str, int] = 0, + col_fill: typing.Hashable = "", + allow_duplicates: bool = False, + replacement: Optional[bigframes.enums.DefaultIndexKind] = None, + ) -> Block: """Reset the index of the block, promoting the old index to a value column. Arguments: + level: the label or index level of the index levels to remove. name: this is the column id for the new value id derived from the old index + allow_duplicates: if false, duplicate col labels will result in error + replacement: if not null, will override default index replacement type Returns: A new Block because dropping index columns can break references from Index classes that point to this block. """ + if level is not None: + # preserve original order, not user provided order + level_ids: Sequence[str] = [ + id for id in self.index_columns if id in self.index.resolve_level(level) + ] + else: + level_ids = self.index_columns + expr = self._expr - if ( - self.session._default_index_type - == bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64 - ): + replacement_idx_type = replacement or self.session._default_index_type + if set(self.index_columns) > set(level_ids): + new_index_cols = [col for col in self.index_columns if col not in level_ids] + new_index_labels = [self.col_id_to_index_name[id] for id in new_index_cols] + elif replacement_idx_type == bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64: expr, new_index_col_id = expr.promote_offsets() new_index_cols = [new_index_col_id] - elif self.session._default_index_type == bigframes.enums.DefaultIndexKind.NULL: + new_index_labels = [None] + elif replacement_idx_type == bigframes.enums.DefaultIndexKind.NULL: new_index_cols = [] + new_index_labels = [] else: - raise ValueError( - f"Unrecognized default index kind: {self.session._default_index_type}" - ) + raise ValueError(f"Unrecognized default index kind: {replacement_idx_type}") if drop: # Even though the index might be part of the ordering, keep that # ordering expression as reset_index shouldn't change the row # order. - expr = expr.drop_columns(self.index_columns) + expr = expr.drop_columns(level_ids) return Block( expr, index_columns=new_index_cols, + index_labels=new_index_labels, column_labels=self.column_labels, ) else: # Add index names to column index - index_labels = self.index.names + col_level_n = ( + col_level + if isinstance(col_level, int) + else self.column_labels.names.index(col_level) + ) column_labels_modified = self.column_labels - for level, label in enumerate(index_labels): + for position, level_id in enumerate(level_ids): + label = self.col_id_to_index_name[level_id] if label is None: - if "index" not in self.column_labels and len(index_labels) <= 1: + if "index" not in self.column_labels and self.index.nlevels <= 1: label = "index" else: - label = f"level_{level}" + label = f"level_{self.index_columns.index(level_id)}" - if label in self.column_labels: + if (not allow_duplicates) and (label in self.column_labels): raise ValueError(f"cannot insert {label}, already exists") + if isinstance(self.column_labels, pd.MultiIndex): nlevels = self.column_labels.nlevels - label = tuple(label if i == 0 else "" for i in range(nlevels)) + label = tuple( + label if i == col_level_n else col_fill for i in range(nlevels) + ) + # Create index copy with label inserted # See: https://pandas.pydata.org/docs/reference/api/pandas.Index.insert.html - column_labels_modified = column_labels_modified.insert(level, label) + column_labels_modified = column_labels_modified.insert(position, label) return Block( - expr, + expr.select_columns((*new_index_cols, *level_ids, *self.value_columns)), index_columns=new_index_cols, + index_labels=new_index_labels, column_labels=column_labels_modified, ) @@ -487,10 +569,22 @@ def to_arrow( self, *, ordered: bool = True, - ) -> Tuple[pa.Table, bigquery.QueryJob]: + allow_large_results: Optional[bool] = None, + ) -> Tuple[pa.Table, Optional[bigquery.QueryJob]]: """Run query and download results as a pyarrow Table.""" - execute_result = self.session._executor.execute(self.expr, ordered=ordered) - pa_table = execute_result.to_arrow_table() + under_10gb = ( + (not allow_large_results) + if (allow_large_results is not None) + else not bigframes.options._allow_large_results + ) + execute_result = self.session._executor.execute( + self.expr, + execution_spec.ExecutionSpec( + promise_under_10gb=under_10gb, + ordered=ordered, + ), + ) + pa_table = execute_result.batches().to_arrow_table() pa_index_labels = [] for index_level, index_label in enumerate(self._index_labels): @@ -511,6 +605,7 @@ def to_pandas( random_state: Optional[int] = None, *, ordered: bool = True, + allow_large_results: Optional[bool] = None, ) -> Tuple[pd.DataFrame, Optional[bigquery.QueryJob]]: """Run query and download results as a pandas DataFrame. @@ -537,6 +632,25 @@ def to_pandas( Returns: pandas.DataFrame, QueryJob """ + sampling = self._get_sampling_option( + max_download_size, sampling_method, random_state + ) + + return self._materialize_local( + materialize_options=MaterializationOptions( + downsampling=sampling, + allow_large_results=allow_large_results, + ordered=ordered, + ) + ) + + def _get_sampling_option( + self, + max_download_size: Optional[int] = None, + sampling_method: Optional[str] = None, + random_state: Optional[int] = None, + ) -> sampling_options.SamplingOptions: + if (sampling_method is not None) and (sampling_method not in _SAMPLING_METHODS): raise NotImplementedError( f"The downsampling method {sampling_method} is not implemented, " @@ -544,86 +658,145 @@ def to_pandas( ) sampling = bigframes.options.sampling.with_max_download_size(max_download_size) - if sampling_method is not None: - sampling = sampling.with_method(sampling_method).with_random_state( # type: ignore - random_state - ) - else: - sampling = sampling.with_disabled() + if sampling_method is None: + return sampling.with_disabled() - df, query_job = self._materialize_local( - materialize_options=MaterializationOptions( - downsampling=sampling, ordered=ordered - ) + return sampling.with_method(sampling_method).with_random_state( # type: ignore + random_state ) - df.set_axis(self.column_labels, axis=1, copy=False) - return df, query_job def try_peek( - self, n: int = 20, force: bool = False + self, n: int = 20, force: bool = False, allow_large_results=None ) -> typing.Optional[pd.DataFrame]: if force or self.expr.supports_fast_peek: - result = self.session._executor.peek(self.expr, n) - df = io_pandas.arrow_to_pandas(result.to_arrow_table(), self.expr.schema) - self._copy_index_to_pandas(df) - return df + # really, we should just block insane peek values and always assume <10gb + under_10gb = ( + (not allow_large_results) + if (allow_large_results is not None) + else not bigframes.options._allow_large_results + ) + result = self.session._executor.execute( + self.expr, + execution_spec.ExecutionSpec(promise_under_10gb=under_10gb, peek=n), + ) + df = result.batches().to_pandas() + return self._copy_index_to_pandas(df) else: return None def to_pandas_batches( - self, page_size: Optional[int] = None, max_results: Optional[int] = None - ): + self, + page_size: Optional[int] = None, + max_results: Optional[int] = None, + allow_large_results: Optional[bool] = None, + ) -> PandasBatches: """Download results one message at a time. page_size and max_results determine the size and number of batches, see https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJob#google_cloud_bigquery_job_QueryJob_result""" - execute_result = self.session._executor.execute( + + under_10gb = ( + (not allow_large_results) + if (allow_large_results is not None) + else not bigframes.options._allow_large_results + ) + execution_result = self.session._executor.execute( self.expr, - ordered=True, - use_explicit_destination=True, - page_size=page_size, - max_results=max_results, + execution_spec.ExecutionSpec( + promise_under_10gb=under_10gb, + ordered=True, + ), + ) + result_batches = execution_result.batches() + + # To reduce the number of edge cases to consider when working with the + # results of this, always return at least one DataFrame. See: + # b/428918844. + try: + empty_arrow_table = self.expr.schema.to_pyarrow().empty_table() + except pa.ArrowNotImplementedError: + # Bug with some pyarrow versions(https://github.com/apache/arrow/issues/45262), + # empty_table only supports base storage types, not extension types. + empty_arrow_table = self.expr.schema.to_pyarrow( + use_storage_types=True + ).empty_table() + empty_val = io_pandas.arrow_to_pandas(empty_arrow_table, self.expr.schema) + dfs = map( + lambda a: a[0], + itertools.zip_longest( + result_batches.to_pandas_batches(page_size, max_results), + [0], + fillvalue=empty_val, + ), ) - for record_batch in execute_result.arrow_batches(): - df = io_pandas.arrow_to_pandas(record_batch, self.expr.schema) - self._copy_index_to_pandas(df) - yield df + dfs = iter(map(self._copy_index_to_pandas, dfs)) - def _copy_index_to_pandas(self, df: pd.DataFrame): - """Set the index on pandas DataFrame to match this block. + total_rows = result_batches.approx_total_rows + if (total_rows is not None) and (max_results is not None): + total_rows = min(total_rows, max_results) - Warning: This method modifies ``df`` inplace. - """ + return PandasBatches( + dfs, + total_rows, + total_bytes_processed=execution_result.total_bytes_processed, + ) + + def _copy_index_to_pandas(self, df: pd.DataFrame) -> pd.DataFrame: + """Set the index on pandas DataFrame to match this block.""" # Note: If BigQuery DataFrame has null index, a default one will be created for the local materialization. + new_df = df.copy() if len(self.index_columns) > 0: - df.set_index(list(self.index_columns), inplace=True) + new_df.set_index(list(self.index_columns), inplace=True) # Pandas names is annotated as list[str] rather than the more # general Sequence[Label] that BigQuery DataFrames has. # See: https://github.com/pandas-dev/pandas-stubs/issues/804 - df.index.names = self.index.names # type: ignore - df.columns = self.column_labels + new_df.index.names = self.index.names # type: ignore + new_df.columns = self.column_labels + return new_df def _materialize_local( self, materialize_options: MaterializationOptions = MaterializationOptions() - ) -> Tuple[pd.DataFrame, Optional[bigquery.QueryJob]]: + ) -> tuple[pd.DataFrame, Optional[bigquery.QueryJob]]: """Run query and download results as a pandas DataFrame. Return the total number of results as well.""" # TODO(swast): Allow for dry run and timeout. + under_10gb = ( + (not materialize_options.allow_large_results) + if (materialize_options.allow_large_results is not None) + else (not bigframes.options._allow_large_results) + ) execute_result = self.session._executor.execute( - self.expr, ordered=materialize_options.ordered, get_size_bytes=True + self.expr, + execution_spec.ExecutionSpec( + promise_under_10gb=under_10gb, + ordered=materialize_options.ordered, + ), ) - assert execute_result.total_bytes is not None - table_mb = execute_result.total_bytes / _BYTES_TO_MEGABYTES + result_batches = execute_result.batches() + sample_config = materialize_options.downsampling - max_download_size = sample_config.max_download_size - fraction = ( - max_download_size / table_mb - if (max_download_size is not None) and (table_mb != 0) - else 2 - ) + if result_batches.approx_total_bytes is not None: + table_mb = result_batches.approx_total_bytes / _BYTES_TO_MEGABYTES + max_download_size = sample_config.max_download_size + fraction = ( + max_download_size / table_mb + if (max_download_size is not None) and (table_mb != 0) + else 2 + ) + else: + # Since we cannot acquire the table size without a query_job, + # we skip the sampling. + if sample_config.enable_downsampling: + msg = bfe.format_message( + "Sampling is disabled and there is no download size limit when 'allow_large_results' is set to " + "False. To prevent downloading excessive data, it is recommended to use the peek() method, or " + "limit the data with methods like .head() or .sample() before proceeding with downloads." + ) + warnings.warn(msg, category=UserWarning) + fraction = 2 # TODO: Maybe materialize before downsampling # Some downsampling methods - if fraction < 1: + if fraction < 1 and (result_batches.approx_total_rows is not None): if not sample_config.enable_downsampling: raise RuntimeError( f"The data size ({table_mb:.2f} MB) exceeds the maximum download limit of " @@ -635,14 +808,14 @@ def _materialize_local( " # Setting it to None will download all the data\n" f"{constants.FEEDBACK_LINK}" ) - msg = ( + msg = bfe.format_message( f"The data size ({table_mb:.2f} MB) exceeds the maximum download limit of" f"({max_download_size} MB). It will be downsampled to {max_download_size} " "MB for download.\nPlease refer to the documentation for configuring " "the downloading limit." ) warnings.warn(msg, category=UserWarning) - total_rows = execute_result.total_rows + total_rows = result_batches.approx_total_rows # Remove downsampling config from subsequent invocations, as otherwise could result in many # iterations if downsampling undershoots return self._downsample( @@ -654,12 +827,10 @@ def _materialize_local( MaterializationOptions(ordered=materialize_options.ordered) ) else: - total_rows = execute_result.total_rows - arrow = execute_result.to_arrow_table() - df = io_pandas.arrow_to_pandas(arrow, schema=self.expr.schema) - self._copy_index_to_pandas(df) - - return df, execute_result.query_job + df = result_batches.to_pandas() + df = self._copy_index_to_pandas(df) + df.set_axis(self.column_labels, axis=1, copy=False) + return df, execute_result.query_job def _downsample( self, total_rows: int, sampling_method: str, fraction: float, random_state @@ -780,11 +951,32 @@ def split( return [sliced_block.drop_columns(drop_cols) for sliced_block in sliced_blocks] def _compute_dry_run( - self, value_keys: Optional[Iterable[str]] = None - ) -> bigquery.QueryJob: + self, + value_keys: Optional[Iterable[str]] = None, + *, + ordered: bool = True, + max_download_size: Optional[int] = None, + sampling_method: Optional[str] = None, + random_state: Optional[int] = None, + ) -> typing.Tuple[pd.Series, bigquery.QueryJob]: + sampling = self._get_sampling_option( + max_download_size, sampling_method, random_state + ) + if sampling.enable_downsampling: + raise NotImplementedError("Dry run with sampling is not supported") + expr = self._apply_value_keys_to_expr(value_keys=value_keys) - query_job = self.session._executor.dry_run(expr) - return query_job + query_job = self.session._executor.dry_run(expr, ordered) + + column_dtypes = { + col: self.expr.get_column_type(self.resolve_label_exact_or_error(col)) + for col in self.column_labels + } + + dry_run_stats = dry_runs.get_query_stats_with_dtypes( + query_job, column_dtypes, self.index.dtypes, self.expr.node + ) + return dry_run_stats, query_job def _apply_value_keys_to_expr(self, value_keys: Optional[Iterable[str]] = None): expr = self._expr @@ -894,27 +1086,19 @@ def apply_nary_op( def multi_apply_window_op( self, columns: typing.Sequence[str], - op: agg_ops.WindowOp, + op: agg_ops.UnaryWindowOp, window_spec: windows.WindowSpec, *, skip_null_groups: bool = False, - never_skip_nulls: bool = False, ) -> typing.Tuple[Block, typing.Sequence[str]]: - block = self - result_ids = [] - for i, col_id in enumerate(columns): - label = self.col_id_to_label[col_id] - block, result_id = block.apply_window_op( - col_id, - op, - window_spec=window_spec, - skip_reproject_unsafe=(i + 1) < len(columns), - result_label=label, - skip_null_groups=skip_null_groups, - never_skip_nulls=never_skip_nulls, - ) - result_ids.append(result_id) - return block, result_ids + return self.apply_analytic( + agg_exprs=( + agg_expressions.UnaryAggregation(op, ex.deref(col)) for col in columns + ), + window=window_spec, + result_labels=self._get_labels_for_columns(columns), + skip_null_groups=skip_null_groups, + ) def multi_apply_unary_op( self, @@ -962,38 +1146,119 @@ def project_exprs( index_labels=self._index_labels, ) + def project_block_exprs( + self, + exprs: Sequence[ex.Expression], + labels: Union[Sequence[Label], pd.Index], + drop=False, + ) -> Block: + """ + Version of the project_exprs that supports mixing analytic and scalar expressions + """ + new_array, _ = self.expr.compute_general_expression(exprs) + if drop: + new_array = new_array.drop_columns(self.value_columns) + + new_array.node.validate_tree() + return Block( + new_array, + index_columns=self.index_columns, + column_labels=labels + if drop + else self.column_labels.append(pd.Index(labels)), + index_labels=self._index_labels, + ) + + def aggregate( + self, + aggregations: typing.Sequence[ex.Expression] = (), + by_column_ids: typing.Sequence[str] = (), + column_labels: Optional[pd.Index] = None, + *, + dropna: bool = True, + ) -> Block: + """ + Apply aggregations to the block. + + Grouping columns will form the index of the result block. + + Arguments: + aggregations: Aggregation expressions to apply + by_column_id: column id of the aggregation key, this is preserved through the transform and used as index. + dropna: whether null keys should be dropped + + Returns: + Block + """ + if column_labels is None: + column_labels = pd.Index(range(len(aggregations))) + + result_expr = self.expr.compute_general_reduction( + aggregations, by_column_ids, dropna=dropna + ) + + grouping_col_labels: typing.List[Label] = [] + if len(by_column_ids) == 0: + # in the absence of grouping columns, there will be a single row output, assign 0 as its row label. + result_expr, label_id = result_expr.create_constant(0, pd.Int64Dtype()) + index_columns = (label_id,) + grouping_col_labels = [None] + else: + index_columns = tuple(by_column_ids) # type: ignore + for by_col_id in by_column_ids: + if by_col_id in self.value_columns: + grouping_col_labels.append(self.col_id_to_label[by_col_id]) + else: + grouping_col_labels.append(self.col_id_to_index_name[by_col_id]) + + return Block( + result_expr, + index_columns=index_columns, + column_labels=column_labels, + index_labels=grouping_col_labels, + ) + def apply_window_op( self, column: str, - op: agg_ops.WindowOp, + op: agg_ops.UnaryWindowOp, window_spec: windows.WindowSpec, *, result_label: Label = None, skip_null_groups: bool = False, - skip_reproject_unsafe: bool = False, - never_skip_nulls: bool = False, ) -> typing.Tuple[Block, str]: + agg_expr = agg_expressions.UnaryAggregation(op, ex.deref(column)) + block, ids = self.apply_analytic( + [agg_expr], + window_spec, + [result_label], + skip_null_groups=skip_null_groups, + ) + return block, ids[0] + + def apply_analytic( + self, + agg_exprs: Iterable[agg_expressions.Aggregation], + window: windows.WindowSpec, + result_labels: Iterable[Label], + *, + skip_null_groups: bool = False, + ) -> typing.Tuple[Block, Sequence[str]]: block = self if skip_null_groups: - for key in window_spec.grouping_keys: - block, not_null_id = block.apply_unary_op(key.id.name, ops.notnull_op) - block = block.filter_by_id(not_null_id).drop_columns([not_null_id]) - expr, result_id = block._expr.project_window_op( - column, - op, - window_spec, - skip_reproject_unsafe=skip_reproject_unsafe, - never_skip_nulls=never_skip_nulls, + for key in window.grouping_keys: + block = block.filter(ops.notnull_op.as_expr(key)) + expr, result_ids = block._expr.project_window_expr( + tuple(agg_exprs), + window, ) block = Block( expr, index_columns=self.index_columns, - column_labels=self.column_labels.insert( - len(self.column_labels), result_label - ), + column_labels=self.column_labels.append(pd.Index(result_labels)), index_labels=self._index_labels, ) - return (block, result_id) + return (block, result_ids) def copy_values(self, source_column_id: str, destination_column_id: str) -> Block: expr = self.expr.assign(source_column_id, destination_column_id) @@ -1060,9 +1325,9 @@ def aggregate_all_and_stack( if axis_n == 0: aggregations = [ ( - ex.UnaryAggregation(operation, ex.deref(col_id)) + agg_expressions.UnaryAggregation(operation, ex.deref(col_id)) if isinstance(operation, agg_ops.UnaryAggregateOp) - else ex.NullaryAggregation(operation), + else agg_expressions.NullaryAggregation(operation), col_id, ) for col_id in self.value_columns @@ -1078,80 +1343,19 @@ def aggregate_all_and_stack( index_labels=[None], ).transpose(original_row_index=pd.Index([None]), single_row_mode=True) else: # axis_n == 1 - # using offsets as identity to group on. - # TODO: Allow to promote identity/total_order columns instead for better perf - expr_with_offsets, offset_col = self.expr.promote_offsets() - stacked_expr, (_, value_col_ids, passthrough_cols,) = unpivot( - expr_with_offsets, - row_labels=self.column_labels, - unpivot_columns=[tuple(self.value_columns)], - passthrough_columns=[*self.index_columns, offset_col], - ) - # these corresponed to passthrough_columns provided to unpivot - index_cols = passthrough_cols[:-1] - og_offset_col = passthrough_cols[-1] - index_aggregations = [ - ( - ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col_id)), - col_id, - ) - for col_id in index_cols - ] - # TODO: may need add NullaryAggregation in main_aggregation - # when agg add support for axis=1, needed for agg("size", axis=1) - assert isinstance( - operation, agg_ops.UnaryAggregateOp - ), f"Expected a unary operation, but got {operation}. Please report this error and how you got here to the BigQuery DataFrames team (bit.ly/bigframes-feedback)." - main_aggregation = ( - ex.UnaryAggregation(operation, ex.deref(value_col_ids[0])), - value_col_ids[0], - ) - # Drop row identity after aggregating over it - result_expr = stacked_expr.aggregate( - [*index_aggregations, main_aggregation], - by_column_ids=[og_offset_col], - dropna=dropna, - ).drop_columns([og_offset_col]) - return Block( - result_expr, - index_columns=index_cols, - column_labels=[None], - index_labels=self.index.names, - ) - - def aggregate_size( - self, - by_column_ids: typing.Sequence[str] = (), - *, - dropna: bool = True, - ): - """Returns a block object to compute the size(s) of groups.""" - agg_specs = [ - (ex.NullaryAggregation(agg_ops.SizeOp()), guid.generate_guid()), - ] - output_col_ids = [agg_spec[1] for agg_spec in agg_specs] - result_expr = self.expr.aggregate(agg_specs, by_column_ids, dropna=dropna) - names: typing.List[Label] = [] - for by_col_id in by_column_ids: - if by_col_id in self.value_columns: - names.append(self.col_id_to_label[by_col_id]) - else: - names.append(self.col_id_to_index_name[by_col_id]) - return ( - Block( - result_expr, - index_columns=by_column_ids, - column_labels=["size"], - index_labels=names, - ), - output_col_ids, - ) + as_array = ops.ToArrayOp().as_expr(*(col for col in self.value_columns)) + reduced = ops.ArrayReduceOp(operation).as_expr(as_array) + block, id = self.project_expr(reduced, None) + return block.select_column(id).with_column_labels(pd.Index([None])) def select_column(self, id: str) -> Block: return self.select_columns([id]) def select_columns(self, ids: typing.Sequence[str]) -> Block: - expr = self._expr.select_columns([*self.index_columns, *ids]) + # Allow renames as may end up selecting same columns multiple times + expr = self._expr.select_columns( + [*self.index_columns, *ids], allow_renames=True + ) col_labels = self._get_labels_for_columns(ids) return Block(expr, self.index_columns, col_labels, self.index.names) @@ -1192,57 +1396,6 @@ def remap_f(x): col_labels.append(remap_f(col_label)) return self.with_column_labels(col_labels) - def aggregate( - self, - by_column_ids: typing.Sequence[str] = (), - aggregations: typing.Sequence[ex.Aggregation] = (), - column_labels: Optional[pd.Index] = None, - *, - dropna: bool = True, - ) -> typing.Tuple[Block, typing.Sequence[str]]: - """ - Apply aggregations to the block. - Arguments: - by_column_id: column id of the aggregation key, this is preserved through the transform and used as index. - aggregations: input_column_id, operation tuples - dropna: whether null keys should be dropped - """ - if column_labels is None: - column_labels = pd.Index(range(len(aggregations))) - - agg_specs = [ - ( - aggregation, - guid.generate_guid(), - ) - for aggregation in aggregations - ] - output_col_ids = [agg_spec[1] for agg_spec in agg_specs] - result_expr = self.expr.aggregate(agg_specs, by_column_ids, dropna=dropna) - - names: typing.List[Label] = [] - if len(by_column_ids) == 0: - result_expr, label_id = result_expr.create_constant(0, pd.Int64Dtype()) - index_columns = (label_id,) - names = [None] - else: - index_columns = tuple(by_column_ids) # type: ignore - for by_col_id in by_column_ids: - if by_col_id in self.value_columns: - names.append(self.col_id_to_label[by_col_id]) - else: - names.append(self.col_id_to_index_name[by_col_id]) - - return ( - Block( - result_expr, - index_columns=index_columns, - column_labels=column_labels, - index_labels=names, - ), - output_col_ids, - ) - def get_stat( self, column_id: str, @@ -1264,9 +1417,9 @@ def get_stat( aggregations = [ ( - ex.UnaryAggregation(stat, ex.deref(column_id)) + agg_expressions.UnaryAggregation(stat, ex.deref(column_id)) if isinstance(stat, agg_ops.UnaryAggregateOp) - else ex.NullaryAggregation(stat), + else agg_expressions.NullaryAggregation(stat), stat.name, ) for stat in stats_to_fetch @@ -1292,7 +1445,7 @@ def get_binary_stat( # TODO(kemppeterson): Add a cache here. aggregations = [ ( - ex.BinaryAggregation( + agg_expressions.BinaryAggregation( stat, ex.deref(column_id_left), ex.deref(column_id_right) ), f"{stat.name}_{column_id_left}{column_id_right}", @@ -1319,9 +1472,9 @@ def summarize( labels = pd.Index([stat.name for stat in stats]) aggregations = [ ( - ex.UnaryAggregation(stat, ex.deref(col_id)) + agg_expressions.UnaryAggregation(stat, ex.deref(col_id)) if isinstance(stat, agg_ops.UnaryAggregateOp) - else ex.NullaryAggregation(stat), + else agg_expressions.NullaryAggregation(stat), f"{col_id}-{stat.name}", ) for stat in stats @@ -1471,13 +1624,32 @@ def retrieve_repr_request_results( """ # head caches full underlying expression, so row_count will be free after - head_result = self.session._executor.head(self.expr, max_results) - count = self.session._executor.get_row_count(self.expr) + executor = self.session._executor + executor.cached( + array_value=self.expr, + config=executors.CacheConfig(optimize_for="head", if_cached="reuse-strict"), + ) + head_result = self.session._executor.execute( + self.expr.slice(start=None, stop=max_results, step=None), + execution_spec.ExecutionSpec( + promise_under_10gb=True, + ordered=True, + ), + ) + row_count = ( + self.session._executor.execute( + self.expr.row_count(), + execution_spec.ExecutionSpec( + promise_under_10gb=True, + ordered=False, + ), + ) + .batches() + .to_py_scalar() + ) - arrow = head_result.to_arrow_table() - df = io_pandas.arrow_to_pandas(arrow, schema=self.expr.schema) - self._copy_index_to_pandas(df) - return df, count, head_result.query_job + head_df = head_result.batches().to_pandas() + return self._copy_index_to_pandas(head_df), row_count, head_result.query_job def promote_offsets(self, label: Label = None) -> typing.Tuple[Block, str]: expr, result_id = self._expr.promote_offsets() @@ -1580,10 +1752,10 @@ def pivot( block = block.select_columns(column_ids) aggregations = [ - ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col_id)) + agg_expressions.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col_id)) for col_id in column_ids ] - result_block, _ = block.aggregate( + result_block = block.aggregate( by_column_ids=self.index_columns, aggregations=aggregations, dropna=True, @@ -1596,7 +1768,9 @@ def pivot( else: return result_block.with_column_labels(columns_values) - def stack(self, how="left", levels: int = 1): + def stack( + self, how="left", levels: int = 1, *, override_labels: Optional[pd.Index] = None + ): """Unpivot last column axis level into row axis""" if levels == 0: return self @@ -1604,7 +1778,9 @@ def stack(self, how="left", levels: int = 1): # These are the values that will be turned into rows col_labels, row_labels = utils.split_index(self.column_labels, levels=levels) - row_labels = row_labels.drop_duplicates() + row_labels = ( + row_labels.drop_duplicates() if override_labels is None else override_labels + ) if col_labels is None: result_index: pd.Index = pd.Index([None]) @@ -1706,7 +1882,7 @@ def transpose( original_row_index = ( original_row_index if original_row_index is not None - else self.index.to_pandas(ordered=True) + else self.index.to_pandas(ordered=True)[0] ) original_row_count = len(original_row_index) if original_row_count > bigframes.constants.MAX_COLUMNS: @@ -1787,6 +1963,31 @@ def _generate_resample_label( Literal["epoch", "start", "start_day", "end", "end_day"], ] = "start_day", ) -> Block: + if not isinstance(rule, str): + raise NotImplementedError( + f"Only offset strings are currently supported for rule, but got {repr(rule)}. {constants.FEEDBACK_LINK}" + ) + + if rule in ("ME", "YE", "QE", "BME", "BA", "BQE", "W"): + raise NotImplementedError( + f"Offset strings 'ME', 'YE', 'QE', 'BME', 'BA', 'BQE', 'W' are not currently supported for rule, but got {repr(rule)}. {constants.FEEDBACK_LINK}" + ) + + if closed == "right": + raise NotImplementedError( + f"Only closed='left' is currently supported. {constants.FEEDBACK_LINK}", + ) + + if label == "right": + raise NotImplementedError( + f"Only label='left' is currently supported. {constants.FEEDBACK_LINK}", + ) + + if origin not in ("epoch", "start", "start_day"): + raise NotImplementedError( + f"Only origin='epoch', 'start', 'start_day' are currently supported, but got {repr(origin)}. {constants.FEEDBACK_LINK}" + ) + # Validate and resolve the index or column to use for grouping if on is None: if len(self.index_columns) == 0: @@ -1848,7 +2049,7 @@ def _generate_resample_label( agg_specs = [ ( - ex.UnaryAggregation(agg_ops.min_op, ex.deref(col_id)), + agg_expressions.UnaryAggregation(agg_ops.min_op, ex.deref(col_id)), guid.generate_guid(), ), ] @@ -1877,13 +2078,13 @@ def _generate_resample_label( # Generate integer label sequence. min_agg_specs = [ ( - ex.UnaryAggregation(agg_ops.min_op, ex.deref(label_col_id)), + ex_types.UnaryAggregation(agg_ops.min_op, ex.deref(label_col_id)), guid.generate_guid(), ), ] max_agg_specs = [ ( - ex.UnaryAggregation(agg_ops.max_op, ex.deref(label_col_id)), + ex_types.UnaryAggregation(agg_ops.max_op, ex.deref(label_col_id)), guid.generate_guid(), ), ] @@ -1931,7 +2132,7 @@ def _generate_resample_label( return block.set_index([resample_label_id]) def _create_stack_column(self, col_label: typing.Tuple, stack_labels: pd.Index): - dtype = None + input_dtypes = [] input_columns: list[Optional[str]] = [] for uvalue in utils.index_as_tuples(stack_labels): label_to_match = (*col_label, *uvalue) @@ -1941,15 +2142,18 @@ def _create_stack_column(self, col_label: typing.Tuple, stack_labels: pd.Index): matching_ids = self.label_to_col_id.get(label_to_match, []) input_id = matching_ids[0] if len(matching_ids) > 0 else None if input_id: - if dtype and dtype != self._column_type(input_id): - raise NotImplementedError( - "Cannot stack columns with non-matching dtypes." - ) - else: - dtype = self._column_type(input_id) + input_dtypes.append(self._column_type(input_id)) input_columns.append(input_id) # Input column i is the first one that - return tuple(input_columns), dtype or pd.Float64Dtype() + if len(input_dtypes) > 0: + output_dtype = bigframes.dtypes.lcd_type(*input_dtypes) + if output_dtype is None: + raise NotImplementedError( + "Cannot stack columns with non-matching dtypes." + ) + else: + output_dtype = pd.Float64Dtype() + return tuple(input_columns), output_dtype def _column_type(self, col_id: str) -> bigframes.dtypes.Dtype: col_offset = self.value_columns.index(col_id) @@ -2000,9 +2204,17 @@ def _get_unique_values( import bigframes.core.block_transforms as block_tf import bigframes.dataframe as df - unique_value_block = block_tf.drop_duplicates( - self.select_columns(columns), columns - ) + if self.explicitly_ordered: + unique_value_block = block_tf.drop_duplicates( + self.select_columns(columns), columns + ) + else: + unique_value_block = self.aggregate(by_column_ids=columns, dropna=False) + col_labels = self._get_labels_for_columns(columns) + unique_value_block = unique_value_block.reset_index( + drop=False + ).with_column_labels(col_labels) + pd_values = ( df.DataFrame(unique_value_block).head(max_unique_values + 1).to_pandas() ) @@ -2060,7 +2272,7 @@ def isin(self, other: Block): return block def _isin_inner(self: Block, col: str, unique_values: core.ArrayValue) -> Block: - expr, matches = self._expr.isin(unique_values, col, unique_values.column_ids[0]) + expr, matches = self._expr.isin(unique_values, col) new_value_cols = tuple( val_col if val_col != col else matches for val_col in self.value_columns @@ -2087,6 +2299,8 @@ def merge( right_join_ids: typing.Sequence[str], sort: bool, suffixes: tuple[str, str] = ("_x", "_y"), + left_index: bool = False, + right_index: bool = False, ) -> Block: conditions = tuple( (lid, rid) for lid, rid in zip(left_join_ids, right_join_ids) @@ -2094,34 +2308,52 @@ def merge( joined_expr, (get_column_left, get_column_right) = self.expr.relational_join( other.expr, type=how, conditions=conditions ) - result_columns = [] - matching_join_labels = [] left_post_join_ids = tuple(get_column_left[id] for id in left_join_ids) right_post_join_ids = tuple(get_column_right[id] for id in right_join_ids) - joined_expr, coalesced_ids = coalesce_columns( - joined_expr, left_post_join_ids, right_post_join_ids, how=how, drop=False - ) + if left_index or right_index: + # For some reason pandas coalesces two joining columns if one side is an index. + joined_expr, resolved_join_ids = coalesce_columns( + joined_expr, left_post_join_ids, right_post_join_ids + ) + else: + joined_expr, resolved_join_ids = resolve_col_join_ids( # type: ignore + joined_expr, + left_post_join_ids, + right_post_join_ids, + how=how, + drop=False, + ) + + result_columns = [] + matching_join_labels = [] + # Select left value columns for col_id in self.value_columns: if col_id in left_join_ids: key_part = left_join_ids.index(col_id) matching_right_id = right_join_ids[key_part] if ( - self.col_id_to_label[col_id] + right_index + or self.col_id_to_label[col_id] == other.col_id_to_label[matching_right_id] ): matching_join_labels.append(self.col_id_to_label[col_id]) - result_columns.append(coalesced_ids[key_part]) + result_columns.append(resolved_join_ids[key_part]) else: result_columns.append(get_column_left[col_id]) else: result_columns.append(get_column_left[col_id]) + + # Select right value columns for col_id in other.value_columns: if col_id in right_join_ids: - if other.col_id_to_label[matching_right_id] in matching_join_labels: + if other.col_id_to_label[col_id] in matching_join_labels: pass + elif left_index: + key_part = right_join_ids.index(col_id) + result_columns.append(resolved_join_ids[key_part]) else: result_columns.append(get_column_right[col_id]) else: @@ -2132,11 +2364,22 @@ def merge( joined_expr = joined_expr.order_by( [ ordering.OrderingExpression(ex.deref(col_id)) - for col_id in coalesced_ids + for col_id in resolved_join_ids ], ) - joined_expr = joined_expr.select_columns(result_columns) + left_idx_id_post_join = [get_column_left[id] for id in self.index_columns] + right_idx_id_post_join = [get_column_right[id] for id in other.index_columns] + index_cols = _resolve_index_col( + left_idx_id_post_join, + right_idx_id_post_join, + resolved_join_ids, + left_index, + right_index, + how, + ) + + joined_expr = joined_expr.select_columns(result_columns + index_cols) labels = utils.merge_column_labels( self.column_labels, other.column_labels, @@ -2155,13 +2398,13 @@ def merge( or other.index.is_null or self.session._default_index_type == bigframes.enums.DefaultIndexKind.NULL ): - expr = joined_expr - index_columns = [] + return Block(joined_expr, index_columns=[], column_labels=labels) + elif index_cols: + return Block(joined_expr, index_columns=index_cols, column_labels=labels) else: expr, offset_index_id = joined_expr.promote_offsets() index_columns = [offset_index_id] - - return Block(expr, index_columns=index_columns, column_labels=labels) + return Block(expr, index_columns=index_columns, column_labels=labels) def _align_both_axes( self, other: Block, how: str @@ -2307,6 +2550,7 @@ def _apply_binop( return self.project_exprs(exprs, labels=labels, drop=True) + # TODO: Re-implement join in terms of merge (requires also adding remaining merge args) def join( self, other: Block, @@ -2314,6 +2558,7 @@ def join( how="left", sort: bool = False, block_identity_join: bool = False, + always_order: bool = False, ) -> Tuple[Block, Tuple[Mapping[str, str], Mapping[str, str]],]: """ Join two blocks objects together, and provide mappings between source columns and output columns. @@ -2327,6 +2572,8 @@ def join( if true will sort result by index block_identity_join (bool): If true, will not convert join to a projection (implicitly assuming unique indices) + always_order (bool): + If true, will always preserve input ordering, even if ordering mode is partial Returns: Block, (left_mapping, right_mapping): Result block and mappers from input column ids to result column ids. @@ -2372,10 +2619,14 @@ def join( self._throw_if_null_index("join") other._throw_if_null_index("join") if self.index.nlevels == other.index.nlevels == 1: - return join_mono_indexed(self, other, how=how, sort=sort) + return join_mono_indexed( + self, other, how=how, sort=sort, propogate_order=always_order + ) else: # Handles cases where one or both sides are multi-indexed # Always sort mult-index join - return join_multi_indexed(self, other, how=how, sort=sort) + return join_multi_indexed( + self, other, how=how, sort=sort, propogate_order=always_order + ) def is_monotonic_increasing( self, column_id: typing.Union[str, Sequence[str]] @@ -2387,19 +2638,19 @@ def is_monotonic_decreasing( ) -> bool: return self._is_monotonic(column_id, increasing=False) - def to_sql_query( - self, include_index: bool, enable_cache: bool = True - ) -> typing.Tuple[str, list[str], list[Label]]: + def _array_value_for_output( + self, *, include_index: bool + ) -> Tuple[bigframes.core.ArrayValue, list[str], list[Label]]: """ - Compiles this DataFrame's expression tree to SQL, optionally - including index columns. + Creates the expression tree with user-visible column names, such as for + SQL output. Args: include_index (bool): whether to include index columns. Returns: - a tuple of (sql_string, index_column_id_list, index_column_label_list). + a tuple of (ArrayValue, index_column_id_list, index_column_label_list). If include_index is set to False, index_column_id_list and index_column_label_list return empty lists. """ @@ -2422,26 +2673,87 @@ def to_sql_query( # the BigQuery unicode column name feature? substitutions[old_id] = new_id + return ( + array_value.rename_columns(substitutions), + new_ids[: len(idx_labels)], + idx_labels, + ) + + def to_sql_query( + self, include_index: bool, enable_cache: bool = True + ) -> Tuple[str, list[str], list[Label]]: + """ + Compiles this DataFrame's expression tree to SQL, optionally + including index columns. + + Args: + include_index (bool): + whether to include index columns. + + Returns: + a tuple of (sql_string, index_column_id_list, index_column_label_list). + If include_index is set to False, index_column_id_list and index_column_label_list + return empty lists. + """ + array_value, idx_ids, idx_labels = self._array_value_for_output( + include_index=include_index + ) + # Note: this uses the sql from the executor, so is coupled tightly to execution # implementaton. It will reference cached tables instead of original data sources. # Maybe should just compile raw BFET? Depends on user intent. - sql = self.session._executor.to_sql( - array_value, col_id_overrides=substitutions, enable_cache=enable_cache - ) + sql = self.session._executor.to_sql(array_value, enable_cache=enable_cache) return ( sql, - new_ids[: len(idx_labels)], + idx_ids, idx_labels, ) + def to_placeholder_table( + self, include_index: bool, *, dry_run: bool = False + ) -> bigquery.TableReference: + """ + Creates a temporary BigQuery VIEW (or empty table if dry_run) with the + SQL corresponding to this block. + """ + if self._view_ref is not None: + return self._view_ref + + # Prefer the real view if it exists, but since dry_run might be called + # many times before the real query, we cache that empty table reference + # with the correct schema too. + if dry_run: + if self._view_ref_dry_run is not None: + return self._view_ref_dry_run + + # Create empty temp table with the right schema. + array_value, _, _ = self._array_value_for_output( + include_index=include_index + ) + temp_table_schema = array_value.schema.to_bigquery() + self._view_ref_dry_run = self.session._create_temp_table( + schema=temp_table_schema + ) + return self._view_ref_dry_run + + # We shouldn't run `to_sql_query` if we have a `dry_run`, because it + # could cause us to make unnecessary API calls to upload local node + # data. + sql, _, _ = self.to_sql_query(include_index=include_index) + self._view_ref = self.session._create_temp_view(sql) + return self._view_ref + def cached(self, *, force: bool = False, session_aware: bool = False) -> None: """Write the block to a session table.""" # use a heuristic for whether something needs to be cached self.session._executor.cached( self.expr, - force=force, - use_session=session_aware, - cluster_cols=self.index_columns, + config=executors.CacheConfig( + optimize_for="auto" + if session_aware + else executors.HierarchicalKey(tuple(self.index_columns)), + if_cached="replace" if force else "reuse-any", + ), ) def _is_monotonic( @@ -2511,14 +2823,6 @@ def _throw_if_null_index(self, opname: str): ) def _get_rows_as_json_values(self) -> Block: - # We want to preserve any ordering currently present before turning to - # direct SQL manipulation. We will restore the ordering when we rebuild - # expression. - # TODO(shobs): Replace direct SQL manipulation by structured expression - # manipulation - expr, ordering_column_name = self.expr.promote_offsets() - expr_sql = self.session._executor.to_sql(expr) - # Names of the columns to serialize for the row. # We will use the repr-eval pattern to serialize a value here and # deserialize in the cloud function. Let's make sure that would work. @@ -2534,92 +2838,44 @@ def _get_rows_as_json_values(self) -> Block: ) column_names.append(serialized_column_name) - column_names_csv = sql.csv(map(sql.simple_literal, column_names)) - - # index columns count - index_columns_count = len(self.index_columns) # column references to form the array of values for the row column_types = list(self.index.dtypes) + list(self.dtypes) column_references = [] for type_, col in zip(column_types, self.expr.column_ids): - if isinstance(type_, pd.ArrowDtype) and pa.types.is_binary( - type_.pyarrow_dtype - ): - column_references.append(sql.to_json_string(col)) + if type_ == bigframes.dtypes.BYTES_DTYPE: + column_references.append(ops.ToJSONString().as_expr(col)) + elif type_ == bigframes.dtypes.BOOL_DTYPE: + # cast operator produces True/False, but function template expects lower case + column_references.append( + ops.lower_op.as_expr( + ops.AsTypeOp(bigframes.dtypes.STRING_DTYPE).as_expr(col) + ) + ) else: - column_references.append(sql.cast_as_string(col)) - - column_references_csv = sql.csv(column_references) - - # types of the columns to serialize for the row - column_types_csv = sql.csv( - [sql.simple_literal(str(typ)) for typ in column_types] - ) + column_references.append( + ops.AsTypeOp(bigframes.dtypes.STRING_DTYPE).as_expr(col) + ) # row dtype to use for deserializing the row as pandas series pandas_row_dtype = bigframes.dtypes.lcd_type(*column_types) if pandas_row_dtype is None: pandas_row_dtype = "object" - pandas_row_dtype = sql.simple_literal(str(pandas_row_dtype)) - - # create a json column representing row through SQL manipulation - row_json_column_name = guid.generate_guid() - select_columns = ( - [ordering_column_name] + list(self.index_columns) + [row_json_column_name] - ) - select_columns_csv = sql.csv( - [googlesql.identifier(col) for col in select_columns] - ) - json_sql = f"""\ -With T0 AS ( -{textwrap.indent(expr_sql, " ")} -), -T1 AS ( - SELECT *, - TO_JSON_STRING(JSON_OBJECT( - "names", [{column_names_csv}], - "types", [{column_types_csv}], - "values", [{column_references_csv}], - "indexlength", {index_columns_count}, - "dtype", {pandas_row_dtype} - )) AS {googlesql.identifier(row_json_column_name)} FROM T0 -) -SELECT {select_columns_csv} FROM T1 -""" - # The only ways this code is used is through df.apply(axis=1) cope path - # TODO: Stop using internal API - destination, query_job = self.session._loader._query_to_destination( - json_sql, index_cols=[ordering_column_name], api_name="apply" - ) - if not destination: - raise ValueError(f"Query job {query_job} did not produce result table") - - new_schema = ( - self.expr.schema.select([*self.index_columns]) - .append( - bf_schema.SchemaItem( - row_json_column_name, bigframes.dtypes.STRING_DTYPE - ) - ) - .append( - bf_schema.SchemaItem(ordering_column_name, bigframes.dtypes.INT_DTYPE) - ) - ) + pandas_row_dtype = str(pandas_row_dtype) - expr = core.ArrayValue.from_table( - self.session.bqclient.get_table(destination), - schema=new_schema, - session=self.session, - offsets_col=ordering_column_name, - ).drop_columns([ordering_column_name]) - block = Block( - expr, - index_columns=self.index_columns, - column_labels=[row_json_column_name], - index_labels=self._index_labels, + struct_op = ops.StructOp( + column_names=("names", "types", "values", "indexlength", "dtype") ) - return block + names_val = ex.const(tuple(column_names)) + types_val = ex.const(tuple(map(str, column_types))) + values_val = ops.ToArrayOp().as_expr(*column_references) + indexlength_val = ex.const(len(self.index_columns)) + dtype_val = ex.const(str(pandas_row_dtype)) + struct_expr = struct_op.as_expr( + names_val, types_val, values_val, indexlength_val, dtype_val + ) + block, col_id = self.project_expr(ops.ToJSONString().as_expr(struct_expr)) + return block.select_column(col_id) class BlockIndexProperties: @@ -2665,14 +2921,29 @@ def column_ids(self) -> Sequence[str]: def is_null(self) -> bool: return len(self._block._index_columns) == 0 - def to_pandas(self, *, ordered: Optional[bool] = None) -> pd.Index: + def to_pandas( + self, + *, + ordered: Optional[bool] = None, + allow_large_results: Optional[bool] = None, + ) -> Tuple[pd.Index, Optional[bigquery.QueryJob]]: """Executes deferred operations and downloads the results.""" if len(self.column_ids) == 0: raise bigframes.exceptions.NullIndexError( "Cannot materialize index, as this object does not have an index. Set index column(s) using set_index." ) ordered = ordered if ordered is not None else True - return self._block.select_columns([]).to_pandas(ordered=ordered)[0].index + + df, query_job = self._block.select_columns([]).to_pandas( + ordered=ordered, + allow_large_results=allow_large_results, + ) + return df.index, query_job + + def _compute_dry_run( + self, *, ordered: bool = True + ) -> Tuple[pd.Series, bigquery.QueryJob]: + return self._block.select_columns([])._compute_dry_run(ordered=ordered) def resolve_level(self, level: LevelsType) -> typing.Sequence[str]: if utils.is_list_like(level): @@ -2811,7 +3082,7 @@ def join_with_single_row( combined_expr, index_columns=index_cols_post_join, column_labels=left.column_labels.append(single_row_block.column_labels), - index_labels=[left.index.name], + index_labels=left.index.names, ) return ( block, @@ -2824,7 +3095,8 @@ def join_mono_indexed( right: Block, *, how="left", - sort=False, + sort: bool = False, + propogate_order: bool = False, ) -> Tuple[Block, Tuple[Mapping[str, str], Mapping[str, str]],]: left_expr = left.expr right_expr = right.expr @@ -2835,12 +3107,13 @@ def join_mono_indexed( conditions=( join_defs.JoinCondition(left.index_columns[0], right.index_columns[0]), ), + propogate_order=propogate_order, ) left_index = get_column_left[left.index_columns[0]] right_index = get_column_right[right.index_columns[0]] # Drop original indices from each side. and used the coalesced combination generated by the join. - combined_expr, coalesced_join_cols = coalesce_columns( + combined_expr, coalesced_join_cols = resolve_col_join_ids( combined_expr, [left_index], [right_index], how=how ) if sort: @@ -2869,7 +3142,8 @@ def join_multi_indexed( right: Block, *, how="left", - sort=False, + sort: bool = False, + propogate_order: bool = False, ) -> Tuple[Block, Tuple[Mapping[str, str], Mapping[str, str]],]: if not (left.index.is_uniquely_named() and right.index.is_uniquely_named()): raise ValueError("Joins not supported on indices with non-unique level names") @@ -2898,12 +3172,13 @@ def join_multi_indexed( join_defs.JoinCondition(left, right) for left, right in zip(left_join_ids, right_join_ids) ), + propogate_order=propogate_order, ) left_ids_post_join = [get_column_left[id] for id in left_join_ids] right_ids_post_join = [get_column_right[id] for id in right_join_ids] # Drop original indices from each side. and used the coalesced combination generated by the join. - combined_expr, coalesced_join_cols = coalesce_columns( + combined_expr, coalesced_join_cols = resolve_col_join_ids( combined_expr, left_ids_post_join, right_ids_post_join, how=how ) if sort: @@ -2946,13 +3221,17 @@ def resolve_label_id(label: Label) -> str: # TODO: Rewrite just to return expressions -def coalesce_columns( +def resolve_col_join_ids( expr: core.ArrayValue, left_ids: typing.Sequence[str], right_ids: typing.Sequence[str], how: str, drop: bool = True, ) -> Tuple[core.ArrayValue, Sequence[str]]: + """ + Collapses and selects the joining column IDs, with the assumption that + the ids are all belong to value columns. + """ result_ids = [] for left_id, right_id in zip(left_ids, right_ids): if how == "left" or how == "inner" or how == "cross": @@ -2964,7 +3243,6 @@ def coalesce_columns( if drop: expr = expr.drop_columns([left_id]) elif how == "outer": - coalesced_id = guid.generate_guid() expr, coalesced_id = expr.project_to_id( ops.coalesce_op.as_expr(left_id, right_id) ) @@ -2976,6 +3254,21 @@ def coalesce_columns( return expr, result_ids +def coalesce_columns( + expr: core.ArrayValue, + left_ids: typing.Sequence[str], + right_ids: typing.Sequence[str], +) -> tuple[core.ArrayValue, list[str]]: + result_ids = [] + for left_id, right_id in zip(left_ids, right_ids): + expr, coalesced_id = expr.project_to_id( + ops.coalesce_op.as_expr(left_id, right_id) + ) + result_ids.append(coalesced_id) + + return expr, result_ids + + def _cast_index(block: Block, dtypes: typing.Sequence[bigframes.dtypes.Dtype]): original_block = block result_ids = [] @@ -3191,3 +3484,35 @@ def _pd_index_to_array_value( rows.append(row) return core.ArrayValue.from_pyarrow(pa.Table.from_pylist(rows), session=session) + + +def _resolve_index_col( + left_index_cols: list[str], + right_index_cols: list[str], + resolved_join_ids: list[str], + left_index: bool, + right_index: bool, + how: typing.Literal[ + "inner", + "left", + "outer", + "right", + "cross", + ], +) -> list[str]: + if left_index and right_index: + if how == "inner" or how == "left": + return left_index_cols + if how == "right": + return right_index_cols + if how == "outer": + return resolved_join_ids + else: + return [] + elif left_index and not right_index: + return right_index_cols + elif right_index and not left_index: + return left_index_cols + else: + # Joining with value columns only. Existing indices will be discarded. + return [] diff --git a/bigframes/core/bq_data.py b/bigframes/core/bq_data.py new file mode 100644 index 0000000000..9b2103b01d --- /dev/null +++ b/bigframes/core/bq_data.py @@ -0,0 +1,236 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import concurrent.futures +import dataclasses +import datetime +import functools +import os +import queue +import threading +import typing +from typing import Any, Iterator, Optional, Sequence, Tuple + +from google.cloud import bigquery_storage_v1 +import google.cloud.bigquery as bq +import google.cloud.bigquery_storage_v1.types as bq_storage_types +from google.protobuf import timestamp_pb2 +import pyarrow as pa + +from bigframes.core import pyarrow_utils +import bigframes.core.schema + +if typing.TYPE_CHECKING: + import bigframes.core.ordering as orderings + + +@dataclasses.dataclass(frozen=True) +class GbqTable: + project_id: str = dataclasses.field() + dataset_id: str = dataclasses.field() + table_id: str = dataclasses.field() + physical_schema: Tuple[bq.SchemaField, ...] = dataclasses.field() + is_physically_stored: bool = dataclasses.field() + cluster_cols: typing.Optional[Tuple[str, ...]] + + @staticmethod + def from_table(table: bq.Table, columns: Sequence[str] = ()) -> GbqTable: + # Subsetting fields with columns can reduce cost of row-hash default ordering + if columns: + schema = tuple(item for item in table.schema if item.name in columns) + else: + schema = tuple(table.schema) + return GbqTable( + project_id=table.project, + dataset_id=table.dataset_id, + table_id=table.table_id, + physical_schema=schema, + is_physically_stored=(table.table_type in ["TABLE", "MATERIALIZED_VIEW"]), + cluster_cols=None + if table.clustering_fields is None + else tuple(table.clustering_fields), + ) + + @staticmethod + def from_ref_and_schema( + table_ref: bq.TableReference, + schema: Sequence[bq.SchemaField], + cluster_cols: Optional[Sequence[str]] = None, + ) -> GbqTable: + return GbqTable( + project_id=table_ref.project, + dataset_id=table_ref.dataset_id, + table_id=table_ref.table_id, + physical_schema=tuple(schema), + is_physically_stored=True, + cluster_cols=tuple(cluster_cols) if cluster_cols else None, + ) + + def get_table_ref(self) -> bq.TableReference: + return bq.TableReference( + bq.DatasetReference(self.project_id, self.dataset_id), self.table_id + ) + + @property + @functools.cache + def schema_by_id(self): + return {col.name: col for col in self.physical_schema} + + +@dataclasses.dataclass(frozen=True) +class BigqueryDataSource: + """ + Google BigQuery Data source. + + This should not be modified once defined, as all attributes contribute to the default ordering. + """ + + def __post_init__(self): + # not all columns need be in schema, eg so can exclude unsupported column types (eg RANGE) + assert set(field.name for field in self.table.physical_schema).issuperset( + self.schema.names + ) + + table: GbqTable + schema: bigframes.core.schema.ArraySchema + at_time: typing.Optional[datetime.datetime] = None + # Added for backwards compatibility, not validated + sql_predicate: typing.Optional[str] = None + ordering: typing.Optional[orderings.RowOrdering] = None + # Optimization field + n_rows: Optional[int] = None + + +_WORKER_TIME_INCREMENT = 0.05 + + +def _iter_stream( + stream_name: str, + storage_read_client: bigquery_storage_v1.BigQueryReadClient, + result_queue: queue.Queue, + stop_event: threading.Event, +): + reader = storage_read_client.read_rows(stream_name) + for page in reader.rows().pages: + while True: # Alternate between put attempt and checking stop event + try: + result_queue.put(page.to_arrow(), timeout=_WORKER_TIME_INCREMENT) + break + except queue.Full: + if stop_event.is_set(): + return + continue + + +def _iter_streams( + streams: Sequence[bq_storage_types.ReadStream], + storage_read_client: bigquery_storage_v1.BigQueryReadClient, +) -> Iterator[pa.RecordBatch]: + stop_event = threading.Event() + result_queue: queue.Queue = queue.Queue( + len(streams) + ) # each response is large, so small queue is appropriate + + in_progress: list[concurrent.futures.Future] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=len(streams)) as pool: + try: + for stream in streams: + in_progress.append( + pool.submit( + _iter_stream, + stream.name, + storage_read_client, + result_queue, + stop_event, + ) + ) + + while in_progress: + try: + yield result_queue.get(timeout=0.1) + except queue.Empty: + new_in_progress = [] + for future in in_progress: + if future.done(): + # Call to raise any exceptions + future.result() + else: + new_in_progress.append(future) + in_progress = new_in_progress + finally: + stop_event.set() + + +@dataclasses.dataclass +class ReadResult: + iter: Iterator[pa.RecordBatch] + approx_rows: int + approx_bytes: int + + +def get_arrow_batches( + data: BigqueryDataSource, + columns: Sequence[str], + storage_read_client: bigquery_storage_v1.BigQueryReadClient, + project_id: str, +) -> ReadResult: + table_mod_options = {} + read_options_dict: dict[str, Any] = {"selected_fields": list(columns)} + if data.sql_predicate: + read_options_dict["row_restriction"] = data.sql_predicate + read_options = bq_storage_types.ReadSession.TableReadOptions(**read_options_dict) + + if data.at_time: + snapshot_time = timestamp_pb2.Timestamp() + snapshot_time.FromDatetime(data.at_time) + table_mod_options["snapshot_time"] = snapshot_time + table_mods = bq_storage_types.ReadSession.TableModifiers(**table_mod_options) + + requested_session = bq_storage_types.stream.ReadSession( + table=data.table.get_table_ref().to_bqstorage(), + data_format=bq_storage_types.DataFormat.ARROW, + read_options=read_options, + table_modifiers=table_mods, + ) + if data.ordering is not None: + max_streams = 1 + else: + max_streams = os.cpu_count() or 8 + + # Single stream to maintain ordering + request = bq_storage_types.CreateReadSessionRequest( + parent=f"projects/{project_id}", + read_session=requested_session, + max_stream_count=max_streams, + ) + + session = storage_read_client.create_read_session(request=request) + + if not session.streams: + batches: Iterator[pa.RecordBatch] = iter([]) + else: + batches = _iter_streams(session.streams, storage_read_client) + + def process_batch(pa_batch): + return pyarrow_utils.cast_batch( + pa_batch.select(columns), data.schema.select(columns).to_pyarrow() + ) + + batches = map(process_batch, batches) + + return ReadResult( + batches, session.estimated_row_count, session.estimated_total_bytes_scanned + ) diff --git a/bigframes/core/compile/__init__.py b/bigframes/core/compile/__init__.py index 964113bd7b..68c36df288 100644 --- a/bigframes/core/compile/__init__.py +++ b/bigframes/core/compile/__init__.py @@ -13,14 +13,13 @@ # limitations under the License. from __future__ import annotations -from bigframes.core.compile.api import ( - SQLCompiler, - test_only_ibis_inferred_schema, - test_only_try_evaluate, -) +from bigframes.core.compile.api import test_only_ibis_inferred_schema +from bigframes.core.compile.configs import CompileRequest, CompileResult +from bigframes.core.compile.ibis_compiler.ibis_compiler import compile_sql __all__ = [ - "SQLCompiler", - "test_only_try_evaluate", "test_only_ibis_inferred_schema", + "compile_sql", + "CompileRequest", + "CompileResult", ] diff --git a/bigframes/core/compile/api.py b/bigframes/core/compile/api.py index 9280cfbb7b..dde6f3a325 100644 --- a/bigframes/core/compile/api.py +++ b/bigframes/core/compile/api.py @@ -13,75 +13,23 @@ # limitations under the License. from __future__ import annotations -from typing import Mapping, Sequence, Tuple, TYPE_CHECKING - -import google.cloud.bigquery as bigquery - -import bigframes.core.compile.compiler as compiler +from typing import TYPE_CHECKING if TYPE_CHECKING: import bigframes.core.nodes - import bigframes.core.ordering - import bigframes.core.schema - -_STRICT_COMPILER = compiler.Compiler(strict=True) - - -class SQLCompiler: - def __init__(self, strict: bool = True): - self._compiler = compiler.Compiler(strict=strict) - - def compile_peek(self, node: bigframes.core.nodes.BigFrameNode, n_rows: int) -> str: - """Compile node into sql that selects N arbitrary rows, may not execute deterministically.""" - return self._compiler.compile_peek_sql(node, n_rows) - - def compile_unordered( - self, - node: bigframes.core.nodes.BigFrameNode, - *, - col_id_overrides: Mapping[str, str] = {}, - ) -> str: - """Compile node into sql where rows are unsorted, and no ordering information is preserved.""" - # TODO: Enable limit pullup, but only if not being used to write to clustered table. - output_ids = [col_id_overrides.get(id, id) for id in node.schema.names] - return self._compiler.compile_sql(node, ordered=False, output_ids=output_ids) - - def compile_ordered( - self, - node: bigframes.core.nodes.BigFrameNode, - *, - col_id_overrides: Mapping[str, str] = {}, - ) -> str: - """Compile node into sql where rows are sorted with ORDER BY.""" - # If we are ordering the query anyways, compiling the slice as a limit is probably a good idea. - output_ids = [col_id_overrides.get(id, id) for id in node.schema.names] - return self._compiler.compile_sql(node, ordered=True, output_ids=output_ids) - - def compile_raw( - self, - node: bigframes.core.nodes.BigFrameNode, - ) -> Tuple[ - str, Sequence[bigquery.SchemaField], bigframes.core.ordering.RowOrdering - ]: - """Compile node into sql that exposes all columns, including hidden ordering-only columns.""" - return self._compiler.compile_raw(node) - - -def test_only_try_evaluate(node: bigframes.core.nodes.BigFrameNode): - """Use only for unit testing paths - not fully featured. Will throw exception if fails.""" - node = _STRICT_COMPILER._preprocess(node) - ibis = _STRICT_COMPILER.compile_node(node)._to_ibis_expr() - return ibis.pandas.connect({}).execute(ibis) def test_only_ibis_inferred_schema(node: bigframes.core.nodes.BigFrameNode): """Use only for testing paths to ensure ibis inferred schema does not diverge from bigframes inferred schema.""" + from bigframes.core.compile.ibis_compiler import ibis_compiler + import bigframes.core.rewrite import bigframes.core.schema - node = _STRICT_COMPILER._preprocess(node) - compiled = _STRICT_COMPILER.compile_node(node) + node = ibis_compiler._replace_unsupported_ops(node) + node = bigframes.core.rewrite.bake_order(node) + ir = ibis_compiler.compile_node(node) items = tuple( - bigframes.core.schema.SchemaItem(name, compiled.get_column_type(ibis_id)) - for name, ibis_id in zip(node.schema.names, compiled.column_ids) + bigframes.core.schema.SchemaItem(name, ir.get_column_type(ibis_id)) + for name, ibis_id in zip(node.schema.names, ir.column_ids) ) return bigframes.core.schema.ArraySchema(items) diff --git a/bigframes/core/compile/compiled.py b/bigframes/core/compile/compiled.py index b0cf30269e..f8be331d59 100644 --- a/bigframes/core/compile/compiled.py +++ b/bigframes/core/compile/compiled.py @@ -24,18 +24,21 @@ import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes import bigframes_vendored.ibis.expr.operations as ibis_ops import bigframes_vendored.ibis.expr.types as ibis_types -import pandas +from google.cloud import bigquery +import pyarrow as pa -import bigframes.core.compile.aggregate_compiler as agg_compiler +from bigframes.core import agg_expressions +import bigframes.core.agg_expressions as ex_types import bigframes.core.compile.googlesql +import bigframes.core.compile.ibis_compiler.aggregate_compiler as agg_compiler +import bigframes.core.compile.ibis_compiler.scalar_op_compiler as op_compilers import bigframes.core.compile.ibis_types -import bigframes.core.compile.scalar_op_compiler as op_compilers import bigframes.core.expression as ex -import bigframes.core.guid from bigframes.core.ordering import OrderingExpression import bigframes.core.sql -from bigframes.core.window_spec import RangeWindowBounds, RowsWindowBounds, WindowSpec +from bigframes.core.window_spec import WindowSpec import bigframes.dtypes +import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops op_compiler = op_compilers.scalar_op_compiler @@ -65,19 +68,28 @@ def __init__( def to_sql( self, - *, - order_by: Sequence[OrderingExpression] = (), - limit: Optional[int] = None, - selections: Optional[Sequence[str]] = None, + order_by: Sequence[OrderingExpression], + limit: Optional[int], + selections: tuple[tuple[ex.DerefOp, str], ...], ) -> str: ibis_table = self._to_ibis_expr() # This set of output transforms maybe should be its own output node?? - if order_by or limit: + + selection_strings = tuple((ref.id.sql, name) for ref, name in selections) + + names_preserved = tuple(name for _, name in selections) == tuple( + self.column_ids + ) + is_noop_selection = ( + all((i[0] == i[1] for i in selection_strings)) and names_preserved + ) + + if order_by or limit or not is_noop_selection: sql = ibis_bigquery.Backend().compile(ibis_table) sql = ( bigframes.core.compile.googlesql.Select() .from_(sql) - .select(selections or self.column_ids) + .select(selection_strings) .sql() ) @@ -154,18 +166,6 @@ def get_column_type(self, key: str) -> bigframes.dtypes.Dtype: bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype(ibis_type), ) - def row_count(self, name: str) -> UnorderedIR: - original_table = self._to_ibis_expr() - ibis_table = original_table.agg( - [ - original_table.count().name(name), - ] - ) - return UnorderedIR( - ibis_table, - (ibis_table[name],), - ) - def _to_ibis_expr( self, *, @@ -203,7 +203,7 @@ def filter(self, predicate: ex.Expression) -> UnorderedIR: def aggregate( self, - aggregations: typing.Sequence[tuple[ex.Aggregation, str]], + aggregations: typing.Sequence[tuple[ex_types.Aggregation, str]], by_column_ids: typing.Sequence[ex.DerefOp] = (), order_by: typing.Sequence[OrderingExpression] = (), ) -> UnorderedIR: @@ -224,7 +224,9 @@ def aggregate( col_out: agg_compiler.compile_aggregate( aggregate, bindings, - order_by=_convert_ordering_to_table_values(table, order_by), + order_by=op_compiler._convert_row_ordering_to_table_values( + table, order_by + ), ) for aggregate, col_out in aggregations } @@ -272,50 +274,20 @@ def _reproject_to_table(self) -> UnorderedIR: ) @classmethod - def from_pandas( - cls, - pd_df: pandas.DataFrame, - scan_cols: bigframes.core.nodes.ScanList, - offsets: typing.Optional[str] = None, + def from_polars( + cls, pa_table: pa.Table, schema: Sequence[bigquery.SchemaField] ) -> UnorderedIR: - # TODO: add offsets - """ - Builds an in-memory only (SQL only) expr from a pandas dataframe. - - Assumed that the dataframe has unique string column names and bigframes-suppported - dtypes. - """ + """Builds an in-memory only (SQL only) expr from a pyarrow table.""" + import bigframes_vendored.ibis.backends.bigquery.datatypes as third_party_ibis_bqtypes - # ibis memtable cannot handle NA, must convert to None - # this destroys the schema however - ibis_values = pd_df.astype("object").where(pandas.notnull(pd_df), None) # type: ignore - if offsets: - ibis_values = ibis_values.assign(**{offsets: range(len(pd_df))}) # derive the ibis schema from the original pandas schema - ibis_schema = [ - ( - local_label, - bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype(dtype), - ) - for id, dtype, local_label in scan_cols.items - ] - if offsets: - ibis_schema.append((offsets, ibis_dtypes.int64)) - keys_memtable = bigframes_vendored.ibis.memtable( - ibis_values, schema=bigframes_vendored.ibis.schema(ibis_schema) + pa_table, + schema=third_party_ibis_bqtypes.BigQuerySchema.to_ibis(list(schema)), ) - - columns = [ - keys_memtable[local_label].name(col_id.sql) - for col_id, _, local_label in scan_cols.items - ] - if offsets: - columns.append(keys_memtable[offsets].name(offsets)) - return cls( keys_memtable, - columns=columns, + columns=tuple(keys_memtable[key] for key in keys_memtable.columns), ) def join( @@ -419,11 +391,9 @@ def isin_join( def project_window_op( self, - expression: ex.Aggregation, + expression: ex_types.Aggregation, window_spec: WindowSpec, output_name: str, - *, - never_skip_nulls=False, ) -> UnorderedIR: """ Creates a new expression based on this expression with unary operation applied to one column. @@ -431,7 +401,6 @@ def project_window_op( op: the windowable operator to apply to the input column window_spec: a specification of the window over which to apply the operator output_name: the id to assign to the output of the operator - never_skip_nulls: will disable null skipping for operators that would otherwise do so """ # Cannot nest analytic expressions, so reproject to cte first if needed. # Also ibis cannot window literals, so need to reproject those (even though this is legal in googlesql) @@ -453,115 +422,67 @@ def project_window_op( expression, window_spec, output_name, - never_skip_nulls=never_skip_nulls, ) - if expression.op.order_independent and not window_spec.row_bounded: + if expression.op.order_independent and window_spec.is_unbounded: # notably percentile_cont does not support ordering clause window_spec = window_spec.without_order() - window = self._ibis_window_from_spec(window_spec) - bindings = {col: self._get_ibis_column(col) for col in self.column_ids} - window_op = agg_compiler.compile_analytic( - expression, - window, - bindings=bindings, + # TODO: Turn this logic into a true rewriter + result_expr: ex.Expression = agg_expressions.WindowExpression( + expression, window_spec ) + clauses: list[tuple[ex.Expression, ex.Expression]] = [] + if window_spec.min_periods and len(expression.inputs) > 0: + if not expression.op.nulls_count_for_min_values: + is_observation = ops.notnull_op.as_expr() - inputs = tuple( - typing.cast(ibis_types.Column, self._compile_expression(ex.DerefOp(column))) - for column in expression.column_references - ) - clauses = [] - if expression.op.skips_nulls and not never_skip_nulls: - for column in inputs: - clauses.append((column.isnull(), ibis_types.null())) - if window_spec.min_periods and len(inputs) > 0: - if expression.op.skips_nulls: # Most operations do not count NULL values towards min_periods - per_col_does_count = (column.notnull() for column in inputs) + per_col_does_count = ( + ops.notnull_op.as_expr(input) for input in expression.inputs + ) # All inputs must be non-null for observation to count is_observation = functools.reduce( - lambda x, y: x & y, per_col_does_count - ).cast(int) - observation_count = agg_compiler.compile_analytic( - ex.UnaryAggregation(agg_ops.sum_op, ex.deref("_observation_count")), - window, - bindings={"_observation_count": is_observation}, + lambda x, y: ops.and_op.as_expr(x, y), per_col_does_count + ) + observation_sentinel = ops.AsTypeOp(bigframes.dtypes.INT_DTYPE).as_expr( + is_observation + ) + observation_count_expr = agg_expressions.WindowExpression( + ex_types.UnaryAggregation(agg_ops.sum_op, observation_sentinel), + window_spec, ) else: # Operations like count treat even NULLs as valid observations for the sake of min_periods # notnull is just used to convert null values to non-null (FALSE) values to be counted - is_observation = inputs[0].notnull() - observation_count = agg_compiler.compile_analytic( - ex.UnaryAggregation( - agg_ops.count_op, ex.deref("_observation_count") - ), - window, - bindings={"_observation_count": is_observation}, + is_observation = ops.notnull_op.as_expr(expression.inputs[0]) + observation_count_expr = agg_expressions.WindowExpression( + agg_ops.count_op.as_expr(is_observation), + window_spec, ) clauses.append( ( - observation_count < ibis_types.literal(window_spec.min_periods), - ibis_types.null(), + ops.lt_op.as_expr( + observation_count_expr, ex.const(window_spec.min_periods) + ), + ex.const(None), ) ) if clauses: - case_statement = bigframes_vendored.ibis.case() - for clause in clauses: - case_statement = case_statement.when(clause[0], clause[1]) - case_statement = case_statement.else_(window_op).end() # type: ignore - window_op = case_statement # type: ignore + case_inputs = [ + *itertools.chain.from_iterable(clauses), + ex.const(True), + result_expr, + ] + result_expr = ops.CaseWhenOp().as_expr(*case_inputs) + + ibis_expr = op_compiler.compile_expression(result_expr, self._ibis_bindings) - return UnorderedIR(self._table, (*self.columns, window_op.name(output_name))) + return UnorderedIR(self._table, (*self.columns, ibis_expr.name(output_name))) def _compile_expression(self, expr: ex.Expression): return op_compiler.compile_expression(expr, self._ibis_bindings) - def _ibis_window_from_spec(self, window_spec: WindowSpec): - group_by: typing.List[ibis_types.Value] = ( - [ - typing.cast( - ibis_types.Column, _as_groupable(self._compile_expression(column)) - ) - for column in window_spec.grouping_keys - ] - if window_spec.grouping_keys - else [] - ) - - # Construct ordering. There are basically 3 main cases - # 1. Order-independent op (aggregation, cut, rank) with unbound window - no ordering clause needed - # 2. Order-independent op (aggregation, cut, rank) with range window - use ordering clause, ties allowed - # 3. Order-depedenpent op (navigation functions, array_agg) or rows bounds - use total row order to break ties. - if window_spec.ordering: - order_by = _convert_ordering_to_table_values( - self._column_names, - window_spec.ordering, - ) - elif window_spec.row_bounded: - # If window spec has following or preceding bounds, we need to apply an unambiguous ordering. - raise ValueError("No ordering provided for ordered analytic function") - else: - # Unbound grouping window. Suitable for aggregations but not for analytic function application. - order_by = None - - bounds = window_spec.bounds - window = bigframes_vendored.ibis.window(order_by=order_by, group_by=group_by) - if bounds is not None: - if isinstance(bounds, RangeWindowBounds): - window = window.preceding_following( - bounds.preceding, bounds.following, how="range" - ) - if isinstance(bounds, RowsWindowBounds): - if bounds.preceding is not None or bounds.following is not None: - window = window.preceding_following( - bounds.preceding, bounds.following, how="rows" - ) - else: - raise ValueError(f"unrecognized window bounds {bounds}") - return window - def is_literal(column: ibis_types.Value) -> bool: # Unfortunately, Literals in ibis are not "Columns"s and therefore can't be aggregated. @@ -579,34 +500,6 @@ def is_window(column: ibis_types.Value) -> bool: return any(isinstance(op, ibis_ops.WindowFunction) for op in matches) -def _convert_ordering_to_table_values( - value_lookup: typing.Mapping[str, ibis_types.Value], - ordering_columns: typing.Sequence[OrderingExpression], -) -> typing.Sequence[ibis_types.Value]: - column_refs = ordering_columns - ordering_values = [] - for ordering_col in column_refs: - expr = op_compiler.compile_expression( - ordering_col.scalar_expression, value_lookup - ) - ordering_value = ( - bigframes_vendored.ibis.asc(expr) # type: ignore - if ordering_col.direction.is_ascending - else bigframes_vendored.ibis.desc(expr) # type: ignore - ) - # Bigquery SQL considers NULLS to be "smallest" values, but we need to override in these cases. - if (not ordering_col.na_last) and (not ordering_col.direction.is_ascending): - # Force nulls to be first - is_null_val = typing.cast(ibis_types.Column, expr.isnull()) - ordering_values.append(bigframes_vendored.ibis.desc(is_null_val)) - elif (ordering_col.na_last) and (ordering_col.direction.is_ascending): - # Force nulls to be last - is_null_val = typing.cast(ibis_types.Column, expr.isnull()) - ordering_values.append(bigframes_vendored.ibis.asc(is_null_val)) - ordering_values.append(ordering_value) - return ordering_values - - def _string_cast_join_cond( lvalue: ibis_types.Column, rvalue: ibis_types.Column ) -> ibis_types.BooleanColumn: @@ -666,10 +559,3 @@ def _join_condition( else: return _string_cast_join_cond(lvalue, rvalue) return typing.cast(ibis_types.BooleanColumn, lvalue == rvalue) - - -def _as_groupable(value: ibis_types.Value): - # Some types need to be converted to string to enable groupby - if value.type().is_float64() or value.type().is_geospatial(): - return value.cast(ibis_dtypes.str) - return value diff --git a/bigframes/core/compile/compiler.py b/bigframes/core/compile/compiler.py deleted file mode 100644 index 77f51542b4..0000000000 --- a/bigframes/core/compile/compiler.py +++ /dev/null @@ -1,320 +0,0 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from __future__ import annotations - -import dataclasses -import functools -import io -import typing - -import bigframes_vendored.ibis.backends.bigquery as ibis_bigquery -import bigframes_vendored.ibis.expr.api as ibis_api -import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes -import bigframes_vendored.ibis.expr.types as ibis_types -import google.cloud.bigquery -import pandas as pd - -from bigframes import dtypes, operations -from bigframes.core import utils -import bigframes.core.compile.compiled as compiled -import bigframes.core.compile.concat as concat_impl -import bigframes.core.compile.explode -import bigframes.core.compile.ibis_types -import bigframes.core.compile.scalar_op_compiler as compile_scalar -import bigframes.core.compile.schema_translator -import bigframes.core.expression as ex -import bigframes.core.identifiers as ids -import bigframes.core.nodes as nodes -import bigframes.core.ordering as bf_ordering -import bigframes.core.rewrite as rewrites - -if typing.TYPE_CHECKING: - import bigframes.core - import bigframes.session - - -@dataclasses.dataclass(frozen=True) -class Compiler: - # In strict mode, ordering will always be deterministic - # In unstrict mode, ordering from ReadTable or after joins may be ambiguous to improve query performance. - strict: bool = True - scalar_op_compiler = compile_scalar.ScalarOpCompiler() - - def compile_sql( - self, node: nodes.BigFrameNode, ordered: bool, output_ids: typing.Sequence[str] - ) -> str: - # TODO: get rid of output_ids arg - assert len(output_ids) == len(list(node.fields)) - node = set_output_names(node, output_ids) - node = nodes.top_down(node, rewrites.rewrite_timedelta_expressions) - if ordered: - node, limit = rewrites.pullup_limit_from_slice(node) - node = nodes.bottom_up(node, rewrites.rewrite_slice) - # TODO: Extract out CTEs - node, ordering = rewrites.pull_up_order( - node, order_root=True, ordered_joins=self.strict - ) - node = rewrites.column_pruning(node) - ir = self.compile_node(node) - return ir.to_sql( - order_by=ordering.all_ordering_columns, - limit=limit, - selections=output_ids, - ) - else: - node = nodes.bottom_up(node, rewrites.rewrite_slice) - node, _ = rewrites.pull_up_order( - node, order_root=False, ordered_joins=self.strict - ) - node = rewrites.column_pruning(node) - ir = self.compile_node(node) - return ir.to_sql(selections=output_ids) - - def compile_peek_sql(self, node: nodes.BigFrameNode, n_rows: int) -> str: - ids = [id.sql for id in node.ids] - node = nodes.bottom_up(node, rewrites.rewrite_slice) - node = nodes.top_down(node, rewrites.rewrite_timedelta_expressions) - node, _ = rewrites.pull_up_order( - node, order_root=False, ordered_joins=self.strict - ) - node = rewrites.column_pruning(node) - return self.compile_node(node).to_sql(limit=n_rows, selections=ids) - - def compile_raw( - self, - node: bigframes.core.nodes.BigFrameNode, - ) -> typing.Tuple[ - str, typing.Sequence[google.cloud.bigquery.SchemaField], bf_ordering.RowOrdering - ]: - node = nodes.bottom_up(node, rewrites.rewrite_slice) - node = nodes.top_down(node, rewrites.rewrite_timedelta_expressions) - node, ordering = rewrites.pull_up_order(node, ordered_joins=self.strict) - node = rewrites.column_pruning(node) - ir = self.compile_node(node) - sql = ir.to_sql() - return sql, node.schema.to_bigquery(), ordering - - def _preprocess(self, node: nodes.BigFrameNode): - node = nodes.bottom_up(node, rewrites.rewrite_slice) - node = nodes.top_down(node, rewrites.rewrite_timedelta_expressions) - node, _ = rewrites.pull_up_order( - node, order_root=False, ordered_joins=self.strict - ) - return node - - # TODO: Remove cache when schema no longer requires compilation to derive schema (and therefor only compiles for execution) - @functools.lru_cache(maxsize=5000) - def compile_node(self, node: nodes.BigFrameNode) -> compiled.UnorderedIR: - """Compile node into CompileArrayValue. Caches result.""" - return self._compile_node(node) - - @functools.singledispatchmethod - def _compile_node(self, node: nodes.BigFrameNode) -> compiled.UnorderedIR: - """Defines transformation but isn't cached, always use compile_node instead""" - raise ValueError(f"Can't compile unrecognized node: {node}") - - @_compile_node.register - def compile_join(self, node: nodes.JoinNode): - condition_pairs = tuple( - (left.id.sql, right.id.sql) for left, right in node.conditions - ) - - left_unordered = self.compile_node(node.left_child) - right_unordered = self.compile_node(node.right_child) - return left_unordered.join( - right=right_unordered, - type=node.type, - conditions=condition_pairs, - join_nulls=node.joins_nulls, - ) - - @_compile_node.register - def compile_isin(self, node: nodes.InNode): - left_unordered = self.compile_node(node.left_child) - right_unordered = self.compile_node(node.right_child) - return left_unordered.isin_join( - right=right_unordered, - indicator_col=node.indicator_col.sql, - conditions=(node.left_col.id.sql, node.right_col.id.sql), - join_nulls=node.joins_nulls, - ) - - @_compile_node.register - def compile_fromrange(self, node: nodes.FromRangeNode): - # Both start and end are single elements and do not inherently have an order - start = self.compile_node(node.start) - end = self.compile_node(node.end) - start_table = start._to_ibis_expr() - end_table = end._to_ibis_expr() - - start_column = start_table.schema().names[0] - end_column = end_table.schema().names[0] - - # Perform a cross join to avoid errors - joined_table = start_table.cross_join(end_table) - - labels_array_table = ibis_api.range( - joined_table[start_column], joined_table[end_column] + node.step, node.step - ).name(node.output_id.sql) - labels = ( - typing.cast(ibis_types.ArrayValue, labels_array_table) - .as_table() - .unnest([node.output_id.sql]) - ) - return compiled.UnorderedIR( - labels, - columns=[labels[labels.columns[0]]], - ) - - @_compile_node.register - def compile_readlocal(self, node: nodes.ReadLocalNode): - array_as_pd = pd.read_feather( - io.BytesIO(node.feather_bytes), - columns=[item.source_id for item in node.scan_list.items], - ) - - # Convert timedeltas to microseconds for compatibility with BigQuery - _ = utils.replace_timedeltas_with_micros(array_as_pd) - - offsets = node.offsets_col.sql if node.offsets_col else None - return compiled.UnorderedIR.from_pandas( - array_as_pd, node.scan_list, offsets=offsets - ) - - @_compile_node.register - def compile_readtable(self, node: nodes.ReadTableNode): - return self.compile_read_table_unordered(node.source, node.scan_list) - - def read_table_as_unordered_ibis( - self, - source: nodes.BigqueryDataSource, - scan_cols: typing.Sequence[str], - ) -> ibis_types.Table: - full_table_name = f"{source.table.project_id}.{source.table.dataset_id}.{source.table.table_id}" - # Physical schema might include unused columns, unsupported datatypes like JSON - physical_schema = ibis_bigquery.BigQuerySchema.to_ibis( - list(source.table.physical_schema) - ) - if source.at_time is not None or source.sql_predicate is not None: - import bigframes.session._io.bigquery - - sql = bigframes.session._io.bigquery.to_query( - full_table_name, - columns=scan_cols, - sql_predicate=source.sql_predicate, - time_travel_timestamp=source.at_time, - ) - return ibis_bigquery.Backend().sql(schema=physical_schema, query=sql) - else: - return ibis_api.table(physical_schema, full_table_name).select(scan_cols) - - def compile_read_table_unordered( - self, source: nodes.BigqueryDataSource, scan: nodes.ScanList - ): - ibis_table = self.read_table_as_unordered_ibis( - source, scan_cols=[col.source_id for col in scan.items] - ) - - # TODO(b/395912450): Remove workaround solution once b/374784249 got resolved. - for scan_item in scan.items: - if ( - scan_item.dtype == dtypes.JSON_DTYPE - and ibis_table[scan_item.source_id].type() == ibis_dtypes.string - ): - json_column = compile_scalar.parse_json( - ibis_table[scan_item.source_id] - ).name(scan_item.source_id) - ibis_table = ibis_table.mutate(json_column) - - return compiled.UnorderedIR( - ibis_table, - tuple( - ibis_table[scan_item.source_id].name(scan_item.id.sql) - for scan_item in scan.items - ), - ) - - @_compile_node.register - def compile_filter(self, node: nodes.FilterNode): - return self.compile_node(node.child).filter(node.predicate) - - @_compile_node.register - def compile_selection(self, node: nodes.SelectionNode): - result = self.compile_node(node.child) - selection = tuple((ref, id.sql) for ref, id in node.input_output_pairs) - return result.selection(selection) - - @_compile_node.register - def compile_projection(self, node: nodes.ProjectionNode): - result = self.compile_node(node.child) - projections = ((expr, id.sql) for expr, id in node.assignments) - return result.projection(tuple(projections)) - - @_compile_node.register - def compile_concat(self, node: nodes.ConcatNode): - output_ids = [id.sql for id in node.output_ids] - compiled_unordered = [self.compile_node(node) for node in node.children] - return concat_impl.concat_unordered(compiled_unordered, output_ids) - - @_compile_node.register - def compile_rowcount(self, node: nodes.RowCountNode): - result = self.compile_node(node.child).row_count(name=node.col_id.sql) - return result - - @_compile_node.register - def compile_aggregate(self, node: nodes.AggregateNode): - aggs = tuple((agg, id.sql) for agg, id in node.aggregations) - result = self.compile_node(node.child).aggregate( - aggs, node.by_column_ids, order_by=node.order_by - ) - # TODO: Remove dropna field and use filter node instead - if node.dropna: - for key in node.by_column_ids: - if node.child.field_by_id[key.id].nullable: - result = result.filter(operations.notnull_op.as_expr(key)) - return result - - @_compile_node.register - def compile_window(self, node: nodes.WindowOpNode): - result = self.compile_node(node.child).project_window_op( - node.expression, - node.window_spec, - node.output_name.sql, - never_skip_nulls=node.never_skip_nulls, - ) - return result - - @_compile_node.register - def compile_explode(self, node: nodes.ExplodeNode): - offsets_col = node.offsets_col.sql if (node.offsets_col is not None) else None - return bigframes.core.compile.explode.explode_unordered( - self.compile_node(node.child), node.column_ids, offsets_col - ) - - @_compile_node.register - def compile_random_sample(self, node: nodes.RandomSampleNode): - return self.compile_node(node.child)._uniform_sampling(node.fraction) - - -def set_output_names( - node: bigframes.core.nodes.BigFrameNode, output_ids: typing.Sequence[str] -): - # TODO: Create specialized output operators that will handle final names - return nodes.SelectionNode( - node, - tuple( - bigframes.core.nodes.AliasedRef(ex.DerefOp(old_id), ids.ColumnId(out_id)) - for old_id, out_id in zip(node.ids, output_ids) - ), - ) diff --git a/bigframes/core/compile/configs.py b/bigframes/core/compile/configs.py new file mode 100644 index 0000000000..5ffca0cf43 --- /dev/null +++ b/bigframes/core/compile/configs.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import dataclasses +import typing + +import google.cloud.bigquery + +from bigframes.core import nodes, ordering + + +@dataclasses.dataclass(frozen=True) +class CompileRequest: + node: nodes.BigFrameNode + sort_rows: bool + materialize_all_order_keys: bool = False + peek_count: typing.Optional[int] = None + + +@dataclasses.dataclass(frozen=True) +class CompileResult: + sql: str + sql_schema: typing.Sequence[google.cloud.bigquery.SchemaField] + row_order: typing.Optional[ordering.RowOrdering] diff --git a/bigframes/core/compile/constants.py b/bigframes/core/compile/constants.py new file mode 100644 index 0000000000..9c307125ab --- /dev/null +++ b/bigframes/core/compile/constants.py @@ -0,0 +1,27 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# Datetime constants +UNIT_TO_US_CONVERSION_FACTORS = { + "W": 7 * 24 * 60 * 60 * 1000 * 1000, + "d": 24 * 60 * 60 * 1000 * 1000, + "D": 24 * 60 * 60 * 1000 * 1000, + "h": 60 * 60 * 1000 * 1000, + "m": 60 * 1000 * 1000, + "s": 1000 * 1000, + "ms": 1000, + "us": 1, + "ns": 1e-3, +} diff --git a/bigframes/core/compile/googlesql/query.py b/bigframes/core/compile/googlesql/query.py index dfe21ef7b2..f591216b3a 100644 --- a/bigframes/core/compile/googlesql/query.py +++ b/bigframes/core/compile/googlesql/query.py @@ -63,22 +63,37 @@ class Select(abc.SQLSyntax): def select( self, - columns: typing.Union[typing.Iterable[str], str, None] = None, + columns: typing.Union[ + typing.Iterable[str], typing.Iterable[tuple[str, str]], str, None + ] = None, distinct: bool = False, ) -> Select: if isinstance(columns, str): columns = [columns] self.select_list: typing.List[typing.Union[SelectExpression, SelectAll]] = ( - [ - SelectExpression(expression=expr.ColumnExpression(name=column)) - for column in columns - ] + [self._select_field(column) for column in columns] if columns else [SelectAll(expression=expr.StarExpression())] ) self.distinct = distinct return self + def _select_field(self, field) -> SelectExpression: + if isinstance(field, str): + return SelectExpression(expression=expr.ColumnExpression(name=field)) + + else: + alias = ( + expr.AliasExpression(field[1]) + if isinstance(field[1], str) + else field[1] + if (field[0] != field[1]) + else None + ) + return SelectExpression( + expression=expr.ColumnExpression(name=field[0]), alias=alias + ) + def from_( self, sources: typing.Union[TABLE_SOURCE_TYPE, typing.Iterable[TABLE_SOURCE_TYPE]], @@ -110,7 +125,7 @@ def sql(self) -> str: return "\n".join(text) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class SelectExpression(abc.SQLSyntax): """This class represents `select_expression`.""" diff --git a/bigframes/core/compile/ibis_compiler/__init__.py b/bigframes/core/compile/ibis_compiler/__init__.py new file mode 100644 index 0000000000..6b9d284c53 --- /dev/null +++ b/bigframes/core/compile/ibis_compiler/__init__.py @@ -0,0 +1,25 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Compiler for BigFrames expression to Ibis expression. + +Make sure to import all ibis_compiler implementations here so that they get +registered. +""" + +from __future__ import annotations + +import bigframes.core.compile.ibis_compiler.operations.generic_ops # noqa: F401 +import bigframes.core.compile.ibis_compiler.operations.geo_ops # noqa: F401 +import bigframes.core.compile.ibis_compiler.scalar_op_registry # noqa: F401 diff --git a/bigframes/core/compile/aggregate_compiler.py b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py similarity index 67% rename from bigframes/core/compile/aggregate_compiler.py rename to bigframes/core/compile/ibis_compiler/aggregate_compiler.py index 4ec0b270ed..0106b150e2 100644 --- a/bigframes/core/compile/aggregate_compiler.py +++ b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py @@ -19,16 +19,22 @@ from typing import cast, List, Optional import bigframes_vendored.constants as constants +import bigframes_vendored.ibis +from bigframes_vendored.ibis.expr import builders as ibis_expr_builders import bigframes_vendored.ibis.expr.api as ibis_api import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes +from bigframes_vendored.ibis.expr.operations import window as ibis_expr_window import bigframes_vendored.ibis.expr.operations as ibis_ops import bigframes_vendored.ibis.expr.operations.udf as ibis_udf import bigframes_vendored.ibis.expr.types as ibis_types import pandas as pd +from bigframes.core import agg_expressions +from bigframes.core.compile import constants as compiler_constants +import bigframes.core.compile.ibis_compiler.scalar_op_compiler as scalar_compilers import bigframes.core.compile.ibis_types as compile_ibis_types -import bigframes.core.compile.scalar_op_compiler as scalar_compilers -import bigframes.core.expression as ex +import bigframes.core.utils +from bigframes.core.window_spec import RangeWindowBounds, RowsWindowBounds, WindowSpec import bigframes.core.window_spec as window_spec import bigframes.operations.aggregations as agg_ops @@ -47,19 +53,19 @@ def approx_quantiles(expression: float, number) -> List[float]: def compile_aggregate( - aggregate: ex.Aggregation, + aggregate: agg_expressions.Aggregation, bindings: typing.Dict[str, ibis_types.Value], order_by: typing.Sequence[ibis_types.Value] = [], ) -> ibis_types.Value: - if isinstance(aggregate, ex.NullaryAggregation): + if isinstance(aggregate, agg_expressions.NullaryAggregation): return compile_nullary_agg(aggregate.op) - if isinstance(aggregate, ex.UnaryAggregation): + if isinstance(aggregate, agg_expressions.UnaryAggregation): input = scalar_compiler.compile_expression(aggregate.arg, bindings=bindings) if not aggregate.op.order_independent: return compile_ordered_unary_agg(aggregate.op, input, order_by=order_by) # type: ignore else: return compile_unary_agg(aggregate.op, input) # type: ignore - elif isinstance(aggregate, ex.BinaryAggregation): + elif isinstance(aggregate, agg_expressions.BinaryAggregation): left = scalar_compiler.compile_expression(aggregate.left, bindings=bindings) right = scalar_compiler.compile_expression(aggregate.right, bindings=bindings) return compile_binary_agg(aggregate.op, left, right) # type: ignore @@ -68,16 +74,17 @@ def compile_aggregate( def compile_analytic( - aggregate: ex.Aggregation, + aggregate: agg_expressions.Aggregation, window: window_spec.WindowSpec, bindings: typing.Dict[str, ibis_types.Value], ) -> ibis_types.Value: - if isinstance(aggregate, ex.NullaryAggregation): - return compile_nullary_agg(aggregate.op, window) - elif isinstance(aggregate, ex.UnaryAggregation): + ibis_window = _ibis_window_from_spec(window, bindings=bindings) + if isinstance(aggregate, agg_expressions.NullaryAggregation): + return compile_nullary_agg(aggregate.op, ibis_window) + elif isinstance(aggregate, agg_expressions.UnaryAggregation): input = scalar_compiler.compile_expression(aggregate.arg, bindings=bindings) - return compile_unary_agg(aggregate.op, input, window) # type: ignore - elif isinstance(aggregate, ex.BinaryAggregation): + return compile_unary_agg(aggregate.op, input, ibis_window) # type: ignore + elif isinstance(aggregate, agg_expressions.BinaryAggregation): raise NotImplementedError("binary analytic operations not yet supported") else: raise ValueError(f"Unexpected analytic operation: {aggregate}") @@ -164,19 +171,15 @@ def _( ) -> ibis_types.NumericValue: # Will be null if all inputs are null. Pandas defaults to zero sum though. bq_sum = _apply_window_if_present(column.sum(), window) - return bq_sum.fillna(ibis_types.literal(0)) + return bq_sum.coalesce(ibis_types.literal(0)) @compile_unary_agg.register -@numeric_op def _( op: agg_ops.MedianOp, column: ibis_types.NumericColumn, window=None, ) -> ibis_types.NumericValue: - # TODO(swast): Allow switching between exact and approximate median. - # For now, the best we can do is an approximate median when we're doing - # an aggregation, as PERCENTILE_CONT is only an analytic function. return cast(ibis_types.NumericValue, column.approx_median()) @@ -231,7 +234,11 @@ def _( column: ibis_types.NumericColumn, window=None, ) -> ibis_types.NumericValue: - return _apply_window_if_present(column.quantile(op.q), window) + result = column.quantile(op.q) + if op.should_floor_result: + result = result.floor() # type:ignore + + return _apply_window_if_present(result, window) @compile_unary_agg.register @@ -242,7 +249,8 @@ def _( window=None, # order_by: typing.Sequence[ibis_types.Value] = [], ) -> ibis_types.NumericValue: - return _apply_window_if_present(column.mean(), window) + result = column.mean().floor() if op.should_floor_result else column.mean() + return _apply_window_if_present(result, window) @compile_unary_agg.register @@ -306,10 +314,11 @@ def _( @numeric_op def _( op: agg_ops.StdOp, - x: ibis_types.Column, + x: ibis_types.NumericColumn, window=None, ) -> ibis_types.Value: - return _apply_window_if_present(cast(ibis_types.NumericColumn, x).std(), window) + result = x.std().floor() if op.should_floor_result else x.std() + return _apply_window_if_present(result, window) @compile_unary_agg.register @@ -353,48 +362,73 @@ def _( if isinstance(op.bins, int): col_min = _apply_window_if_present(x.min(), window) col_max = _apply_window_if_present(x.max(), window) + adj = (col_max - col_min) * 0.001 bin_width = (col_max - col_min) / op.bins - if op.labels is False: - for this_bin in range(op.bins - 1): - out = out.when( - x <= (col_min + (this_bin + 1) * bin_width), - compile_ibis_types.literal_to_ibis_scalar( - this_bin, force_dtype=pd.Int64Dtype() - ), - ) - out = out.when(x.notnull(), op.bins - 1) - else: - interval_struct = None - adj = (col_max - col_min) * 0.001 - for this_bin in range(op.bins): - left_edge = ( - col_min + this_bin * bin_width - (0 if this_bin > 0 else adj) + for this_bin in range(op.bins): + if op.labels is False: + value = compile_ibis_types.literal_to_ibis_scalar( + this_bin, + force_dtype=pd.Int64Dtype(), ) - right_edge = col_min + (this_bin + 1) * bin_width - interval_struct = ibis_types.struct( - { - "left_exclusive": left_edge, - "right_inclusive": right_edge, - } + elif isinstance(op.labels, typing.Iterable): + value = compile_ibis_types.literal_to_ibis_scalar( + list(op.labels)[this_bin], + force_dtype=pd.StringDtype(storage="pyarrow"), ) + else: + left_adj = adj if this_bin == 0 and op.right else 0 + right_adj = adj if this_bin == op.bins - 1 and not op.right else 0 - if this_bin < op.bins - 1: - out = out.when( - x <= (col_min + (this_bin + 1) * bin_width), - interval_struct, + left = col_min + this_bin * bin_width - left_adj + right = col_min + (this_bin + 1) * bin_width + right_adj + + if op.right: + value = ibis_types.struct( + {"left_exclusive": left, "right_inclusive": right} + ) + else: + value = ibis_types.struct( + {"left_inclusive": left, "right_exclusive": right} ) + if this_bin == op.bins - 1: + case_expr = x.notnull() + else: + if op.right: + case_expr = x <= (col_min + (this_bin + 1) * bin_width) else: - out = out.when(x.notnull(), interval_struct) + case_expr = x < (col_min + (this_bin + 1) * bin_width) + out = out.when(case_expr, value) else: # Interpret as intervals - for interval in op.bins: + for this_bin, interval in enumerate(op.bins): left = compile_ibis_types.literal_to_ibis_scalar(interval[0]) right = compile_ibis_types.literal_to_ibis_scalar(interval[1]) - condition = (x > left) & (x <= right) - interval_struct = ibis_types.struct( - {"left_exclusive": left, "right_inclusive": right} - ) - out = out.when(condition, interval_struct) + if op.right: + condition = (x > left) & (x <= right) + else: + condition = (x >= left) & (x < right) + + if op.labels is False: + value = compile_ibis_types.literal_to_ibis_scalar( + this_bin, + force_dtype=pd.Int64Dtype(), + ) + elif isinstance(op.labels, typing.Iterable): + value = compile_ibis_types.literal_to_ibis_scalar( + list(op.labels)[this_bin], + force_dtype=pd.StringDtype(storage="pyarrow"), + ) + else: + if op.right: + value = ibis_types.struct( + {"left_exclusive": left, "right_inclusive": right} + ) + else: + value = ibis_types.struct( + {"left_inclusive": left, "right_exclusive": right} + ) + + out = out.when(condition, value) return out.end() @@ -569,6 +603,30 @@ def _( return original_column.delta(shifted_column, part="microsecond") +@compile_unary_agg.register +def _( + op: agg_ops.DateSeriesDiffOp, + column: ibis_types.Column, + window=None, +) -> ibis_types.Value: + if not column.type().is_date(): + raise TypeError(f"Cannot perform date series diff on type{column.type()}") + + original_column = cast(ibis_types.DateColumn, column) + shifted_column = cast( + ibis_types.DateColumn, + compile_unary_agg(agg_ops.ShiftOp(op.periods), column, window), + ) + + conversion_factor = typing.cast( + ibis_types.IntegerValue, compiler_constants.UNIT_TO_US_CONVERSION_FACTORS["D"] + ) + + return ( + original_column.delta(shifted_column, part="day") * conversion_factor + ).floor() + + @compile_unary_agg.register def _( op: agg_ops.AllOp, @@ -579,12 +637,7 @@ def _( result = _apply_window_if_present(_is_true(column).all(), window) literal = ibis_types.literal(True) - return cast( - ibis_types.BooleanScalar, - result.fill_null(literal) - if hasattr(result, "fill_null") - else result.fillna(literal), - ) + return cast(ibis_types.BooleanScalar, result.fill_null(literal)) @compile_unary_agg.register @@ -597,12 +650,7 @@ def _( result = _apply_window_if_present(_is_true(column).any(), window) literal = ibis_types.literal(False) - return cast( - ibis_types.BooleanScalar, - result.fill_null(literal) - if hasattr(result, "fill_null") - else result.fillna(literal), - ) + return cast(ibis_types.BooleanScalar, result.fill_null(literal)) @compile_ordered_unary_agg.register @@ -630,6 +678,29 @@ def _( ).to_expr() +@compile_ordered_unary_agg.register +def _( + op: agg_ops.StringAggOp, + column: ibis_types.Column, + window=None, + order_by: typing.Sequence[ibis_types.Value] = [], +) -> ibis_types.ArrayValue: + if window is not None: + raise NotImplementedError( + f"StringAgg with windowing is not supported. {constants.FEEDBACK_LINK}" + ) + + return ( + ibis_ops.StringAgg( + column, # type: ignore + sep=op.sep, # type: ignore + order_by=order_by, # type: ignore + ) + .to_expr() + .fill_null(ibis_types.literal("")) + ) + + @compile_binary_agg.register def _( op: agg_ops.CorrOp, left: ibis_types.Column, right: ibis_types.Column, window=None @@ -660,6 +731,109 @@ def _apply_window_if_present(value: ibis_types.Value, window): return value.over(window) if (window is not None) else value +def _ibis_window_from_spec( + window_spec: WindowSpec, bindings: typing.Dict[str, ibis_types.Value] +): + group_by: typing.List[ibis_types.Value] = ( + [ + typing.cast( + ibis_types.Column, + _as_groupable(scalar_compiler.compile_expression(column, bindings)), + ) + for column in window_spec.grouping_keys + ] + if window_spec.grouping_keys + else [] + ) + + # Construct ordering. There are basically 3 main cases + # 1. Order-independent op (aggregation, cut, rank) with unbound window - no ordering clause needed + # 2. Order-independent op (aggregation, cut, rank) with range window - use ordering clause, ties allowed + # 3. Order-depedenpent op (navigation functions, array_agg) or rows bounds - use total row order to break ties. + if window_spec.is_row_bounded: + if not window_spec.ordering: + # If window spec has following or preceding bounds, we need to apply an unambiguous ordering. + raise ValueError("No ordering provided for ordered analytic function") + order_by = scalar_compiler._convert_row_ordering_to_table_values( + bindings, + window_spec.ordering, + ) + + elif window_spec.is_range_bounded: + order_by = [ + scalar_compiler._convert_range_ordering_to_table_value( + bindings, + window_spec.ordering[0], + ) + ] + # The rest if branches are for unbounded windows + elif window_spec.ordering: + # Unbound grouping window. Suitable for aggregations but not for analytic function application. + order_by = scalar_compiler._convert_row_ordering_to_table_values( + bindings, + window_spec.ordering, + ) + else: + order_by = None + + window = bigframes_vendored.ibis.window(order_by=order_by, group_by=group_by) + if window_spec.bounds is not None: + return _add_boundary(window_spec.bounds, window) + return window + + +def _as_groupable(value: ibis_types.Value): + from bigframes.core.compile.ibis_compiler import scalar_op_registry + + # Some types need to be converted to another type to enable groupby + if value.type().is_float64(): + return value.cast(ibis_dtypes.str) + elif value.type().is_geospatial(): + return typing.cast(ibis_types.GeoSpatialColumn, value).as_binary() + elif value.type().is_json(): + return scalar_op_registry.to_json_string(value) + else: + return value + + +def _to_ibis_boundary( + boundary: Optional[int], +) -> Optional[ibis_expr_window.WindowBoundary]: + if boundary is None: + return None + return ibis_expr_window.WindowBoundary( + abs(boundary), preceding=boundary <= 0 # type:ignore + ) + + +def _add_boundary( + bounds: typing.Union[RowsWindowBounds, RangeWindowBounds], + ibis_window: ibis_expr_builders.LegacyWindowBuilder, +) -> ibis_expr_builders.LegacyWindowBuilder: + if isinstance(bounds, RangeWindowBounds): + return ibis_window.range( + start=_to_ibis_boundary( + None + if bounds.start is None + else bigframes.core.utils.timedelta_to_micros(bounds.start) + ), + end=_to_ibis_boundary( + None + if bounds.end is None + else bigframes.core.utils.timedelta_to_micros(bounds.end) + ), + ) + if isinstance(bounds, RowsWindowBounds): + if bounds.start is not None or bounds.end is not None: + return ibis_window.rows( + start=_to_ibis_boundary(bounds.start), + end=_to_ibis_boundary(bounds.end), + ) + return ibis_window + else: + raise ValueError(f"unrecognized window bounds {bounds}") + + def _map_to_literal( original: ibis_types.Value, literal: ibis_types.Scalar ) -> ibis_types.Column: diff --git a/bigframes/core/compile/default_ordering.py b/bigframes/core/compile/ibis_compiler/default_ordering.py similarity index 95% rename from bigframes/core/compile/default_ordering.py rename to bigframes/core/compile/ibis_compiler/default_ordering.py index 1a1350cfd6..3f2628d10c 100644 --- a/bigframes/core/compile/default_ordering.py +++ b/bigframes/core/compile/ibis_compiler/default_ordering.py @@ -47,10 +47,7 @@ def _convert_to_nonnull_string(column: ibis_types.Value) -> ibis_types.StringVal result = ibis_ops.ToJsonString(column).to_expr() # type: ignore # Escape backslashes and use backslash as delineator escaped = cast( - ibis_types.StringColumn, - result.fill_null(ibis_types.literal("")) - if hasattr(result, "fill_null") - else result.fillna(""), + ibis_types.StringColumn, result.fill_null(ibis_types.literal("")) ).replace( "\\", # type: ignore "\\\\", # type: ignore diff --git a/bigframes/core/compile/ibis_compiler/ibis_compiler.py b/bigframes/core/compile/ibis_compiler/ibis_compiler.py new file mode 100644 index 0000000000..31cd9a0456 --- /dev/null +++ b/bigframes/core/compile/ibis_compiler/ibis_compiler.py @@ -0,0 +1,288 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import dataclasses +import functools +import typing +from typing import cast, Optional + +import bigframes_vendored.ibis.backends.bigquery as ibis_bigquery +import bigframes_vendored.ibis.expr.api as ibis_api +import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes +import bigframes_vendored.ibis.expr.types as ibis_types + +from bigframes import dtypes, operations +from bigframes.core import bq_data, expression, pyarrow_utils +import bigframes.core.compile.compiled as compiled +import bigframes.core.compile.concat as concat_impl +import bigframes.core.compile.configs as configs +import bigframes.core.compile.explode +import bigframes.core.nodes as nodes +import bigframes.core.ordering as bf_ordering +import bigframes.core.rewrite as rewrites + +if typing.TYPE_CHECKING: + import bigframes.core + + +def compile_sql(request: configs.CompileRequest) -> configs.CompileResult: + output_names = tuple((expression.DerefOp(id), id.sql) for id in request.node.ids) + result_node = nodes.ResultNode( + request.node, + output_cols=output_names, + limit=request.peek_count, + ) + if request.sort_rows: + # Can only pullup slice if we are doing ORDER BY in outermost SELECT + # Need to do this before replacing unsupported ops, as that will rewrite slice ops + result_node = rewrites.pull_up_limits(result_node) + result_node = _replace_unsupported_ops(result_node) + # prune before pulling up order to avoid unnnecessary row_number() ops + result_node = cast(nodes.ResultNode, rewrites.column_pruning(result_node)) + result_node = rewrites.defer_order( + result_node, output_hidden_row_keys=request.materialize_all_order_keys + ) + if request.sort_rows: + result_node = cast(nodes.ResultNode, rewrites.column_pruning(result_node)) + sql = compile_result_node(result_node) + return configs.CompileResult( + sql, result_node.schema.to_bigquery(), result_node.order_by + ) + + ordering: Optional[bf_ordering.RowOrdering] = result_node.order_by + result_node = dataclasses.replace(result_node, order_by=None) + result_node = cast(nodes.ResultNode, rewrites.column_pruning(result_node)) + result_node = cast(nodes.ResultNode, rewrites.defer_selection(result_node)) + sql = compile_result_node(result_node) + # Return the ordering iff no extra columns are needed to define the row order + if ordering is not None: + output_order = ( + ordering if ordering.referenced_columns.issubset(result_node.ids) else None + ) + assert (not request.materialize_all_order_keys) or (output_order is not None) + return configs.CompileResult(sql, result_node.schema.to_bigquery(), output_order) + + +def _replace_unsupported_ops(node: nodes.BigFrameNode): + # TODO: Run all replacement rules as single bottom-up pass + node = nodes.bottom_up(node, rewrites.rewrite_slice) + node = nodes.bottom_up(node, rewrites.rewrite_timedelta_expressions) + node = nodes.bottom_up(node, rewrites.rewrite_range_rolling) + return node + + +def compile_result_node(root: nodes.ResultNode) -> str: + return compile_node(root.child).to_sql( + order_by=root.order_by.all_ordering_columns if root.order_by else (), + limit=root.limit, + selections=root.output_cols, + ) + + +# TODO: Remove cache when schema no longer requires compilation to derive schema (and therefor only compiles for execution) +@functools.lru_cache(maxsize=5000) +def compile_node(node: nodes.BigFrameNode) -> compiled.UnorderedIR: + """Compile node into CompileArrayValue. Caches result.""" + return node.reduce_up(lambda node, children: _compile_node(node, *children)) + + +@functools.singledispatch +def _compile_node( + node: nodes.BigFrameNode, *compiled_children: compiled.UnorderedIR +) -> compiled.UnorderedIR: + """Defines transformation but isn't cached, always use compile_node instead""" + raise ValueError(f"Can't compile unrecognized node: {node}") + + +@_compile_node.register +def compile_join( + node: nodes.JoinNode, left: compiled.UnorderedIR, right: compiled.UnorderedIR +): + condition_pairs = tuple( + (left.id.sql, right.id.sql) for left, right in node.conditions + ) + return left.join( + right=right, + type=node.type, + conditions=condition_pairs, + join_nulls=node.joins_nulls, + ) + + +@_compile_node.register +def compile_isin( + node: nodes.InNode, left: compiled.UnorderedIR, right: compiled.UnorderedIR +): + return left.isin_join( + right=right, + indicator_col=node.indicator_col.sql, + conditions=(node.left_col.id.sql, list(node.right_child.ids)[0].sql), + join_nulls=node.joins_nulls, + ) + + +@_compile_node.register +def compile_fromrange( + node: nodes.FromRangeNode, start: compiled.UnorderedIR, end: compiled.UnorderedIR +): + # Both start and end are single elements and do not inherently have an order) + start_table = start._to_ibis_expr() + end_table = end._to_ibis_expr() + + start_column = start_table.schema().names[0] + end_column = end_table.schema().names[0] + + # Perform a cross join to avoid errors + joined_table = start_table.cross_join(end_table) + + labels_array_table = ibis_api.range( + joined_table[start_column], joined_table[end_column] + node.step, node.step + ).name(node.output_id.sql) + labels = ( + typing.cast(ibis_types.ArrayValue, labels_array_table) + .as_table() + .unnest([node.output_id.sql]) + ) + return compiled.UnorderedIR( + labels, + columns=[labels[labels.columns[0]]], + ) + + +@_compile_node.register +def compile_readlocal(node: nodes.ReadLocalNode, *args): + offsets = node.offsets_col.sql if node.offsets_col else None + pa_table = node.local_data_source.data + bq_schema = node.schema.to_bigquery() + + pa_table = pa_table.select([item.source_id for item in node.scan_list.items]) + pa_table = pa_table.rename_columns([item.id.sql for item in node.scan_list.items]) + + if offsets: + pa_table = pyarrow_utils.append_offsets(pa_table, offsets) + return compiled.UnorderedIR.from_polars(pa_table, bq_schema) + + +@_compile_node.register +def compile_readtable(node: nodes.ReadTableNode, *args): + from bigframes.core.compile.ibis_compiler import scalar_op_registry + + ibis_table = _table_to_ibis( + node.source, scan_cols=[col.source_id for col in node.scan_list.items] + ) + + # TODO(b/395912450): Remove workaround solution once b/374784249 got resolved. + for scan_item in node.scan_list.items: + if ( + node.source.schema.get_type(scan_item.source_id) == dtypes.JSON_DTYPE + and ibis_table[scan_item.source_id].type() == ibis_dtypes.string + ): + json_column = scalar_op_registry.parse_json( + ibis_table[scan_item.source_id] + ).name(scan_item.source_id) + ibis_table = ibis_table.mutate(json_column) + + return compiled.UnorderedIR( + ibis_table, + tuple( + ibis_table[scan_item.source_id].name(scan_item.id.sql) + for scan_item in node.scan_list.items + ), + ) + + +def _table_to_ibis( + source: bq_data.BigqueryDataSource, + scan_cols: typing.Sequence[str], +) -> ibis_types.Table: + full_table_name = ( + f"{source.table.project_id}.{source.table.dataset_id}.{source.table.table_id}" + ) + # Physical schema might include unused columns, unsupported datatypes like JSON + physical_schema = ibis_bigquery.BigQuerySchema.to_ibis( + list(source.table.physical_schema) + ) + if source.at_time is not None or source.sql_predicate is not None: + import bigframes.session._io.bigquery + + sql = bigframes.session._io.bigquery.to_query( + full_table_name, + columns=scan_cols, + sql_predicate=source.sql_predicate, + time_travel_timestamp=source.at_time, + ) + return ibis_bigquery.Backend().sql(schema=physical_schema, query=sql) + else: + return ibis_api.table(physical_schema, full_table_name).select(scan_cols) + + +@_compile_node.register +def compile_filter(node: nodes.FilterNode, child: compiled.UnorderedIR): + return child.filter(node.predicate) + + +@_compile_node.register +def compile_selection(node: nodes.SelectionNode, child: compiled.UnorderedIR): + selection = tuple((ref, id.sql) for ref, id in node.input_output_pairs) + return child.selection(selection) + + +@_compile_node.register +def compile_projection(node: nodes.ProjectionNode, child: compiled.UnorderedIR): + projections = ((expr, id.sql) for expr, id in node.assignments) + return child.projection(tuple(projections)) + + +@_compile_node.register +def compile_concat(node: nodes.ConcatNode, *children: compiled.UnorderedIR): + output_ids = [id.sql for id in node.output_ids] + return concat_impl.concat_unordered(children, output_ids) + + +@_compile_node.register +def compile_aggregate(node: nodes.AggregateNode, child: compiled.UnorderedIR): + aggs = tuple((agg, id.sql) for agg, id in node.aggregations) + result = child.aggregate(aggs, node.by_column_ids, order_by=node.order_by) + # TODO: Remove dropna field and use filter node instead + if node.dropna: + for key in node.by_column_ids: + if node.child.field_by_id[key.id].nullable: + result = result.filter(operations.notnull_op.as_expr(key)) + return result + + +@_compile_node.register +def compile_window(node: nodes.WindowOpNode, child: compiled.UnorderedIR): + result = child + for cdef in node.agg_exprs: + result = result.project_window_op( + cdef.expression, # type: ignore + node.window_spec, + cdef.id.sql, + ) + return result + + +@_compile_node.register +def compile_explode(node: nodes.ExplodeNode, child: compiled.UnorderedIR): + offsets_col = node.offsets_col.sql if (node.offsets_col is not None) else None + return bigframes.core.compile.explode.explode_unordered( + child, node.column_ids, offsets_col + ) + + +@_compile_node.register +def compile_random_sample(node: nodes.RandomSampleNode, child: compiled.UnorderedIR): + return child._uniform_sampling(node.fraction) diff --git a/bigframes/core/compile/ibis_compiler/operations/__init__.py b/bigframes/core/compile/ibis_compiler/operations/__init__.py new file mode 100644 index 0000000000..9d9f3849ab --- /dev/null +++ b/bigframes/core/compile/ibis_compiler/operations/__init__.py @@ -0,0 +1,21 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Operation implementations for the Ibis-based compiler. + +This directory structure should reflect the same layout as the +`bigframes/operations` directory where the operations are defined. + +Prefer a few ops per file to keep file sizes manageable for text editors and LLMs. +""" diff --git a/bigframes/core/compile/ibis_compiler/operations/generic_ops.py b/bigframes/core/compile/ibis_compiler/operations/generic_ops.py new file mode 100644 index 0000000000..78f6a0c4de --- /dev/null +++ b/bigframes/core/compile/ibis_compiler/operations/generic_ops.py @@ -0,0 +1,38 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +BigFrames -> Ibis compilation for the operations in bigframes.operations.generic_ops. + +Please keep implementations in sequential order by op name. +""" + +from __future__ import annotations + +from bigframes_vendored.ibis.expr import types as ibis_types + +from bigframes.core.compile.ibis_compiler import scalar_op_compiler +from bigframes.operations import generic_ops + +register_unary_op = scalar_op_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(generic_ops.notnull_op) +def notnull_op_impl(x: ibis_types.Value): + return x.notnull() + + +@register_unary_op(generic_ops.isnull_op) +def isnull_op_impl(x: ibis_types.Value): + return x.isnull() diff --git a/bigframes/core/compile/ibis_compiler/operations/geo_ops.py b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py new file mode 100644 index 0000000000..0ca69726ff --- /dev/null +++ b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py @@ -0,0 +1,204 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import cast + +from bigframes_vendored import ibis +from bigframes_vendored.ibis.expr import types as ibis_types +import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes +import bigframes_vendored.ibis.expr.operations.geospatial as ibis_geo +import bigframes_vendored.ibis.expr.operations.udf as ibis_udf + +from bigframes.core.compile.ibis_compiler import scalar_op_compiler +from bigframes.operations import geo_ops as ops + +register_unary_op = scalar_op_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_op_compiler.scalar_op_compiler.register_binary_op + + +# Geo Ops +@register_unary_op(ops.geo_area_op) +def geo_area_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).area() + + +@register_unary_op(ops.geo_st_astext_op) +def geo_st_astext_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).as_text() + + +@register_unary_op(ops.geo_st_boundary_op, pass_op=False) +def geo_st_boundary_op_impl(x: ibis_types.Value): + return st_boundary(x) + + +@register_unary_op(ops.GeoStBufferOp, pass_op=True) +def geo_st_buffer_op_impl(x: ibis_types.Value, op: ops.GeoStBufferOp): + return st_buffer( + x, + op.buffer_radius, + op.num_seg_quarter_circle, + op.use_spheroid, + ) + + +@register_unary_op(ops.geo_st_centroid_op, pass_op=False) +def geo_st_centroid_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).centroid() + + +@register_unary_op(ops.geo_st_convexhull_op, pass_op=False) +def geo_st_convexhull_op_impl(x: ibis_types.Value): + return st_convexhull(x) + + +@register_binary_op(ops.geo_st_difference_op, pass_op=False) +def geo_st_difference_op_impl(x: ibis_types.Value, y: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).difference( + cast(ibis_types.GeoSpatialValue, y) + ) + + +@register_binary_op(ops.GeoStDistanceOp, pass_op=True) +def geo_st_distance_op_impl( + x: ibis_types.Value, y: ibis_types.Value, op: ops.GeoStDistanceOp +): + return st_distance(x, y, op.use_spheroid) + + +@register_unary_op(ops.geo_st_geogfromtext_op) +def geo_st_geogfromtext_op_impl(x: ibis_types.Value): + # Ibis doesn't seem to provide a dedicated method to cast from string to geography, + # so we use a BigQuery scalar function, st_geogfromtext(), directly. + return st_geogfromtext(x) + + +@register_binary_op(ops.geo_st_geogpoint_op, pass_op=False) +def geo_st_geogpoint_op_impl(x: ibis_types.Value, y: ibis_types.Value): + return cast(ibis_types.NumericValue, x).point(cast(ibis_types.NumericValue, y)) + + +@register_binary_op(ops.geo_st_intersection_op, pass_op=False) +def geo_st_intersection_op_impl(x: ibis_types.Value, y: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).intersection( + cast(ibis_types.GeoSpatialValue, y) + ) + + +@register_unary_op(ops.geo_st_isclosed_op, pass_op=False) +def geo_st_isclosed_op_impl(x: ibis_types.Value): + return st_isclosed(x) + + +@register_unary_op(ops.GeoStRegionStatsOp, pass_op=True) +def geo_st_regionstats_op_impl( + geography: ibis_types.Value, + op: ops.GeoStRegionStatsOp, +): + if op.band: + band = ibis.literal(op.band, type=ibis_dtypes.string()) + else: + band = None + + if op.include: + include = ibis.literal(op.include, type=ibis_dtypes.string()) + else: + include = None + + if op.options: + options = ibis.literal(op.options, type=ibis_dtypes.json()) + else: + options = None + + return ibis_geo.GeoRegionStats( + arg=geography, # type: ignore + raster_id=ibis.literal(op.raster_id, type=ibis_dtypes.string()), # type: ignore + band=band, # type: ignore + include=include, # type: ignore + options=options, # type: ignore + ).to_expr() + + +@register_unary_op(ops.GeoStSimplifyOp, pass_op=True) +def st_simplify_op_impl(x: ibis_types.Value, op: ops.GeoStSimplifyOp): + x = cast(ibis_types.GeoSpatialValue, x) + return st_simplify(x, op.tolerance_meters) + + +@register_unary_op(ops.geo_x_op) +def geo_x_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).x() + + +@register_unary_op(ops.GeoStLengthOp, pass_op=True) +def geo_length_op_impl(x: ibis_types.Value, op: ops.GeoStLengthOp): + # Call the st_length UDF defined in this file (or imported) + return st_length(x, op.use_spheroid) + + +@register_unary_op(ops.geo_y_op) +def geo_y_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).y() + + +@ibis_udf.scalar.builtin +def st_convexhull(x: ibis_dtypes.geography) -> ibis_dtypes.geography: # type: ignore + """ST_CONVEXHULL""" + ... + + +@ibis_udf.scalar.builtin +def st_geogfromtext(a: str) -> ibis_dtypes.geography: # type: ignore + """Convert string to geography.""" + + +@ibis_udf.scalar.builtin +def st_boundary(a: ibis_dtypes.geography) -> ibis_dtypes.geography: # type: ignore + """Find the boundary of a geography.""" + + +@ibis_udf.scalar.builtin +def st_buffer( + geography: ibis_dtypes.geography, # type: ignore + buffer_radius: ibis_dtypes.Float64, + num_seg_quarter_circle: ibis_dtypes.Float64, + use_spheroid: ibis_dtypes.Boolean, +) -> ibis_dtypes.geography: # type: ignore + ... + + +@ibis_udf.scalar.builtin +def st_distance(a: ibis_dtypes.geography, b: ibis_dtypes.geography, use_spheroid: bool) -> ibis_dtypes.float: # type: ignore + """Convert string to geography.""" + + +@ibis_udf.scalar.builtin +def st_length(geog: ibis_dtypes.geography, use_spheroid: bool) -> ibis_dtypes.float: # type: ignore + """ST_LENGTH BQ builtin. This body is never executed.""" + pass + + +@ibis_udf.scalar.builtin +def st_isclosed(a: ibis_dtypes.geography) -> ibis_dtypes.boolean: # type: ignore + """Checks if a geography is closed.""" + + +@ibis_udf.scalar.builtin +def st_simplify( + geography: ibis_dtypes.geography, # type: ignore + tolerance_meters: ibis_dtypes.float, # type: ignore +) -> ibis_dtypes.geography: # type: ignore + ... diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py b/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py new file mode 100644 index 0000000000..8a027ca296 --- /dev/null +++ b/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py @@ -0,0 +1,280 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""To avoid circular imports, this module should _not_ depend on any ops.""" + +from __future__ import annotations + +import functools +import typing +from typing import TYPE_CHECKING + +import bigframes_vendored.ibis +import bigframes_vendored.ibis.expr.types as ibis_types + +from bigframes.core import agg_expressions, ordering +import bigframes.core.compile.ibis_types +import bigframes.core.expression as ex +from bigframes.operations import numeric_ops + +if TYPE_CHECKING: + import bigframes.operations as ops + + +class ExpressionCompiler: + # Mapping of operation name to implemenations + _registry: dict[ + str, + typing.Callable[ + [typing.Sequence[ibis_types.Value], ops.RowOp], ibis_types.Value + ], + ] = {} + + @functools.singledispatchmethod + def compile_expression( + self, + expression: ex.Expression, + bindings: typing.Dict[str, ibis_types.Value], + ) -> ibis_types.Value: + raise NotImplementedError(f"Unrecognized expression: {expression}") + + @compile_expression.register + def _( + self, + expression: ex.ScalarConstantExpression, + bindings: typing.Dict[str, ibis_types.Value], + ) -> ibis_types.Value: + return bigframes.core.compile.ibis_types.literal_to_ibis_scalar( + expression.value, expression.dtype + ) + + @compile_expression.register + def _( + self, + expression: ex.DerefOp, + bindings: typing.Dict[str, ibis_types.Value], + ) -> ibis_types.Value: + if expression.id.sql not in bindings: + raise ValueError(f"Could not resolve unbound variable {expression.id}") + else: + return bindings[expression.id.sql] + + @compile_expression.register + def _( + self, + expression: agg_expressions.WindowExpression, + bindings: typing.Dict[str, ibis_types.Value], + ) -> ibis_types.Value: + import bigframes.core.compile.ibis_compiler.aggregate_compiler as agg_compile + + return agg_compile.compile_analytic( + expression.analytic_expr, expression.window, bindings + ) + + @compile_expression.register + def _( + self, + expression: ex.OpExpression, + bindings: typing.Dict[str, ibis_types.Value], + ) -> ibis_types.Value: + inputs = [ + self.compile_expression(sub_expr, bindings) + for sub_expr in expression.inputs + ] + return self.compile_row_op(expression.op, inputs) + + def compile_row_op( + self, op: ops.RowOp, inputs: typing.Sequence[ibis_types.Value] + ) -> ibis_types.Value: + impl = self._registry[op.name] + return impl(inputs, op) + + def register_unary_op( + self, + op_ref: typing.Union[ops.UnaryOp, type[ops.UnaryOp]], + pass_op: bool = False, + ): + """ + Decorator to register a unary op implementation. + + Args: + op_ref (UnaryOp or UnaryOp type): + Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., ibis_types.Value]): + def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): + if pass_op: + return impl(args[0], op) + else: + return impl(args[0]) + + self._register(key, normalized_impl) + return impl + + return decorator + + def register_binary_op( + self, + op_ref: typing.Union[ops.BinaryOp, type[ops.BinaryOp]], + pass_op: bool = False, + ): + """ + Decorator to register a binary op implementation. + + Args: + op_ref (BinaryOp or BinaryOp type): + Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., ibis_types.Value]): + def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): + if pass_op: + return impl(args[0], args[1], op) + else: + return impl(args[0], args[1]) + + self._register(key, normalized_impl) + return impl + + return decorator + + def register_ternary_op( + self, op_ref: typing.Union[ops.TernaryOp, type[ops.TernaryOp]] + ): + """ + Decorator to register a ternary op implementation. + + Args: + op_ref (TernaryOp or TernaryOp type): + Class or instance of operator that is implemented by the decorated function. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., ibis_types.Value]): + def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): + return impl(args[0], args[1], args[2]) + + self._register(key, normalized_impl) + return impl + + return decorator + + def register_nary_op( + self, op_ref: typing.Union[ops.NaryOp, type[ops.NaryOp]], pass_op: bool = False + ): + """ + Decorator to register a nary op implementation. + + Args: + op_ref (NaryOp or NaryOp type): + Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., ibis_types.Value]): + def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): + if pass_op: + return impl(*args, op=op) + else: + return impl(*args) + + self._register(key, normalized_impl) + return impl + + return decorator + + def _register( + self, + op_name: str, + impl: typing.Callable[ + [typing.Sequence[ibis_types.Value], ops.RowOp], ibis_types.Value + ], + ): + if op_name in self._registry: + raise ValueError(f"Operation name {op_name} already registered") + self._registry[op_name] = impl + + def _convert_row_ordering_to_table_values( + self, + value_lookup: typing.Mapping[str, ibis_types.Value], + ordering_columns: typing.Sequence[ordering.OrderingExpression], + ) -> typing.Sequence[ibis_types.Value]: + column_refs = ordering_columns + ordering_values = [] + for ordering_col in column_refs: + expr = self.compile_expression(ordering_col.scalar_expression, value_lookup) + ordering_value = ( + bigframes_vendored.ibis.asc(expr) # type: ignore + if ordering_col.direction.is_ascending + else bigframes_vendored.ibis.desc(expr) # type: ignore + ) + # Bigquery SQL considers NULLS to be "smallest" values, but we need to override in these cases. + if (not ordering_col.na_last) and (not ordering_col.direction.is_ascending): + # Force nulls to be first + is_null_val = typing.cast(ibis_types.Column, expr.isnull()) + ordering_values.append(bigframes_vendored.ibis.desc(is_null_val)) + elif (ordering_col.na_last) and (ordering_col.direction.is_ascending): + # Force nulls to be last + is_null_val = typing.cast(ibis_types.Column, expr.isnull()) + ordering_values.append(bigframes_vendored.ibis.asc(is_null_val)) + ordering_values.append(ordering_value) + return ordering_values + + def _convert_range_ordering_to_table_value( + self, + value_lookup: typing.Mapping[str, ibis_types.Value], + ordering_column: ordering.OrderingExpression, + ) -> ibis_types.Value: + """Converts the ordering for range windows to Ibis references. + + Note that this method is different from `_convert_row_ordering_to_table_values` in + that it does not arrange null values. There are two reasons: + 1. Manipulating null positions requires more than one ordering key, which is forbidden + by SQL window syntax for range rolling. + 2. Pandas does not allow range rolling on timeseries with nulls. + + Therefore, we opt for the simplest approach here: generate the simplest SQL and follow + the BigQuery engine behavior. + """ + expr = self.compile_expression(ordering_column.scalar_expression, value_lookup) + + if ordering_column.direction.is_ascending: + return bigframes_vendored.ibis.asc(expr) # type: ignore + return bigframes_vendored.ibis.desc(expr) # type: ignore + + +# Singleton compiler +scalar_op_compiler = ExpressionCompiler() + + +@scalar_op_compiler.register_unary_op(numeric_ops.isnan_op) +def isnanornull(arg): + return arg.isnan() + + +@scalar_op_compiler.register_unary_op(numeric_ops.isfinite_op) +def isfinite(arg): + return arg.isinf().negate() & arg.isnan().negate() diff --git a/bigframes/core/compile/scalar_op_compiler.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py similarity index 78% rename from bigframes/core/compile/scalar_op_compiler.py rename to bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 7111406646..91bbfbfbcf 100644 --- a/bigframes/core/compile/scalar_op_compiler.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -17,19 +17,22 @@ import functools import typing -import bigframes_vendored.constants as constants +from bigframes_vendored import ibis import bigframes_vendored.ibis.expr.api as ibis_api import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes +import bigframes_vendored.ibis.expr.operations.ai_ops as ai_ops import bigframes_vendored.ibis.expr.operations.generic as ibis_generic import bigframes_vendored.ibis.expr.operations.udf as ibis_udf import bigframes_vendored.ibis.expr.types as ibis_types import numpy as np import pandas as pd -import bigframes.core.compile.default_ordering +from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS +import bigframes.core.compile.ibis_compiler.default_ordering +from bigframes.core.compile.ibis_compiler.scalar_op_compiler import ( + scalar_op_compiler, # TODO(tswast): avoid import of variables +) import bigframes.core.compile.ibis_types -import bigframes.core.expression as ex -import bigframes.dtypes import bigframes.operations as ops _ZERO = typing.cast(ibis_types.NumericValue, ibis_types.literal(0)) @@ -50,209 +53,8 @@ ) _OBJ_REF_IBIS_DTYPE = ibis_dtypes.Struct.from_tuples(_OBJ_REF_STRUCT_SCHEMA) # type: ignore -# Datetime constants -UNIT_TO_US_CONVERSION_FACTORS = { - "W": 7 * 24 * 60 * 60 * 1000 * 1000, - "d": 24 * 60 * 60 * 1000 * 1000, - "D": 24 * 60 * 60 * 1000 * 1000, - "h": 60 * 60 * 1000 * 1000, - "m": 60 * 1000 * 1000, - "s": 1000 * 1000, - "ms": 1000, - "us": 1, - "ns": 1e-3, -} - - -class ScalarOpCompiler: - # Mapping of operation name to implemenations - _registry: dict[ - str, - typing.Callable[ - [typing.Sequence[ibis_types.Value], ops.RowOp], ibis_types.Value - ], - ] = {} - - @functools.singledispatchmethod - def compile_expression( - self, - expression: ex.Expression, - bindings: typing.Dict[str, ibis_types.Value], - ) -> ibis_types.Value: - raise NotImplementedError(f"Unrecognized expression: {expression}") - - @compile_expression.register - def _( - self, - expression: ex.ScalarConstantExpression, - bindings: typing.Dict[str, ibis_types.Value], - ) -> ibis_types.Value: - return bigframes.core.compile.ibis_types.literal_to_ibis_scalar( - expression.value, expression.dtype - ) - - @compile_expression.register - def _( - self, - expression: ex.DerefOp, - bindings: typing.Dict[str, ibis_types.Value], - ) -> ibis_types.Value: - if expression.id.sql not in bindings: - raise ValueError(f"Could not resolve unbound variable {expression.id}") - else: - return bindings[expression.id.sql] - - @compile_expression.register - def _( - self, - expression: ex.OpExpression, - bindings: typing.Dict[str, ibis_types.Value], - ) -> ibis_types.Value: - inputs = [ - self.compile_expression(sub_expr, bindings) - for sub_expr in expression.inputs - ] - return self.compile_row_op(expression.op, inputs) - - def compile_row_op( - self, op: ops.RowOp, inputs: typing.Sequence[ibis_types.Value] - ) -> ibis_types.Value: - impl = self._registry[op.name] - return impl(inputs, op) - - def register_unary_op( - self, - op_ref: typing.Union[ops.UnaryOp, type[ops.UnaryOp]], - pass_op: bool = False, - ): - """ - Decorator to register a unary op implementation. - - Args: - op_ref (UnaryOp or UnaryOp type): - Class or instance of operator that is implemented by the decorated function. - pass_op (bool): - Set to true if implementation takes the operator object as the last argument. - This is needed for parameterized ops where parameters are part of op object. - """ - key = typing.cast(str, op_ref.name) - - def decorator(impl: typing.Callable[..., ibis_types.Value]): - def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): - if pass_op: - return impl(args[0], op) - else: - return impl(args[0]) - - self._register(key, normalized_impl) - return impl - - return decorator - - def register_binary_op( - self, - op_ref: typing.Union[ops.BinaryOp, type[ops.BinaryOp]], - pass_op: bool = False, - ): - """ - Decorator to register a binary op implementation. - - Args: - op_ref (BinaryOp or BinaryOp type): - Class or instance of operator that is implemented by the decorated function. - pass_op (bool): - Set to true if implementation takes the operator object as the last argument. - This is needed for parameterized ops where parameters are part of op object. - """ - key = typing.cast(str, op_ref.name) - - def decorator(impl: typing.Callable[..., ibis_types.Value]): - def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): - if pass_op: - return impl(args[0], args[1], op) - else: - return impl(args[0], args[1]) - - self._register(key, normalized_impl) - return impl - - return decorator - - def register_ternary_op( - self, op_ref: typing.Union[ops.TernaryOp, type[ops.TernaryOp]] - ): - """ - Decorator to register a ternary op implementation. - - Args: - op_ref (TernaryOp or TernaryOp type): - Class or instance of operator that is implemented by the decorated function. - """ - key = typing.cast(str, op_ref.name) - - def decorator(impl: typing.Callable[..., ibis_types.Value]): - def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): - return impl(args[0], args[1], args[2]) - - self._register(key, normalized_impl) - return impl - - return decorator - - def register_nary_op( - self, op_ref: typing.Union[ops.NaryOp, type[ops.NaryOp]], pass_op: bool = False - ): - """ - Decorator to register a nary op implementation. - - Args: - op_ref (NaryOp or NaryOp type): - Class or instance of operator that is implemented by the decorated function. - pass_op (bool): - Set to true if implementation takes the operator object as the last argument. - This is needed for parameterized ops where parameters are part of op object. - """ - key = typing.cast(str, op_ref.name) - - def decorator(impl: typing.Callable[..., ibis_types.Value]): - def normalized_impl(args: typing.Sequence[ibis_types.Value], op: ops.RowOp): - if pass_op: - return impl(*args, op=op) - else: - return impl(*args) - - self._register(key, normalized_impl) - return impl - - return decorator - - def _register( - self, - op_name: str, - impl: typing.Callable[ - [typing.Sequence[ibis_types.Value], ops.RowOp], ibis_types.Value - ], - ): - if op_name in self._registry: - raise ValueError(f"Operation name {op_name} already registered") - self._registry[op_name] = impl - - -# Singleton compiler -scalar_op_compiler = ScalarOpCompiler() - ### Unary Ops -@scalar_op_compiler.register_unary_op(ops.isnull_op) -def isnull_op_impl(x: ibis_types.Value): - return x.isnull() - - -@scalar_op_compiler.register_unary_op(ops.notnull_op) -def notnull_op_impl(x: ibis_types.Value): - return x.notnull() - - @scalar_op_compiler.register_unary_op(ops.hash_op) def hash_op_impl(x: ibis_types.Value): return typing.cast(ibis_types.IntegerValue, x).hash() @@ -468,9 +270,19 @@ def upper_op_impl(x: ibis_types.Value): return typing.cast(ibis_types.StringValue, x).upper() -@scalar_op_compiler.register_unary_op(ops.strip_op) -def strip_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.StringValue, x).strip() +@scalar_op_compiler.register_unary_op(ops.StrLstripOp, pass_op=True) +def str_lstrip_op_impl(x: ibis_types.Value, op: ops.StrStripOp): + return str_lstrip_op(x, to_strip=op.to_strip) + + +@scalar_op_compiler.register_unary_op(ops.StrRstripOp, pass_op=True) +def str_rstrip_op_impl(x: ibis_types.Value, op: ops.StrRstripOp): + return str_rstrip_op(x, to_strip=op.to_strip) + + +@scalar_op_compiler.register_unary_op(ops.StrStripOp, pass_op=True) +def str_strip_op_impl(x: ibis_types.Value, op: ops.StrStripOp): + return str_strip_op(x, to_strip=op.to_strip) @scalar_op_compiler.register_unary_op(ops.isnumeric_op) @@ -490,9 +302,9 @@ def isalpha_op_impl(x: ibis_types.Value): @scalar_op_compiler.register_unary_op(ops.isdigit_op) def isdigit_op_impl(x: ibis_types.Value): - # Based on docs, should include superscript/subscript-ed numbers - # Tests however pass only when set to Nd unicode class - return typing.cast(ibis_types.StringValue, x).re_search(r"^(\p{Nd})+$") + return typing.cast(ibis_types.StringValue, x).re_search( + r"^[\p{Nd}\x{00B9}\x{00B2}\x{00B3}\x{2070}\x{2074}-\x{2079}\x{2080}-\x{2089}]+$" + ) @scalar_op_compiler.register_unary_op(ops.isdecimal_op) @@ -531,16 +343,6 @@ def isupper_op_impl(x: ibis_types.Value): ).re_search(r"\p{Ll}|\p{Lt}") -@scalar_op_compiler.register_unary_op(ops.rstrip_op) -def rstrip_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.StringValue, x).rstrip() - - -@scalar_op_compiler.register_unary_op(ops.lstrip_op) -def lstrip_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.StringValue, x).lstrip() - - @scalar_op_compiler.register_unary_op(ops.capitalize_op) def capitalize_op_impl(x: ibis_types.Value): return typing.cast(ibis_types.StringValue, x).capitalize() @@ -679,6 +481,22 @@ def date_op_impl(x: ibis_types.Value): return typing.cast(ibis_types.TimestampValue, x).date() +@scalar_op_compiler.register_unary_op(ops.iso_day_op) +def iso_day_op_impl(x: ibis_types.Value): + # Plus 1 because iso day of week uses 1-based indexing + return dayofweek_op_impl(x) + 1 + + +@scalar_op_compiler.register_unary_op(ops.iso_week_op) +def iso_week_op_impl(x: ibis_types.Value): + return typing.cast(ibis_types.TimestampValue, x).week_of_year() + + +@scalar_op_compiler.register_unary_op(ops.iso_year_op) +def iso_year_op_impl(x: ibis_types.Value): + return typing.cast(ibis_types.TimestampValue, x).iso_year() + + @scalar_op_compiler.register_unary_op(ops.dayofweek_op) def dayofweek_op_impl(x: ibis_types.Value): return ( @@ -688,6 +506,13 @@ def dayofweek_op_impl(x: ibis_types.Value): ) +@scalar_op_compiler.register_unary_op(ops.dayofyear_op) +def dayofyear_op_impl(x: ibis_types.Value): + return ( + typing.cast(ibis_types.TimestampValue, x).day_of_year().cast(ibis_dtypes.int64) + ) + + @scalar_op_compiler.register_unary_op(ops.hour_op) def hour_op_impl(x: ibis_types.Value): return typing.cast(ibis_types.TimestampValue, x).hour().cast(ibis_dtypes.int64) @@ -752,6 +577,21 @@ def timestamp_sub_op_impl(x: ibis_types.TimestampValue, y: ibis_types.IntegerVal return x - y.to_interval("us") +@scalar_op_compiler.register_binary_op(ops.date_diff_op) +def date_diff_op_impl(x: ibis_types.DateValue, y: ibis_types.DateValue): + return x.delta(y, "day") * int(UNIT_TO_US_CONVERSION_FACTORS["d"]) # type: ignore + + +@scalar_op_compiler.register_binary_op(ops.date_add_op) +def date_add_op_impl(x: ibis_types.DateValue, y: ibis_types.IntegerValue): + return x.cast(ibis_dtypes.timestamp()) + y.to_interval("us") # type: ignore + + +@scalar_op_compiler.register_binary_op(ops.date_sub_op) +def date_sub_op_impl(x: ibis_types.DateValue, y: ibis_types.IntegerValue): + return x.cast(ibis_dtypes.timestamp()) - y.to_interval("us") # type: ignore + + @scalar_op_compiler.register_unary_op(ops.FloorDtOp, pass_op=True) def floor_dt_op_impl(x: ibis_types.Value, op: ops.FloorDtOp): supported_freqs = ["Y", "Q", "M", "W", "D", "h", "min", "s", "ms", "us", "ns"] @@ -997,41 +837,6 @@ def normalize_op_impl(x: ibis_types.Value): return result.cast(result_type) -# Geo Ops -@scalar_op_compiler.register_unary_op(ops.geo_x_op) -def geo_x_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).x() - - -@scalar_op_compiler.register_unary_op(ops.geo_y_op) -def geo_y_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).y() - - -@scalar_op_compiler.register_unary_op(ops.geo_area_op) -def geo_area_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).area() - - -@scalar_op_compiler.register_unary_op(ops.geo_st_astext_op) -def geo_st_astext_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).as_text() - - -@scalar_op_compiler.register_unary_op(ops.geo_st_geogfromtext_op) -def geo_st_geogfromtext_op_impl(x: ibis_types.Value): - # Ibis doesn't seem to provide a dedicated method to cast from string to geography, - # so we use a BigQuery scalar function, st_geogfromtext(), directly. - return st_geogfromtext(x) - - -@scalar_op_compiler.register_binary_op(ops.geo_st_geogpoint_op, pass_op=False) -def geo_st_geogpoint_op_impl(x: ibis_types.Value, y: ibis_types.Value): - return typing.cast(ibis_types.NumericValue, x).point( - typing.cast(ibis_types.NumericValue, y) - ) - - # Parameterized ops @scalar_op_compiler.register_unary_op(ops.StructFieldOp, pass_op=True) def struct_field_op_impl(x: ibis_types.Value, op: ops.StructFieldOp): @@ -1112,6 +917,35 @@ def astype_op_impl(x: ibis_types.Value, op: ops.AsTypeOp): elif to_type == ibis_dtypes.time: return x_converted.time() + if to_type == ibis_dtypes.json: + if x.type() == ibis_dtypes.string: + return parse_json_in_safe(x) if op.safe else parse_json(x) + if x.type() == ibis_dtypes.bool: + x_bool = typing.cast( + ibis_types.StringValue, + bigframes.core.compile.ibis_types.cast_ibis_value( + x, ibis_dtypes.string, safe=op.safe + ), + ).lower() + return parse_json_in_safe(x_bool) if op.safe else parse_json(x_bool) + if x.type() in (ibis_dtypes.int64, ibis_dtypes.float64): + x_str = bigframes.core.compile.ibis_types.cast_ibis_value( + x, ibis_dtypes.string, safe=op.safe + ) + return parse_json_in_safe(x_str) if op.safe else parse_json(x_str) + + if x.type() == ibis_dtypes.json: + if to_type == ibis_dtypes.int64: + return cast_json_to_int64_in_safe(x) if op.safe else cast_json_to_int64(x) + if to_type == ibis_dtypes.float64: + return ( + cast_json_to_float64_in_safe(x) if op.safe else cast_json_to_float64(x) + ) + if to_type == ibis_dtypes.bool: + return cast_json_to_bool_in_safe(x) if op.safe else cast_json_to_bool(x) + if to_type == ibis_dtypes.string: + return cast_json_to_string_in_safe(x) if op.safe else cast_json_to_string(x) + # TODO: either inline this function, or push rest of this op into the function return bigframes.core.compile.ibis_types.cast_ibis_value(x, to_type, safe=op.safe) @@ -1138,7 +972,7 @@ def isin_op_impl(x: ibis_types.Value, op: ops.IsInOp): if op.match_nulls and contains_nulls: return x.isnull() | x.isin(matchable_ibis_values) else: - return x.isin(matchable_ibis_values) + return x.isin(matchable_ibis_values).fill_null(ibis.literal(False)) @scalar_op_compiler.register_unary_op(ops.ToDatetimeOp, pass_op=True) @@ -1193,19 +1027,64 @@ def timedelta_floor_op_impl(x: ibis_types.NumericValue): @scalar_op_compiler.register_unary_op(ops.RemoteFunctionOp, pass_op=True) def remote_function_op_impl(x: ibis_types.Value, op: ops.RemoteFunctionOp): - ibis_node = getattr(op.func, "ibis_node", None) - if ibis_node is None: - raise TypeError( - f"only a bigframes remote function is supported as a callable. {constants.FEEDBACK_LINK}" - ) - x_transformed = ibis_node(x) + udf_sig = op.function_def.signature + ibis_py_sig = (udf_sig.py_input_types, udf_sig.py_output_type) + + @ibis_udf.scalar.builtin( + name=str(op.function_def.routine_ref), signature=ibis_py_sig + ) + def udf(input): + ... + + x_transformed = udf(x) if not op.apply_on_null: - x_transformed = ibis_api.case().when(x.isnull(), x).else_(x_transformed).end() + return ibis_api.case().when(x.isnull(), x).else_(x_transformed).end() return x_transformed +@scalar_op_compiler.register_binary_op(ops.BinaryRemoteFunctionOp, pass_op=True) +def binary_remote_function_op_impl( + x: ibis_types.Value, y: ibis_types.Value, op: ops.BinaryRemoteFunctionOp +): + udf_sig = op.function_def.signature + ibis_py_sig = (udf_sig.py_input_types, udf_sig.py_output_type) + + @ibis_udf.scalar.builtin( + name=str(op.function_def.routine_ref), signature=ibis_py_sig + ) + def udf(input1, input2): + ... + + x_transformed = udf(x, y) + return x_transformed + + +@scalar_op_compiler.register_nary_op(ops.NaryRemoteFunctionOp, pass_op=True) +def nary_remote_function_op_impl( + *operands: ibis_types.Value, op: ops.NaryRemoteFunctionOp +): + udf_sig = op.function_def.signature + ibis_py_sig = (udf_sig.py_input_types, udf_sig.py_output_type) + arg_names = tuple(arg.name for arg in udf_sig.input_types) + + @ibis_udf.scalar.builtin( + name=str(op.function_def.routine_ref), + signature=ibis_py_sig, + param_name_overrides=arg_names, + ) + def udf(*inputs): + ... + + result = udf(*operands) + return result + + @scalar_op_compiler.register_unary_op(ops.MapOp, pass_op=True) def map_op_impl(x: ibis_types.Value, op: ops.MapOp): + # this should probably be handled by a rewriter + if len(op.mappings) == 0: + return x + case = ibis_api.case() for mapping in op.mappings: case = case.when(x == mapping[0], mapping[1]) @@ -1236,6 +1115,35 @@ def array_slice_op_impl(x: ibis_types.Value, op: ops.ArraySliceOp): return res +@scalar_op_compiler.register_nary_op(ops.ToArrayOp, pass_op=False) +def to_arry_op_impl(*values: ibis_types.Value): + do_upcast_bool = any(t.type().is_numeric() for t in values) + if do_upcast_bool: + values = tuple( + val.cast(ibis_dtypes.int64) if val.type().is_boolean() else val + for val in values + ) + return ibis_api.array(values) + + +@scalar_op_compiler.register_unary_op(ops.ArrayReduceOp, pass_op=True) +def array_reduce_op_impl(x: ibis_types.Value, op: ops.ArrayReduceOp): + import bigframes.core.compile.ibis_compiler.aggregate_compiler as agg_compilers + + if op.aggregation.order_independent: + return typing.cast(ibis_types.ArrayValue, x).reduce( + lambda arr_vals: agg_compilers.compile_unary_agg( + op.aggregation, typing.cast(ibis_types.Column, arr_vals) + ) + ) + else: + return typing.cast(ibis_types.ArrayValue, x).reduce( + lambda arr_vals: agg_compilers.compile_ordered_unary_agg( + op.aggregation, typing.cast(ibis_types.Column, arr_vals) + ) + ) + + # JSON Ops @scalar_op_compiler.register_binary_op(ops.JSONSet, pass_op=True) def json_set_op_impl(x: ibis_types.Value, y: ibis_types.Value, op: ops.JSONSet): @@ -1275,14 +1183,45 @@ def json_extract_string_array_op_impl( return json_extract_string_array(json_obj=x, json_path=op.json_path) +@scalar_op_compiler.register_unary_op(ops.JSONQuery, pass_op=True) +def json_query_op_impl(x: ibis_types.Value, op: ops.JSONQuery): + # Define a user-defined function whose returned type is dynamically matching the input. + def json_query(json_or_json_string, json_path: ibis_dtypes.str): # type: ignore + """Extracts a JSON value and converts it to a SQL JSON-formatted STRING or JSON value.""" + ... + + return_type = x.type() + json_query.__annotations__["return"] = return_type + json_query_op = ibis_udf.scalar.builtin(json_query) + return json_query_op(json_or_json_string=x, json_path=op.json_path) + + +@scalar_op_compiler.register_unary_op(ops.JSONQueryArray, pass_op=True) +def json_query_array_op_impl(x: ibis_types.Value, op: ops.JSONQueryArray): + # Define a user-defined function whose returned type is dynamically matching the input. + def json_query_array(json_or_json_string, json_path: ibis_dtypes.str): # type: ignore + """Extracts a JSON value and converts it to a SQL JSON-formatted STRING or JSON value.""" + ... + + return_type = x.type() + json_query_array.__annotations__["return"] = ibis_dtypes.Array[return_type] # type: ignore + json_query_op = ibis_udf.scalar.builtin(json_query_array) + return json_query_op(json_or_json_string=x, json_path=op.json_path) + + @scalar_op_compiler.register_unary_op(ops.ParseJSON, pass_op=True) def parse_json_op_impl(x: ibis_types.Value, op: ops.ParseJSON): return parse_json(json_str=x) +@scalar_op_compiler.register_unary_op(ops.ToJSON) +def to_json_op_impl(json_obj: ibis_types.Value): + return to_json(json_obj=json_obj) + + @scalar_op_compiler.register_unary_op(ops.ToJSONString) -def to_json_string_op_impl(json_obj: ibis_types.Value): - return to_json_string(json_obj=json_obj) +def to_json_string_op_impl(x: ibis_types.Value): + return to_json_string(value=x) @scalar_op_compiler.register_unary_op(ops.JSONValue, pass_op=True) @@ -1290,6 +1229,16 @@ def json_value_op_impl(x: ibis_types.Value, op: ops.JSONValue): return json_value(json_obj=x, json_path=op.json_path) +@scalar_op_compiler.register_unary_op(ops.JSONValueArray, pass_op=True) +def json_value_array_op_impl(x: ibis_types.Value, op: ops.JSONValueArray): + return json_value_array(json_obj=x, json_path=op.json_path) + + +@scalar_op_compiler.register_unary_op(ops.JSONKeys, pass_op=True) +def json_keys_op_impl(x: ibis_types.Value, op: ops.JSONKeys): + return json_keys(x, op.max_depth) + + # Blob Ops @scalar_op_compiler.register_unary_op(ops.obj_fetch_metadata_op) def obj_fetch_metadata_op_impl(obj_ref: ibis_types.Value): @@ -1335,6 +1284,7 @@ def eq_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) return x == y @@ -1344,13 +1294,14 @@ def eq_nulls_match_op( y: ibis_types.Value, ): """Variant of eq_op where nulls match each other. Only use where dtypes are known to be same.""" + x, y = _coerce_bools(x, y) literal = ibis_types.literal("$NULL_SENTINEL$") if hasattr(x, "fill_null"): left = x.cast(ibis_dtypes.str).fill_null(literal) right = y.cast(ibis_dtypes.str).fill_null(literal) else: - left = x.cast(ibis_dtypes.str).fillna(literal) - right = y.cast(ibis_dtypes.str).fillna(literal) + left = x.cast(ibis_dtypes.str).fill_null(literal) + right = y.cast(ibis_dtypes.str).fill_null(literal) return left == right @@ -1360,6 +1311,7 @@ def ne_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) return x != y @@ -1371,6 +1323,14 @@ def _null_or_value(value: ibis_types.Value, where_value: ibis_types.BooleanValue ) +def _coerce_bools(x: ibis_types.Value, y: ibis_types.Value, *, always: bool = False): + if x.type().is_boolean() and (always or not y.type().is_boolean()): + x = x.cast(ibis_dtypes.int64) + if y.type().is_boolean() and (always or not x.type().is_boolean()): + y = y.cast(ibis_dtypes.int64) + return x, y + + @scalar_op_compiler.register_binary_op(ops.and_op) def and_op( x: ibis_types.Value, @@ -1427,8 +1387,18 @@ def add_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) if isinstance(x, ibis_types.NullScalar) or isinstance(x, ibis_types.NullScalar): return ibis_types.null() + + if x.type().is_boolean() and y.type().is_boolean(): + x, y = _coerce_bools(x, y, always=True) + return ( + typing.cast(ibis_types.NumericValue, x) + + typing.cast(ibis_types.NumericValue, x) + ).cast(ibis_dtypes.Boolean) + + x, y = _coerce_bools(x, y) return x + y # type: ignore @@ -1438,6 +1408,7 @@ def sub_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) return typing.cast(ibis_types.NumericValue, x) - typing.cast( ibis_types.NumericValue, y ) @@ -1449,6 +1420,13 @@ def mul_op( x: ibis_types.Value, y: ibis_types.Value, ): + if x.type().is_boolean() and y.type().is_boolean(): + x, y = _coerce_bools(x, y, always=True) + return ( + typing.cast(ibis_types.NumericValue, x) + * typing.cast(ibis_types.NumericValue, x) + ).cast(ibis_dtypes.Boolean) + x, y = _coerce_bools(x, y) return typing.cast(ibis_types.NumericValue, x) * typing.cast( ibis_types.NumericValue, y ) @@ -1460,6 +1438,7 @@ def div_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) return typing.cast(ibis_types.NumericValue, x) / typing.cast( ibis_types.NumericValue, y ) @@ -1471,6 +1450,7 @@ def pow_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) if x.type().is_integer() and y.type().is_integer(): return _int_pow_op(x, y) else: @@ -1484,6 +1464,7 @@ def unsafe_pow_op( y: ibis_types.Value, ): """For internal use only - where domain and overflow checks are not needed.""" + x, y = _coerce_bools(x, y) return typing.cast(ibis_types.NumericValue, x) ** typing.cast( ibis_types.NumericValue, y ) @@ -1572,6 +1553,7 @@ def lt_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) return x < y @@ -1581,6 +1563,7 @@ def le_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) return x <= y @@ -1590,6 +1573,7 @@ def gt_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) return x > y @@ -1599,6 +1583,7 @@ def ge_op( x: ibis_types.Value, y: ibis_types.Value, ): + x, y = _coerce_bools(x, y) return x >= y @@ -1608,6 +1593,10 @@ def floordiv_op( x: ibis_types.Value, y: ibis_types.Value, ): + if x.type().is_boolean(): + x = x.cast(ibis_dtypes.int64) + elif y.type().is_boolean(): + y = y.cast(ibis_dtypes.int64) x_numeric = typing.cast(ibis_types.NumericValue, x) y_numeric = typing.cast(ibis_types.NumericValue, y) floordiv_expr = x_numeric // y_numeric @@ -1646,6 +1635,7 @@ def mod_op( if isinstance(op, ibis_generic.Literal) and op.value == 0: return ibis_types.null().cast(x.type()) + x, y = _coerce_bools(x, y) if x.type().is_integer() and y.type().is_integer(): # both are ints, no casting necessary return _int_mod( @@ -1737,14 +1727,18 @@ def fillna_op( x: ibis_types.Value, y: ibis_types.Value, ): - if hasattr(x, "fill_null"): - return x.fill_null(typing.cast(ibis_types.Scalar, y)) - else: - return x.fillna(typing.cast(ibis_types.Scalar, y)) + return x.fill_null(typing.cast(ibis_types.Scalar, y)) @scalar_op_compiler.register_binary_op(ops.round_op) def round_op(x: ibis_types.Value, y: ibis_types.Value): + if x.type().is_integer(): + # bq produces float64, but pandas returns int + return ( + typing.cast(ibis_types.NumericValue, x) + .round(digits=typing.cast(ibis_types.IntegerValue, y)) + .cast(ibis_dtypes.int64) + ) return typing.cast(ibis_types.NumericValue, x).round( digits=typing.cast(ibis_types.IntegerValue, y) ) @@ -1807,19 +1801,6 @@ def manhattan_distance_impl( return vector_distance(vector1, vector2, "MANHATTAN") -@scalar_op_compiler.register_binary_op(ops.BinaryRemoteFunctionOp, pass_op=True) -def binary_remote_function_op_impl( - x: ibis_types.Value, y: ibis_types.Value, op: ops.BinaryRemoteFunctionOp -): - ibis_node = getattr(op.func, "ibis_node", None) - if ibis_node is None: - raise TypeError( - f"only a bigframes remote function is supported as a callable. {constants.FEEDBACK_LINK}" - ) - x_transformed = ibis_node(x, y) - return x_transformed - - # Blob Ops @scalar_op_compiler.register_binary_op(ops.obj_make_ref_op) def obj_make_ref_op(x: ibis_types.Value, y: ibis_types.Value): @@ -1847,34 +1828,18 @@ def clip_op( if isinstance(lower, ibis_types.NullScalar) and ( not isinstance(upper, ibis_types.NullScalar) ): - return ( - ibis_api.case() # type: ignore - .when(upper.isnull() | (original > upper), upper) - .else_(original) - .end() - ) + return ibis_api.least(original, upper) elif (not isinstance(lower, ibis_types.NullScalar)) and isinstance( upper, ibis_types.NullScalar ): - return ( - ibis_api.case() # type: ignore - .when(lower.isnull() | (original < lower), lower) - .else_(original) - .end() - ) + return ibis_api.greatest(original, lower) elif isinstance(lower, ibis_types.NullScalar) and ( isinstance(upper, ibis_types.NullScalar) ): return original else: # Note: Pandas has unchanged behavior when upper bound and lower bound are flipped. This implementation requires that lower_bound < upper_bound - return ( - ibis_api.case() # type: ignore - .when(lower.isnull() | (original < lower), lower) - .when(upper.isnull() | (original > upper), upper) - .else_(original) - .end() - ) + return ibis_api.greatest(ibis_api.least(original, upper), lower) # N-ary Operations @@ -1897,19 +1862,6 @@ def case_when_op(*cases_and_outputs: ibis_types.Value) -> ibis_types.Value: return case_val.end() # type: ignore -@scalar_op_compiler.register_nary_op(ops.NaryRemoteFunctionOp, pass_op=True) -def nary_remote_function_op_impl( - *operands: ibis_types.Value, op: ops.NaryRemoteFunctionOp -): - ibis_node = getattr(op.func, "ibis_node", None) - if ibis_node is None: - raise TypeError( - f"only a bigframes remote function is supported as a callable. {constants.FEEDBACK_LINK}" - ) - result = ibis_node(*operands) - return result - - @scalar_op_compiler.register_nary_op(ops.SqlScalarOp, pass_op=True) def sql_scalar_op_impl(*operands: ibis_types.Value, op: ops.SqlScalarOp): return ibis_generic.SqlScalar( @@ -1932,9 +1884,112 @@ def struct_op_impl( return ibis_types.struct(data) +@scalar_op_compiler.register_nary_op(ops.AIGenerate, pass_op=True) +def ai_generate( + *values: ibis_types.Value, op: ops.AIGenerate +) -> ibis_types.StructValue: + + return ai_ops.AIGenerate( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + op.endpoint, # type: ignore + op.request_type.upper(), # type: ignore + op.model_params, # type: ignore + op.output_schema, # type: ignore + ).to_expr() + + +@scalar_op_compiler.register_nary_op(ops.AIGenerateBool, pass_op=True) +def ai_generate_bool( + *values: ibis_types.Value, op: ops.AIGenerateBool +) -> ibis_types.StructValue: + + return ai_ops.AIGenerateBool( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + op.endpoint, # type: ignore + op.request_type.upper(), # type: ignore + op.model_params, # type: ignore + ).to_expr() + + +@scalar_op_compiler.register_nary_op(ops.AIGenerateInt, pass_op=True) +def ai_generate_int( + *values: ibis_types.Value, op: ops.AIGenerateInt +) -> ibis_types.StructValue: + + return ai_ops.AIGenerateInt( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + op.endpoint, # type: ignore + op.request_type.upper(), # type: ignore + op.model_params, # type: ignore + ).to_expr() + + +@scalar_op_compiler.register_nary_op(ops.AIGenerateDouble, pass_op=True) +def ai_generate_double( + *values: ibis_types.Value, op: ops.AIGenerateDouble +) -> ibis_types.StructValue: + + return ai_ops.AIGenerateDouble( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + op.endpoint, # type: ignore + op.request_type.upper(), # type: ignore + op.model_params, # type: ignore + ).to_expr() + + +@scalar_op_compiler.register_nary_op(ops.AIIf, pass_op=True) +def ai_if(*values: ibis_types.Value, op: ops.AIIf) -> ibis_types.StructValue: + + return ai_ops.AIIf( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + ).to_expr() + + +@scalar_op_compiler.register_nary_op(ops.AIClassify, pass_op=True) +def ai_classify( + *values: ibis_types.Value, op: ops.AIClassify +) -> ibis_types.StructValue: + + return ai_ops.AIClassify( + _construct_prompt(values, op.prompt_context), # type: ignore + op.categories, # type: ignore + op.connection_id, # type: ignore + ).to_expr() + + +@scalar_op_compiler.register_nary_op(ops.AIScore, pass_op=True) +def ai_score(*values: ibis_types.Value, op: ops.AIScore) -> ibis_types.StructValue: + + return ai_ops.AIScore( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + ).to_expr() + + +def _construct_prompt( + col_refs: tuple[ibis_types.Value], prompt_context: tuple[str | None] +) -> ibis_types.StructValue: + prompt: dict[str, ibis_types.Value | str] = {} + column_ref_idx = 0 + + for idx, elem in enumerate(prompt_context): + if elem is None: + prompt[f"_field_{idx + 1}"] = col_refs[column_ref_idx] + column_ref_idx += 1 + else: + prompt[f"_field_{idx + 1}"] = elem + + return ibis.struct(prompt) + + @scalar_op_compiler.register_nary_op(ops.RowKey, pass_op=True) def rowkey_op_impl(*values: ibis_types.Value, op: ops.RowKey) -> ibis_types.Value: - return bigframes.core.compile.default_ordering.gen_row_key(values) + return bigframes.core.compile.ibis_compiler.default_ordering.gen_row_key(values) # Helpers @@ -1947,11 +2002,6 @@ def _ibis_num(number: float): return typing.cast(ibis_types.NumericValue, ibis_types.literal(number)) -@ibis_udf.scalar.builtin -def st_geogfromtext(a: str) -> ibis_dtypes.geography: # type: ignore - """Convert string to geography.""" - - @ibis_udf.scalar.builtin def timestamp(a: str) -> ibis_dtypes.timestamp: # type: ignore """Convert string to timestamp.""" @@ -1985,6 +2035,11 @@ def parse_json(json_str: str) -> ibis_dtypes.JSON: # type: ignore[empty-body] """Converts a JSON-formatted STRING value to a JSON value.""" +@ibis_udf.scalar.builtin(name="SAFE.PARSE_JSON") +def parse_json_in_safe(json_str: str) -> ibis_dtypes.JSON: # type: ignore[empty-body] + """Converts a JSON-formatted STRING value to a JSON value in the safe mode.""" + + @ibis_udf.scalar.builtin(name="json_set") def json_set( # type: ignore[empty-body] json_obj: ibis_dtypes.JSON, json_path: ibis_dtypes.String, json_value @@ -1999,11 +2054,22 @@ def json_extract_string_array( # type: ignore[empty-body] """Extracts a JSON array and converts it to a SQL ARRAY of STRINGs.""" +@ibis_udf.scalar.builtin(name="to_json") +def to_json(json_obj) -> ibis_dtypes.JSON: # type: ignore[empty-body] + """Convert to JSON.""" + + @ibis_udf.scalar.builtin(name="to_json_string") -def to_json_string( # type: ignore[empty-body] +def to_json_string(value) -> ibis_dtypes.String: # type: ignore[empty-body] + """Convert value to JSON-formatted string.""" + + +@ibis_udf.scalar.builtin(name="json_keys") +def json_keys( # type: ignore[empty-body] json_obj: ibis_dtypes.JSON, -) -> ibis_dtypes.String: - """Convert JSON to STRING.""" + max_depth: ibis_dtypes.Int64, +) -> ibis_dtypes.Array[ibis_dtypes.String]: + """Extracts unique JSON keys from a JSON expression.""" @ibis_udf.scalar.builtin(name="json_value") @@ -2013,6 +2079,53 @@ def json_value( # type: ignore[empty-body] """Retrieve value of a JSON field as plain STRING.""" +@ibis_udf.scalar.builtin(name="json_value_array") +def json_value_array( # type: ignore[empty-body] + json_obj: ibis_dtypes.JSON, json_path: ibis_dtypes.String +) -> ibis_dtypes.Array[ibis_dtypes.String]: + """Extracts a JSON array and converts it to a SQL ARRAY of STRINGs.""" + + +@ibis_udf.scalar.builtin(name="INT64") +def cast_json_to_int64(json_str: ibis_dtypes.JSON) -> ibis_dtypes.Int64: # type: ignore[empty-body] + """Converts a JSON number to a SQL INT64 value.""" + + +@ibis_udf.scalar.builtin(name="SAFE.INT64") +def cast_json_to_int64_in_safe(json_str: ibis_dtypes.JSON) -> ibis_dtypes.Int64: # type: ignore[empty-body] + """Converts a JSON number to a SQL INT64 value in the safe mode.""" + + +@ibis_udf.scalar.builtin(name="FLOAT64") +def cast_json_to_float64(json_str: ibis_dtypes.JSON) -> ibis_dtypes.Float64: # type: ignore[empty-body] + """Attempts to convert a JSON value to a SQL FLOAT64 value.""" + + +@ibis_udf.scalar.builtin(name="SAFE.FLOAT64") +def cast_json_to_float64_in_safe(json_str: ibis_dtypes.JSON) -> ibis_dtypes.Float64: # type: ignore[empty-body] + """Attempts to convert a JSON value to a SQL FLOAT64 value.""" + + +@ibis_udf.scalar.builtin(name="BOOL") +def cast_json_to_bool(json_str: ibis_dtypes.JSON) -> ibis_dtypes.Boolean: # type: ignore[empty-body] + """Attempts to convert a JSON value to a SQL BOOL value.""" + + +@ibis_udf.scalar.builtin(name="SAFE.BOOL") +def cast_json_to_bool_in_safe(json_str: ibis_dtypes.JSON) -> ibis_dtypes.Boolean: # type: ignore[empty-body] + """Attempts to convert a JSON value to a SQL BOOL value.""" + + +@ibis_udf.scalar.builtin(name="STRING") +def cast_json_to_string(json_str: ibis_dtypes.JSON) -> ibis_dtypes.String: # type: ignore[empty-body] + """Attempts to convert a JSON value to a SQL STRING value.""" + + +@ibis_udf.scalar.builtin(name="SAFE.STRING") +def cast_json_to_string_in_safe(json_str: ibis_dtypes.JSON) -> ibis_dtypes.String: # type: ignore[empty-body] + """Attempts to convert a JSON value to a SQL STRING value.""" + + @ibis_udf.scalar.builtin(name="ML.DISTANCE") def vector_distance(vector1, vector2, type: str) -> ibis_dtypes.Float64: # type: ignore[empty-body] """Computes the distance between two vectors using specified type ("EUCLIDEAN", "MANHATTAN", or "COSINE")""" @@ -2031,3 +2144,24 @@ def obj_make_ref(uri: str, authorizer: str) -> _OBJ_REF_IBIS_DTYPE: # type: ign @ibis_udf.scalar.builtin(name="OBJ.GET_ACCESS_URL") def obj_get_access_url(obj_ref: _OBJ_REF_IBIS_DTYPE, mode: ibis_dtypes.String) -> ibis_dtypes.JSON: # type: ignore """Get access url (as ObjectRefRumtime JSON) from ObjectRef.""" + + +@ibis_udf.scalar.builtin(name="ltrim") +def str_lstrip_op( # type: ignore[empty-body] + x: ibis_dtypes.String, to_strip: ibis_dtypes.String +) -> ibis_dtypes.String: + """Remove leading and trailing characters.""" + + +@ibis_udf.scalar.builtin(name="rtrim") +def str_rstrip_op( # type: ignore[empty-body] + x: ibis_dtypes.String, to_strip: ibis_dtypes.String +) -> ibis_dtypes.String: + """Remove leading and trailing characters.""" + + +@ibis_udf.scalar.builtin(name="trim") +def str_strip_op( # type: ignore[empty-body] + x: ibis_dtypes.String, to_strip: ibis_dtypes.String +) -> ibis_dtypes.String: + """Remove leading and trailing characters.""" diff --git a/bigframes/core/compile/ibis_types.py b/bigframes/core/compile/ibis_types.py index 2dcc1b3c8a..25b59d4582 100644 --- a/bigframes/core/compile/ibis_types.py +++ b/bigframes/core/compile/ibis_types.py @@ -13,20 +13,14 @@ # limitations under the License. from __future__ import annotations -import typing from typing import cast, Dict, Iterable, Optional, Tuple, Union import bigframes_vendored.constants as constants import bigframes_vendored.ibis -import bigframes_vendored.ibis.backends.bigquery.datatypes as third_party_ibis_bqtypes import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes -from bigframes_vendored.ibis.expr.datatypes.core import ( - dtype as python_type_to_ibis_type, -) import bigframes_vendored.ibis.expr.types as ibis_types import db_dtypes # type: ignore import geopandas as gpd # type: ignore -import google.cloud.bigquery as bigquery import pandas as pd import pyarrow as pa @@ -75,7 +69,7 @@ IBIS_GEO_TYPE, gpd.array.GeometryDtype(), ), - (ibis_dtypes.json, db_dtypes.JSONDtype()), + (ibis_dtypes.json, pd.ArrowDtype(db_dtypes.JSONArrowType())), ) BIGFRAMES_TO_IBIS: Dict[bigframes.dtypes.Dtype, ibis_dtypes.DataType] = { @@ -388,15 +382,13 @@ def literal_to_ibis_scalar( # Ibis has bug for casting nulltype to geospatial, so we perform intermediate cast first geotype = ibis_dtypes.GeoSpatial(geotype="geography", srid=4326, nullable=True) return bigframes_vendored.ibis.literal(None, geotype) - ibis_dtype = BIGFRAMES_TO_IBIS[force_dtype] if force_dtype else None + + ibis_dtype = bigframes_dtype_to_ibis_dtype(force_dtype) if force_dtype else None if pd.api.types.is_list_like(literal): - if validate: - raise ValueError( - f"List types can't be stored in BigQuery DataFrames. {constants.FEEDBACK_LINK}" - ) # "correct" way would be to use ibis.array, but this produces invalid BQ SQL syntax return tuple(literal) + if not pd.api.types.is_list_like(literal) and pd.isna(literal): if ibis_dtype: return bigframes_vendored.ibis.null().cast(ibis_dtype) @@ -437,36 +429,3 @@ def literal_to_ibis_scalar( ) return scalar_expr - - -class UnsupportedTypeError(ValueError): - def __init__(self, type_, supported_types): - self.type = type_ - self.supported_types = supported_types - super().__init__( - f"'{type_}' is not one of the supported types {supported_types}" - ) - - -def ibis_type_from_python_type(t: type) -> ibis_dtypes.DataType: - if t not in bigframes.dtypes.RF_SUPPORTED_IO_PYTHON_TYPES: - raise UnsupportedTypeError(t, bigframes.dtypes.RF_SUPPORTED_IO_PYTHON_TYPES) - return python_type_to_ibis_type(t) - - -def ibis_array_output_type_from_python_type(t: type) -> ibis_dtypes.DataType: - array_of = typing.get_args(t)[0] - if array_of not in bigframes.dtypes.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES: - raise UnsupportedTypeError( - array_of, bigframes.dtypes.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES - ) - return python_type_to_ibis_type(t) - - -def ibis_type_from_type_kind(tk: bigquery.StandardSqlTypeNames) -> ibis_dtypes.DataType: - """Convert bq type to ibis. Only to be used for remote functions, does not handle all types.""" - if tk not in bigframes.dtypes.RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS: - raise UnsupportedTypeError( - tk, bigframes.dtypes.RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS - ) - return third_party_ibis_bqtypes.BigQueryType.to_ibis(tk) diff --git a/bigframes/core/compile/polars/__init__.py b/bigframes/core/compile/polars/__init__.py index 8c37e046ab..215d6b088e 100644 --- a/bigframes/core/compile/polars/__init__.py +++ b/bigframes/core/compile/polars/__init__.py @@ -11,16 +11,32 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +"""Compiler for BigFrames expression to Polars LazyFrame expression. + +Make sure to import all polars implementations here so that they get registered. +""" from __future__ import annotations import warnings +# The ops imports appear first so that the implementations can be registered. +# polars shouldn't be needed at import time, as register is a no-op if polars +# isn't installed. +import bigframes.core.compile.polars.operations.generic_ops # noqa: F401 +import bigframes.core.compile.polars.operations.numeric_ops # noqa: F401 +import bigframes.core.compile.polars.operations.struct_ops # noqa: F401 + try: - import polars # noqa + import bigframes._importing + + # Use import_polars() instead of importing directly so that we check the + # version numbers. + bigframes._importing.import_polars() from bigframes.core.compile.polars.compiler import PolarsCompiler __all__ = ["PolarsCompiler"] -except Exception: - msg = "Polars compiler not available as polars is not installed." +except Exception as exc: + msg = f"Polars compiler not available as there was an exception importing polars. Details: {str(exc)}" warnings.warn(msg) diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 6d5b11a5e8..1f0ca592e5 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -16,26 +16,119 @@ import dataclasses import functools import itertools -from typing import cast, Sequence, Tuple, TYPE_CHECKING +from typing import cast, Literal, Optional, Sequence, Tuple, Type, TYPE_CHECKING + +import pandas as pd import bigframes.core +from bigframes.core import agg_expressions, identifiers, nodes, ordering, window_spec +from bigframes.core.compile.polars import lowering import bigframes.core.expression as ex import bigframes.core.guid as guid -import bigframes.core.nodes as nodes import bigframes.core.rewrite +import bigframes.core.rewrite.schema_binding +import bigframes.dtypes import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops +import bigframes.operations.array_ops as arr_ops +import bigframes.operations.bool_ops as bool_ops +import bigframes.operations.comparison_ops as comp_ops +import bigframes.operations.date_ops as date_ops +import bigframes.operations.datetime_ops as dt_ops +import bigframes.operations.frequency_ops as freq_ops +import bigframes.operations.generic_ops as gen_ops +import bigframes.operations.json_ops as json_ops +import bigframes.operations.numeric_ops as num_ops +import bigframes.operations.string_ops as string_ops polars_installed = True if TYPE_CHECKING: import polars as pl else: try: - import polars as pl + import bigframes._importing + + # Use import_polars() instead of importing directly so that we check + # the version numbers. + pl = bigframes._importing.import_polars() except Exception: polars_installed = False + +def register_op(op: Type): + """Register a compilation from BigFrames to Ibis. + + This decorator can be used, even if Polars is not installed. + + Args: + op: The type of the operator the wrapped function compiles. + """ + + def decorator(func): + if polars_installed: + # Ignore the type because compile_op is a generic Callable, so + # register isn't available according to mypy. + return PolarsExpressionCompiler.compile_op.register(op)(func) # type: ignore + else: + return func + + return decorator + + if polars_installed: + _FREQ_MAPPING = { + "Y": "1y", + "Q": "1q", + "M": "1mo", + "W": "1w", + "D": "1d", + "h": "1h", + "min": "1m", + "s": "1s", + "ms": "1ms", + "us": "1us", + "ns": "1ns", + } + + _DTYPE_MAPPING = { + # Direct mappings + bigframes.dtypes.INT_DTYPE: pl.Int64(), + bigframes.dtypes.FLOAT_DTYPE: pl.Float64(), + bigframes.dtypes.BOOL_DTYPE: pl.Boolean(), + bigframes.dtypes.STRING_DTYPE: pl.String(), + bigframes.dtypes.NUMERIC_DTYPE: pl.Decimal(38, 9), + bigframes.dtypes.BIGNUMERIC_DTYPE: pl.Decimal(76, 38), + bigframes.dtypes.BYTES_DTYPE: pl.Binary(), + bigframes.dtypes.DATE_DTYPE: pl.Date(), + bigframes.dtypes.DATETIME_DTYPE: pl.Datetime(time_zone=None), + bigframes.dtypes.TIMESTAMP_DTYPE: pl.Datetime(time_zone="UTC"), + bigframes.dtypes.TIME_DTYPE: pl.Time(), + bigframes.dtypes.TIMEDELTA_DTYPE: pl.Duration(), + # Indirect mappings + bigframes.dtypes.GEO_DTYPE: pl.String(), + bigframes.dtypes.JSON_DTYPE: pl.String(), + } + + def _bigframes_dtype_to_polars_dtype( + dtype: bigframes.dtypes.ExpressionType, + ) -> pl.DataType: + if dtype is None: + return pl.Null() + if bigframes.dtypes.is_struct_like(dtype): + return pl.Struct( + [ + pl.Field(name, _bigframes_dtype_to_polars_dtype(type)) + for name, type in bigframes.dtypes.get_struct_fields(dtype).items() + ] + ) + if bigframes.dtypes.is_array_like(dtype): + return pl.Array( + inner=_bigframes_dtype_to_polars_dtype( + bigframes.dtypes.get_array_inner_type(dtype) + ) + ) + else: + return _DTYPE_MAPPING[dtype] @dataclasses.dataclass(frozen=True) class PolarsExpressionCompiler: @@ -46,77 +139,343 @@ class PolarsExpressionCompiler: """ @functools.singledispatchmethod - def compile_expression(self, expression: ex.Expression): + def compile_expression(self, expression: ex.Expression) -> pl.Expr: raise NotImplementedError(f"Cannot compile expression: {expression}") @compile_expression.register def _( self, expression: ex.ScalarConstantExpression, - ): - return pl.lit(expression.value) + ) -> pl.Expr: + value = expression.value + if not isinstance(value, float) and pd.isna(value): # type: ignore + value = None + if expression.dtype is None: + return pl.lit(None) + + # Polars lit does not handle pandas timedelta well at v1.36 + if isinstance(value, pd.Timedelta): + value = value.to_pytimedelta() + + return pl.lit(value, _bigframes_dtype_to_polars_dtype(expression.dtype)) @compile_expression.register def _( self, expression: ex.DerefOp, - ): + ) -> pl.Expr: + return pl.col(expression.id.sql) + + @compile_expression.register + def _( + self, + expression: ex.ResolvedDerefOp, + ) -> pl.Expr: return pl.col(expression.id.sql) @compile_expression.register def _( self, expression: ex.OpExpression, - ): - # TODO: Complete the implementation, convert to hash dispatch + ) -> pl.Expr: + # TODO: Complete the implementation op = expression.op args = tuple(map(self.compile_expression, expression.inputs)) - if isinstance(op, ops.invert_op.__class__): - return args[0].neg() - if isinstance(op, ops.and_op.__class__): - return args[0] & args[1] - if isinstance(op, ops.or_op.__class__): - return args[0] | args[1] - if isinstance(op, ops.add_op.__class__): - return args[0] + args[1] - if isinstance(op, ops.sub_op.__class__): - return args[0] - args[1] - if isinstance(op, ops.ge_op.__class__): - return args[0] >= args[1] - if isinstance(op, ops.gt_op.__class__): - return args[0] > args[1] - if isinstance(op, ops.le_op.__class__): - return args[0] <= args[1] - if isinstance(op, ops.lt_op.__class__): - return args[0] < args[1] - if isinstance(op, ops.eq_op.__class__): - return args[0] == args[1] - if isinstance(op, ops.mod_op.__class__): - return args[0] % args[1] - if isinstance(op, ops.coalesce_op.__class__): - return pl.coalesce(*args) - if isinstance(op, ops.CaseWhenOp): - expr = pl.when(args[0]).then(args[1]) - for pred, result in zip(args[2::2], args[3::2]): - return expr.when(pred).then(result) - return expr + return self.compile_op(op, *args) + + @functools.singledispatchmethod + def compile_op(self, op: ops.ScalarOp, *args: pl.Expr) -> pl.Expr: raise NotImplementedError(f"Polars compiler hasn't implemented {op}") + @compile_op.register(gen_ops.InvertOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.not_() + + @compile_op.register(num_ops.AbsOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.abs() + + @compile_op.register(num_ops.FloorOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.floor() + + @compile_op.register(num_ops.CeilOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.ceil() + + @compile_op.register(num_ops.PosOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.__pos__() + + @compile_op.register(num_ops.NegOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.__neg__() + + @compile_op.register(bool_ops.AndOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input & r_input + + @compile_op.register(bool_ops.OrOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input | r_input + + @compile_op.register(bool_ops.XorOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input ^ r_input + + @compile_op.register(num_ops.AddOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input + r_input + + @compile_op.register(num_ops.SubOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input - r_input + + @compile_op.register(num_ops.MulOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input * r_input + + @compile_op.register(num_ops.DivOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input / r_input + + @compile_op.register(num_ops.FloorDivOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input // r_input + + @compile_op.register(num_ops.ModOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input % r_input + + @compile_op.register(num_ops.PowOp) + @compile_op.register(num_ops.UnsafePowOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input**r_input + + @compile_op.register(comp_ops.EqOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input.eq(r_input) + + @compile_op.register(comp_ops.EqNullsMatchOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input.eq_missing(r_input) + + @compile_op.register(comp_ops.NeOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input.ne(r_input) + + @compile_op.register(comp_ops.GtOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input > r_input + + @compile_op.register(comp_ops.GeOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input >= r_input + + @compile_op.register(comp_ops.LtOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input < r_input + + @compile_op.register(comp_ops.LeOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return l_input <= r_input + + @compile_op.register(gen_ops.IsInOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + # TODO: Filter out types that can't be coerced to right type + assert isinstance(op, gen_ops.IsInOp) + assert not op.match_nulls # should be stripped by a lowering step rn + values = pl.Series(op.values, strict=False) + return input.is_in(values) + + @compile_op.register(gen_ops.FillNaOp) + @compile_op.register(gen_ops.CoalesceOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + return pl.coalesce(l_input, r_input) + + @compile_op.register(gen_ops.CaseWhenOp) + def _(self, op: ops.ScalarOp, *inputs: pl.Expr) -> pl.Expr: + expr = pl.when(inputs[0]).then(inputs[1]) + for pred, result in zip(inputs[2::2], inputs[3::2]): + expr = expr.when(pred).then(result) # type: ignore + return expr + + @compile_op.register(gen_ops.WhereOp) + def _( + self, + op: ops.ScalarOp, + original: pl.Expr, + condition: pl.Expr, + otherwise: pl.Expr, + ) -> pl.Expr: + return pl.when(condition).then(original).otherwise(otherwise) + + @compile_op.register(gen_ops.AsTypeOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, gen_ops.AsTypeOp) + # TODO: Polars casting works differently, need to lower instead to specific conversion ops. + # eg. We want "True" instead of "true" for bool to strin + return input.cast(_DTYPE_MAPPING[op.to_type], strict=not op.safe) + + @compile_op.register(string_ops.StrConcatOp) + def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.StrConcatOp) + return pl.concat_str(l_input, r_input) + + @compile_op.register(string_ops.StrContainsOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.StrContainsOp) + return input.str.contains(pattern=op.pat, literal=True) + + @compile_op.register(string_ops.StrContainsRegexOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.StrContainsRegexOp) + return input.str.contains(pattern=op.pat, literal=False) + + @compile_op.register(string_ops.UpperOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.UpperOp) + return input.str.to_uppercase() + + @compile_op.register(string_ops.LowerOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.LowerOp) + return input.str.to_lowercase() + + @compile_op.register(string_ops.ArrayLenOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.ArrayLenOp) + return input.list.len() + + @compile_op.register(string_ops.StrLenOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.StrLenOp) + return input.str.len_chars() + + @compile_op.register(string_ops.StartsWithOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.StartsWithOp) + if len(op.pat) == 1: + return input.str.starts_with(op.pat[0]) + else: + return pl.any_horizontal( + *(input.str.starts_with(pat) for pat in op.pat) + ) + + @compile_op.register(string_ops.EndsWithOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.EndsWithOp) + if len(op.pat) == 1: + return input.str.ends_with(op.pat[0]) + else: + return pl.any_horizontal(*(input.str.ends_with(pat) for pat in op.pat)) + + @compile_op.register(freq_ops.FloorDtOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, freq_ops.FloorDtOp) + return input.dt.truncate(every=_FREQ_MAPPING[op.freq]) + + @compile_op.register(dt_ops.StrftimeOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, dt_ops.StrftimeOp) + return input.dt.strftime(op.date_format) + + @compile_op.register(date_ops.YearOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.year() + + @compile_op.register(date_ops.QuarterOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.quarter() + + @compile_op.register(date_ops.MonthOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.month() + + @compile_op.register(date_ops.DayOfWeekOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.weekday() - 1 + + @compile_op.register(date_ops.DayOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.day() + + @compile_op.register(date_ops.IsoYearOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.iso_year() + + @compile_op.register(date_ops.IsoWeekOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.week() + + @compile_op.register(date_ops.IsoDayOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.weekday() + + @compile_op.register(dt_ops.ParseDatetimeOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, dt_ops.ParseDatetimeOp) + return input.str.to_datetime( + time_unit="us", time_zone=None, ambiguous="earliest" + ) + + @compile_op.register(dt_ops.ParseTimestampOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, dt_ops.ParseTimestampOp) + return input.str.to_datetime( + time_unit="us", time_zone="UTC", ambiguous="earliest" + ) + + @compile_op.register(json_ops.JSONDecode) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, json_ops.JSONDecode) + return input.str.json_decode(_DTYPE_MAPPING[op.to_type]) + + @compile_op.register(arr_ops.ToArrayOp) + def _(self, op: ops.ToArrayOp, *inputs: pl.Expr) -> pl.Expr: + return pl.concat_list(*inputs) + + @compile_op.register(arr_ops.ArrayReduceOp) + def _(self, op: ops.ArrayReduceOp, input: pl.Expr) -> pl.Expr: + # TODO: Unify this with general aggregation compilation? + if isinstance(op.aggregation, agg_ops.MinOp): + return input.list.min() + if isinstance(op.aggregation, agg_ops.MaxOp): + return input.list.max() + if isinstance(op.aggregation, agg_ops.SumOp): + return input.list.sum() + if isinstance(op.aggregation, agg_ops.MeanOp): + return input.list.mean() + if isinstance(op.aggregation, agg_ops.CountOp): + return input.list.len() + if isinstance(op.aggregation, agg_ops.StdOp): + return input.list.std() + if isinstance(op.aggregation, agg_ops.VarOp): + return input.list.var() + if isinstance(op.aggregation, agg_ops.AnyOp): + return input.list.any() + if isinstance(op.aggregation, agg_ops.AllOp): + return input.list.all() + else: + raise NotImplementedError( + f"Haven't implemented array aggregation: {op.aggregation}" + ) + @dataclasses.dataclass(frozen=True) class PolarsAggregateCompiler: scalar_compiler = PolarsExpressionCompiler() def get_args( self, - agg: ex.Aggregation, + agg: agg_expressions.Aggregation, ) -> Sequence[pl.Expr]: """Prepares arguments for aggregation by compiling them.""" - if isinstance(agg, ex.NullaryAggregation): + if isinstance(agg, agg_expressions.NullaryAggregation): return [] - elif isinstance(agg, ex.UnaryAggregation): + elif isinstance(agg, agg_expressions.UnaryAggregation): arg = self.scalar_compiler.compile_expression(agg.arg) return [arg] - elif isinstance(agg, ex.BinaryAggregation): + elif isinstance(agg, agg_expressions.BinaryAggregation): larg = self.scalar_compiler.compile_expression(agg.left) rarg = self.scalar_compiler.compile_expression(agg.right) return [larg, rarg] @@ -125,13 +484,13 @@ def get_args( f"Aggregation {agg} not yet supported in polars engine." ) - def compile_agg_expr(self, expr: ex.Aggregation): - if isinstance(expr, ex.NullaryAggregation): + def compile_agg_expr(self, expr: agg_expressions.Aggregation): + if isinstance(expr, agg_expressions.NullaryAggregation): inputs: Tuple = () - elif isinstance(expr, ex.UnaryAggregation): + elif isinstance(expr, agg_expressions.UnaryAggregation): assert isinstance(expr.arg, ex.DerefOp) inputs = (expr.arg.id.sql,) - elif isinstance(expr, ex.BinaryAggregation): + elif isinstance(expr, agg_expressions.BinaryAggregation): assert isinstance(expr.left, ex.DerefOp) assert isinstance(expr.right, ex.DerefOp) inputs = ( @@ -143,12 +502,26 @@ def compile_agg_expr(self, expr: ex.Aggregation): return self.compile_agg_op(expr.op, inputs) - def compile_agg_op(self, op: agg_ops.WindowOp, inputs: Sequence[str] = []): + def compile_agg_op( + self, op: agg_ops.WindowOp, inputs: Sequence[str] = [] + ) -> pl.Expr: if isinstance(op, agg_ops.ProductOp): - # TODO: Need schema to cast back to original type if posisble (eg float back to int) - return pl.col(*inputs).log().sum().exp() + # TODO: Fix datatype inconsistency with float/int + return pl.col(*inputs).product() if isinstance(op, agg_ops.SumOp): return pl.sum(*inputs) + if isinstance(op, (agg_ops.SizeOp, agg_ops.SizeUnaryOp)): + return pl.len() + if isinstance(op, agg_ops.MeanOp): + return pl.mean(*inputs) + if isinstance(op, agg_ops.MedianOp): + return pl.median(*inputs) + if isinstance(op, agg_ops.AllOp): + return pl.col(inputs).cast(pl.Boolean).all() + if isinstance(op, agg_ops.AnyOp): + return pl.col(inputs).cast(pl.Boolean).any() + if isinstance(op, agg_ops.NuniqueOp): + return pl.col(*inputs).drop_nulls().n_unique() if isinstance(op, agg_ops.MinOp): return pl.min(*inputs) if isinstance(op, agg_ops.MaxOp): @@ -156,242 +529,403 @@ def compile_agg_op(self, op: agg_ops.WindowOp, inputs: Sequence[str] = []): if isinstance(op, agg_ops.CountOp): return pl.count(*inputs) if isinstance(op, agg_ops.CorrOp): - return pl.corr(*inputs) + return pl.corr( + pl.col(inputs[0]).fill_nan(None), pl.col(inputs[1]).fill_nan(None) + ) + if isinstance(op, agg_ops.CovOp): + return pl.cov( + pl.col(inputs[0]).fill_nan(None), pl.col(inputs[1]).fill_nan(None) + ) + if isinstance(op, agg_ops.StdOp): + return pl.std(inputs[0]) + if isinstance(op, agg_ops.VarOp): + # polars var doesnt' support decimal, so use std instead + return pl.std(inputs[0]).pow(2) + if isinstance(op, agg_ops.PopVarOp): + # polars var doesnt' support decimal, so use std instead + return pl.std(inputs[0], ddof=0).pow(2) + if isinstance(op, agg_ops.FirstNonNullOp): + return pl.col(*inputs).drop_nulls().first() + if isinstance(op, agg_ops.LastNonNullOp): + return pl.col(*inputs).drop_nulls().last() + if isinstance(op, agg_ops.FirstOp): + return pl.col(*inputs).first() + if isinstance(op, agg_ops.LastOp): + return pl.col(*inputs).last() + if isinstance(op, agg_ops.RowNumberOp): + # pl.row_index is not yet stable enough to use here, and only supports polars>=1.32 + return pl.int_range(pl.len(), dtype=pl.Int64) + if isinstance(op, agg_ops.ShiftOp): + return pl.col(*inputs).shift(op.periods) + if isinstance(op, agg_ops.DiffOp): + return pl.col(*inputs) - pl.col(*inputs).shift(op.periods) + if isinstance(op, agg_ops.AnyValueOp): + return pl.max( + *inputs + ) # probably something faster? maybe just get first item? raise NotImplementedError( f"Aggregate op {op} not yet supported in polars engine." ) + @dataclasses.dataclass(frozen=True) + class PolarsCompiler: + """ + Compiles ArrayValue to polars LazyFrame and executes. + + This feature is in development and is incomplete. + While most node types are supported, this has the following limitations: + 1. GBQ data sources not supported. + 2. Joins do not order rows correctly + 3. Incomplete scalar op support + 4. Incomplete aggregate op support + 5. Incomplete analytic op support + 6. Some complex windowing types not supported (eg. groupby + rolling) + 7. UDFs are not supported. + 8. Returned types may not be entirely consistent with BigQuery backend + 9. Some operations are not entirely lazy - sampling and somse windowing. + """ -@dataclasses.dataclass(frozen=True) -class PolarsCompiler: - """ - Compiles ArrayValue to polars LazyFrame and executes. - - This feature is in development and is incomplete. - While most node types are supported, this has the following limitations: - 1. GBQ data sources not supported. - 2. Joins do not order rows correctly - 3. Incomplete scalar op support - 4. Incomplete aggregate op support - 5. Incomplete analytic op support - 6. Some complex windowing types not supported (eg. groupby + rolling) - 7. UDFs are not supported. - 8. Returned types may not be entirely consistent with BigQuery backend - 9. Some operations are not entirely lazy - sampling and somse windowing. - """ + expr_compiler = PolarsExpressionCompiler() + agg_compiler = PolarsAggregateCompiler() + + def compile(self, plan: nodes.BigFrameNode) -> pl.LazyFrame: + if not polars_installed: + raise ValueError( + "Polars is not installed, cannot compile to polars engine." + ) + + # TODO: Create standard way to configure BFET -> BFET rewrites + # Polars has incomplete slice support in lazy mode + node = plan + node = bigframes.core.rewrite.column_pruning(node) + node = nodes.bottom_up(node, bigframes.core.rewrite.rewrite_slice) + node = bigframes.core.rewrite.pull_out_window_order(node) + node = bigframes.core.rewrite.schema_binding.bind_schema_to_tree(node) + node = lowering.lower_ops_to_polars(node) + return self.compile_node(node) + + @functools.singledispatchmethod + def compile_node(self, node: nodes.BigFrameNode) -> pl.LazyFrame: + """Defines transformation but isn't cached, always use compile_node instead""" + raise ValueError(f"Can't compile unrecognized node: {node}") + + @compile_node.register + def compile_readlocal(self, node: nodes.ReadLocalNode): + cols_to_read = { + scan_item.source_id: scan_item.id.sql + for scan_item in node.scan_list.items + } + lazy_frame = cast( + pl.DataFrame, pl.from_arrow(node.local_data_source.data) + ).lazy() + lazy_frame = lazy_frame.select(cols_to_read.keys()).rename(cols_to_read) + if node.offsets_col: + lazy_frame = lazy_frame.with_columns( + [pl.int_range(pl.len(), dtype=pl.Int64).alias(node.offsets_col.sql)] + ) + return lazy_frame - expr_compiler = PolarsExpressionCompiler() - agg_compiler = PolarsAggregateCompiler() + @compile_node.register + def compile_filter(self, node: nodes.FilterNode): + return self.compile_node(node.child).filter( + self.expr_compiler.compile_expression(node.predicate) + ) - def compile(self, array_value: bigframes.core.ArrayValue) -> pl.LazyFrame: - if not polars_installed: - raise ValueError( - "Polars is not installed, cannot compile to polars engine." + @compile_node.register + def compile_orderby(self, node: nodes.OrderByNode): + frame = self.compile_node(node.child) + if len(node.by) == 0: + # pragma: no cover + return frame + return self._sort(frame, node.by) + + def _sort( + self, frame: pl.LazyFrame, by: Sequence[ordering.OrderingExpression] + ) -> pl.LazyFrame: + sorted = frame.sort( + [ + self.expr_compiler.compile_expression(by.scalar_expression) + for by in by + ], + descending=[not by.direction.is_ascending for by in by], + nulls_last=[by.na_last for by in by], + maintain_order=True, ) + return sorted + + @compile_node.register + def compile_reversed(self, node: nodes.ReversedNode): + return self.compile_node(node.child).reverse() + + @compile_node.register + def compile_selection(self, node: nodes.SelectionNode): + return self.compile_node(node.child).select( + **{new.sql: orig.id.sql for orig, new in node.input_output_pairs} + ) + + @compile_node.register + def compile_projection(self, node: nodes.ProjectionNode): + new_cols = [] + for proj_expr, name in node.assignments: + bound_expr = ex.bind_schema_fields(proj_expr, node.child.field_by_id) + new_col = self.expr_compiler.compile_expression(bound_expr).alias( + name.sql + ) + if bound_expr.output_type is None: + new_col = new_col.cast( + _bigframes_dtype_to_polars_dtype(bigframes.dtypes.DEFAULT_DTYPE) + ) + new_cols.append(new_col) + return self.compile_node(node.child).with_columns(new_cols) + + @compile_node.register + def compile_offsets(self, node: nodes.PromoteOffsetsNode): + return self.compile_node(node.child).with_columns( + [pl.int_range(pl.len(), dtype=pl.Int64).alias(node.col_id.sql)] + ) + + @compile_node.register + def compile_join(self, node: nodes.JoinNode): + left = self.compile_node(node.left_child) + right = self.compile_node(node.right_child) + + left_on = [] + right_on = [] + for left_ex, right_ex in node.conditions: + left_ex, right_ex = lowering._coerce_comparables(left_ex, right_ex) + left_on.append(self.expr_compiler.compile_expression(left_ex)) + right_on.append(self.expr_compiler.compile_expression(right_ex)) + + if node.type == "right": + return self._ordered_join( + right, left, "left", right_on, left_on, node.joins_nulls + ).select([id.sql for id in node.ids]) + return self._ordered_join( + left, right, node.type, left_on, right_on, node.joins_nulls + ) + + @compile_node.register + def compile_isin(self, node: nodes.InNode): + left = self.compile_node(node.left_child) + right = self.compile_node(node.right_child).unique() + right = right.with_columns(pl.lit(True).alias(node.indicator_col.sql)) + + right_col = ex.ResolvedDerefOp.from_field(node.right_child.fields[0]) + left_ex, right_ex = lowering._coerce_comparables(node.left_col, right_col) + + left_pl_ex = self.expr_compiler.compile_expression(left_ex) + right_pl_ex = self.expr_compiler.compile_expression(right_ex) - # TODO: Create standard way to configure BFET -> BFET rewrites - # Polars has incomplete slice support in lazy mode - node = nodes.bottom_up(array_value.node, bigframes.core.rewrite.rewrite_slice) - return self.compile_node(node) - - @functools.singledispatchmethod - def compile_node(self, node: nodes.BigFrameNode): - """Defines transformation but isn't cached, always use compile_node instead""" - raise ValueError(f"Can't compile unrecognized node: {node}") - - @compile_node.register - def compile_readlocal(self, node: nodes.ReadLocalNode): - cols_to_read = { - scan_item.source_id: scan_item.id.sql for scan_item in node.scan_list.items - } - return ( - pl.read_ipc(node.feather_bytes, columns=list(cols_to_read.keys())) - .lazy() - .rename(cols_to_read) - ) - - @compile_node.register - def compile_filter(self, node: nodes.FilterNode): - return self.compile_node(node.child).filter( - self.expr_compiler.compile_expression(node.predicate) - ) - - @compile_node.register - def compile_orderby(self, node: nodes.OrderByNode): - frame = self.compile_node(node.child) - if len(node.by) == 0: - # pragma: no cover - return frame - - frame = frame.sort( - [ - self.expr_compiler.compile_expression(by.scalar_expression) - for by in node.by - ], - descending=[not by.direction.is_ascending for by in node.by], - nulls_last=[by.na_last for by in node.by], - maintain_order=True, - ) - return frame - - @compile_node.register - def compile_reversed(self, node: nodes.ReversedNode): - return self.compile_node(node.child).reverse() - - @compile_node.register - def compile_selection(self, node: nodes.SelectionNode): - return self.compile_node(node.child).select( - **{new.sql: orig.id.sql for orig, new in node.input_output_pairs} - ) - - @compile_node.register - def compile_projection(self, node: nodes.ProjectionNode): - new_cols = [ - self.expr_compiler.compile_expression(ex).alias(name.sql) - for ex, name in node.assignments - ] - return self.compile_node(node.child).with_columns(new_cols) - - @compile_node.register - def compile_rowcount(self, node: nodes.RowCountNode): - df = cast(pl.LazyFrame, self.compile_node(node.child)) - return df.select(pl.len().alias(node.col_id.sql)) - - @compile_node.register - def compile_offsets(self, node: nodes.PromoteOffsetsNode): - return self.compile_node(node.child).with_columns( - [pl.int_range(pl.len(), dtype=pl.Int64).alias(node.col_id.sql)] - ) - - @compile_node.register - def compile_join(self, node: nodes.JoinNode): - # Always totally order this, as adding offsets is relatively cheap for in-memory columnar data - left = self.compile_node(node.left_child).with_columns( - [ - pl.int_range(pl.len()).alias("_bf_join_l"), - ] - ) - right = self.compile_node(node.right_child).with_columns( - [ - pl.int_range(pl.len()).alias("_bf_join_r"), - ] - ) - if node.type != "cross": - left_on = [l_name.id.sql for l_name, _ in node.conditions] - right_on = [r_name.id.sql for _, r_name in node.conditions] joined = left.join( - right, how=node.type, left_on=left_on, right_on=right_on, coalesce=False + right, + how="left", + left_on=left_pl_ex, + right_on=right_pl_ex, + # Note: join_nulls renamed to nulls_equal for polars 1.24 + join_nulls=node.joins_nulls, # type: ignore + coalesce=False, ) - else: - joined = left.join(right, how=node.type) - return joined.sort(["_bf_join_l", "_bf_join_r"]).drop( - ["_bf_join_l", "_bf_join_r"] - ) - - @compile_node.register - def compile_concat(self, node: nodes.ConcatNode): - return pl.concat(self.compile_node(child) for child in node.child_nodes) - - @compile_node.register - def compile_agg(self, node: nodes.AggregateNode): - df = self.compile_node(node.child) - - # Need to materialize columns to broadcast constants - agg_inputs = [ - list( - map( - lambda x: x.alias(guid.generate_guid()), - self.agg_compiler.get_args(agg), + passthrough = [pl.col(id) for id in left.columns] + indicator = pl.col(node.indicator_col.sql).fill_null(False) + return joined.select((*passthrough, indicator)) + + def _ordered_join( + self, + left_frame: pl.LazyFrame, + right_frame: pl.LazyFrame, + how: Literal["inner", "outer", "left", "cross"], + left_on: Sequence[pl.Expr], + right_on: Sequence[pl.Expr], + join_nulls: bool, + ): + if how == "right": + # seems to cause seg faults as of v1.30 for no apparent reason + raise ValueError("right join not supported") + left = left_frame.with_columns( + [ + pl.int_range(pl.len()).alias("_bf_join_l"), + ] + ) + right = right_frame.with_columns( + [ + pl.int_range(pl.len()).alias("_bf_join_r"), + ] + ) + if how != "cross": + joined = left.join( + right, + how=how, + left_on=left_on, + right_on=right_on, + # Note: join_nulls renamed to nulls_equal for polars 1.24 + join_nulls=join_nulls, # type: ignore + coalesce=False, ) + else: + joined = left.join(right, how=how, coalesce=False) + + join_order = ( + ["_bf_join_l", "_bf_join_r"] + if how != "right" + else ["_bf_join_r", "_bf_join_l"] ) - for agg, _ in node.aggregations - ] - - df_agg_inputs = df.with_columns(itertools.chain(*agg_inputs)) - - agg_exprs = [ - self.agg_compiler.compile_agg_op( - agg.op, list(map(lambda x: x.meta.output_name(), inputs)) - ).alias(id.sql) - for (agg, id), inputs in zip(node.aggregations, agg_inputs) - ] - - if len(node.by_column_ids) > 0: - group_exprs = [pl.col(ref.id.sql) for ref in node.by_column_ids] - grouped_df = df_agg_inputs.group_by(group_exprs) - return grouped_df.agg(agg_exprs).sort(group_exprs) - else: - return df_agg_inputs.select(agg_exprs) - - @compile_node.register - def compile_explode(self, node: nodes.ExplodeNode): - df = self.compile_node(node.child) - cols = [pl.col(col.id.sql) for col in node.column_ids] - return df.explode(cols) - - @compile_node.register - def compile_sample(self, node: nodes.RandomSampleNode): - df = self.compile_node(node.child) - # Sample is not available on lazyframe - return df.collect().sample(fraction=node.fraction).lazy() - - @compile_node.register - def compile_window(self, node: nodes.WindowOpNode): - df = self.compile_node(node.child) - agg_expr = self.agg_compiler.compile_agg_expr(node.expression).alias( - node.output_name.sql - ) - # Three window types: completely unbound, grouped and row bounded - - window = node.window_spec - - if window.min_periods > 0: - raise NotImplementedError("min_period not yet supported for polars engine") - - if window.bounds is None: - # polars will automatically broadcast the aggregate to the matching input rows - if len(window.grouping_keys) == 0: # unbound window - pass - else: # partition-only window - agg_expr = agg_expr.over( - partition_by=[ref.id.sql for ref in window.grouping_keys] + return joined.sort(join_order, nulls_last=True).drop( + ["_bf_join_l", "_bf_join_r"] + ) + + @compile_node.register + def compile_concat(self, node: nodes.ConcatNode): + child_frames = [self.compile_node(child) for child in node.child_nodes] + child_frames = [ + frame.rename( + {col: id.sql for col, id in zip(frame.columns, node.output_ids)} + ).cast( + { + field.id.sql: _bigframes_dtype_to_polars_dtype(field.dtype) + for field in node.fields + } + ) + for frame in child_frames + ] + df = pl.concat(child_frames) + return df + + @compile_node.register + def compile_agg(self, node: nodes.AggregateNode): + df = self.compile_node(node.child) + if node.dropna and len(node.by_column_ids) > 0: + df = df.filter( + [pl.col(ref.id.sql).is_not_null() for ref in node.by_column_ids] + ) + if node.order_by: + df = self._sort(df, node.order_by) + return self._aggregate(df, node.aggregations, node.by_column_ids) + + def _aggregate( + self, + df: pl.LazyFrame, + aggregations: Sequence[ + Tuple[agg_expressions.Aggregation, identifiers.ColumnId] + ], + grouping_keys: Tuple[ex.DerefOp, ...], + ) -> pl.LazyFrame: + # Need to materialize columns to broadcast constants + agg_inputs = [ + list( + map( + lambda x: x.alias(guid.generate_guid()), + self.agg_compiler.get_args(agg), + ) + ) + for agg, _ in aggregations + ] + + df_agg_inputs = df.with_columns(itertools.chain(*agg_inputs)) + + agg_exprs = [ + self.agg_compiler.compile_agg_op( + agg.op, list(map(lambda x: x.meta.output_name(), inputs)) + ).alias(id.sql) + for (agg, id), inputs in zip(aggregations, agg_inputs) + ] + + if len(grouping_keys) > 0: + group_exprs = [pl.col(ref.id.sql) for ref in grouping_keys] + grouped_df = df_agg_inputs.group_by(group_exprs) + return grouped_df.agg(agg_exprs).sort(group_exprs, nulls_last=True) + else: + return df_agg_inputs.select(agg_exprs) + + @compile_node.register + def compile_explode(self, node: nodes.ExplodeNode): + assert node.offsets_col is None + df = self.compile_node(node.child) + cols = [col.id.sql for col in node.column_ids] + return df.explode(cols) + + @compile_node.register + def compile_sample(self, node: nodes.RandomSampleNode): + df = self.compile_node(node.child) + # Sample is not available on lazyframe + return df.collect().sample(fraction=node.fraction).lazy() + + @compile_node.register + def compile_window(self, node: nodes.WindowOpNode): + df = self.compile_node(node.child) + + window = node.window_spec + # Should have been handled by reweriter + assert len(window.ordering) == 0 + if window.min_periods > 0: + raise NotImplementedError( + "min_period not yet supported for polars engine" ) - return df.with_columns([agg_expr]) - else: # row-bounded window + result = df + for cdef in node.agg_exprs: + assert isinstance(cdef.expression, agg_expressions.Aggregation) + if (window.bounds is None) or (window.is_unbounded): + # polars will automatically broadcast the aggregate to the matching input rows + agg_pl = self.agg_compiler.compile_agg_expr(cdef.expression) + if window.grouping_keys: + agg_pl = agg_pl.over( + self.expr_compiler.compile_expression(key) + for key in window.grouping_keys + ) + result = result.with_columns(agg_pl.alias(cdef.id.sql)) + else: # row-bounded window + window_result = self._calc_row_analytic_func( + result, cdef.expression, node.window_spec, cdef.id.sql + ) + result = pl.concat([result, window_result], how="horizontal") + return result + + def _calc_row_analytic_func( + self, + frame: pl.LazyFrame, + agg_expr: agg_expressions.Aggregation, + window: window_spec.WindowSpec, + name: str, + ) -> pl.LazyFrame: + if not isinstance(window.bounds, window_spec.RowsWindowBounds): + raise NotImplementedError("Only row bounds supported by polars engine") + groupby = None + if len(window.grouping_keys) > 0: + groupby = [ + self.expr_compiler.compile_expression(ref) + for ref in window.grouping_keys + ] + # Polars API semi-bounded, and any grouped rolling window challenging # https://github.com/pola-rs/polars/issues/4799 # https://github.com/pola-rs/polars/issues/8976 + pl_agg_expr = self.agg_compiler.compile_agg_expr(agg_expr).alias(name) index_col_name = "_bf_pl_engine_offsets" - indexed_df = df.with_row_index(index_col_name) - if len(window.grouping_keys) == 0: # rolling-only window - # https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.rolling.html - finite = ( - window.bounds.preceding is not None - and window.bounds.following is not None - ) - offset_n = ( - None - if window.bounds.preceding is None - else -window.bounds.preceding - ) - # collecting height is a massive kludge - period_n = ( - df.collect().height - if not finite - else cast(int, window.bounds.preceding) - + cast(int, window.bounds.following) - + 1 - ) - results = indexed_df.rolling( + indexed_df = frame.with_row_index(index_col_name) + # https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.rolling.html + period_n, offset_n = _get_period_and_offset(window.bounds) + return ( + indexed_df.rolling( index_column=index_col_name, period=f"{period_n}i", - offset=f"{offset_n}i" if offset_n else None, - ).agg(agg_expr) - else: # groupby-rolling window - raise NotImplementedError( - "Groupby rolling windows not yet implemented in polars engine" + offset=f"{offset_n}i" if (offset_n is not None) else None, + group_by=groupby, ) - # polars is columnar, so this is efficient - # TODO: why can't just add columns? - return pl.concat([df, results], how="horizontal") + .agg(pl_agg_expr) + .select(name) + ) + + +def _get_period_and_offset( + bounds: window_spec.RowsWindowBounds, +) -> tuple[int, Optional[int]]: + # fixed size window + if (bounds.start is not None) and (bounds.end is not None): + return ((bounds.end - bounds.start + 1), bounds.start - 1) + + LARGE_N = 1000000000 + if bounds.start is not None: + return (LARGE_N, bounds.start - 1) + if bounds.end is not None: + return (LARGE_N, None) + raise ValueError("Not a bounded window") diff --git a/bigframes/core/compile/polars/lowering.py b/bigframes/core/compile/polars/lowering.py new file mode 100644 index 0000000000..bf617d6879 --- /dev/null +++ b/bigframes/core/compile/polars/lowering.py @@ -0,0 +1,472 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import dataclasses +from typing import cast + +import numpy as np +import pandas as pd + +from bigframes import dtypes +from bigframes.core import bigframe_node, expression +from bigframes.core.rewrite import op_lowering +from bigframes.operations import ( + comparison_ops, + datetime_ops, + generic_ops, + json_ops, + numeric_ops, + string_ops, +) +import bigframes.operations as ops + +# TODO: Would be more precise to actually have separate op set for polars ops (where they diverge from the original ops) + + +@dataclasses.dataclass +class CoerceArgsRule(op_lowering.OpLoweringRule): + op_type: type[ops.BinaryOp] + + @property + def op(self) -> type[ops.ScalarOp]: + return self.op_type + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, self.op_type) + larg, rarg = _coerce_comparables(expr.children[0], expr.children[1]) + return expr.op.as_expr(larg, rarg) + + +class LowerAddRule(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return numeric_ops.AddOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, numeric_ops.AddOp) + larg, rarg = expr.children[0], expr.children[1] + + if ( + larg.output_type == dtypes.BOOL_DTYPE + and rarg.output_type == dtypes.BOOL_DTYPE + ): + int_result = expr.op.as_expr( + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(larg), + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(rarg), + ) + return ops.AsTypeOp(to_type=dtypes.BOOL_DTYPE).as_expr(int_result) + + if dtypes.is_string_like(larg.output_type) and dtypes.is_string_like( + rarg.output_type + ): + return ops.strconcat_op.as_expr(larg, rarg) + + if larg.output_type == dtypes.BOOL_DTYPE: + larg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(larg) + if rarg.output_type == dtypes.BOOL_DTYPE: + rarg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(rarg) + + if ( + larg.output_type == dtypes.DATE_DTYPE + and rarg.output_type == dtypes.TIMEDELTA_DTYPE + ): + larg = ops.AsTypeOp(to_type=dtypes.DATETIME_DTYPE).as_expr(larg) + + if ( + larg.output_type == dtypes.TIMEDELTA_DTYPE + and rarg.output_type == dtypes.DATE_DTYPE + ): + rarg = ops.AsTypeOp(to_type=dtypes.DATETIME_DTYPE).as_expr(rarg) + + return expr.op.as_expr(larg, rarg) + + +class LowerSubRule(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return numeric_ops.SubOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, numeric_ops.SubOp) + larg, rarg = expr.children[0], expr.children[1] + + if ( + larg.output_type == dtypes.BOOL_DTYPE + and rarg.output_type == dtypes.BOOL_DTYPE + ): + int_result = expr.op.as_expr( + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(larg), + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(rarg), + ) + return ops.AsTypeOp(to_type=dtypes.BOOL_DTYPE).as_expr(int_result) + + if larg.output_type == dtypes.BOOL_DTYPE: + larg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(larg) + if rarg.output_type == dtypes.BOOL_DTYPE: + rarg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(rarg) + + if ( + larg.output_type == dtypes.DATE_DTYPE + and rarg.output_type == dtypes.TIMEDELTA_DTYPE + ): + larg = ops.AsTypeOp(to_type=dtypes.DATETIME_DTYPE).as_expr(larg) + + return expr.op.as_expr(larg, rarg) + + +@dataclasses.dataclass +class LowerMulRule(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return numeric_ops.MulOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, numeric_ops.MulOp) + larg, rarg = expr.children[0], expr.children[1] + + if ( + larg.output_type == dtypes.BOOL_DTYPE + and rarg.output_type == dtypes.BOOL_DTYPE + ): + int_result = expr.op.as_expr( + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(larg), + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(rarg), + ) + return ops.AsTypeOp(to_type=dtypes.BOOL_DTYPE).as_expr(int_result) + + if ( + larg.output_type == dtypes.BOOL_DTYPE + and rarg.output_type != dtypes.BOOL_DTYPE + ): + larg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(larg) + if ( + rarg.output_type == dtypes.BOOL_DTYPE + and larg.output_type != dtypes.BOOL_DTYPE + ): + rarg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(rarg) + + return expr.op.as_expr(larg, rarg) + + +class LowerDivRule(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return numeric_ops.DivOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, numeric_ops.DivOp) + + dividend = expr.children[0] + divisor = expr.children[1] + + if dividend.output_type == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric( + divisor.output_type + ): + # exact same as floordiv impl for timedelta + numeric_result = ops.floordiv_op.as_expr( + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(dividend), divisor + ) + int_result = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(numeric_result) + return ops.AsTypeOp(to_type=dtypes.TIMEDELTA_DTYPE).as_expr(int_result) + + if ( + dividend.output_type == dtypes.BOOL_DTYPE + and divisor.output_type == dtypes.BOOL_DTYPE + ): + int_result = expr.op.as_expr( + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(dividend), + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(divisor), + ) + return ops.AsTypeOp(to_type=dtypes.BOOL_DTYPE).as_expr(int_result) + + # polars divide doesn't like bools, convert to int always + # convert numerics to float always + if dividend.output_type == dtypes.BOOL_DTYPE: + dividend = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(dividend) + elif dividend.output_type in (dtypes.BIGNUMERIC_DTYPE, dtypes.NUMERIC_DTYPE): + dividend = ops.AsTypeOp(to_type=dtypes.FLOAT_DTYPE).as_expr(dividend) + if divisor.output_type == dtypes.BOOL_DTYPE: + divisor = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(divisor) + + return numeric_ops.div_op.as_expr(dividend, divisor) + + +class LowerFloorDivRule(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return numeric_ops.FloorDivOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, numeric_ops.FloorDivOp) + + dividend = expr.children[0] + divisor = expr.children[1] + + if ( + dividend.output_type == dtypes.TIMEDELTA_DTYPE + and divisor.output_type == dtypes.TIMEDELTA_DTYPE + ): + int_result = expr.op.as_expr( + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(dividend), + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(divisor), + ) + return int_result + if dividend.output_type == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric( + divisor.output_type + ): + # this is pretty fragile as zero will break it, and must fit back into int + numeric_result = expr.op.as_expr( + ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(dividend), divisor + ) + int_result = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(numeric_result) + return ops.AsTypeOp(to_type=dtypes.TIMEDELTA_DTYPE).as_expr(int_result) + + if dividend.output_type == dtypes.BOOL_DTYPE: + dividend = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(dividend) + if divisor.output_type == dtypes.BOOL_DTYPE: + divisor = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(divisor) + + if expr.output_type != dtypes.FLOAT_DTYPE: + # need to guard against zero divisor + # multiply dividend in this case to propagate nulls + return ops.where_op.as_expr( + ops.mul_op.as_expr(dividend, expression.const(0)), + ops.eq_op.as_expr(divisor, expression.const(0)), + numeric_ops.floordiv_op.as_expr(dividend, divisor), + ) + else: + return expr.op.as_expr(dividend, divisor) + + +class LowerModRule(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return numeric_ops.ModOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + og_expr = expr + assert isinstance(expr.op, numeric_ops.ModOp) + larg, rarg = expr.children[0], expr.children[1] + + if ( + larg.output_type == dtypes.TIMEDELTA_DTYPE + and rarg.output_type == dtypes.TIMEDELTA_DTYPE + ): + larg_int = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(larg) + rarg_int = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(rarg) + int_result = expr.op.as_expr(larg_int, rarg_int) + w_zero_handling = ops.where_op.as_expr( + int_result, + ops.ne_op.as_expr(rarg_int, expression.const(0)), + ops.mul_op.as_expr(rarg_int, expression.const(0)), + ) + return ops.AsTypeOp(to_type=dtypes.TIMEDELTA_DTYPE).as_expr(w_zero_handling) + + if larg.output_type == dtypes.BOOL_DTYPE: + larg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(larg) + if rarg.output_type == dtypes.BOOL_DTYPE: + rarg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(rarg) + + wo_bools = expr.op.as_expr(larg, rarg) + + if og_expr.output_type == dtypes.INT_DTYPE: + return ops.where_op.as_expr( + wo_bools, + ops.ne_op.as_expr(rarg, expression.const(0)), + ops.mul_op.as_expr(rarg, expression.const(0)), + ) + return wo_bools + + +class LowerAsTypeRule(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return ops.AsTypeOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, ops.AsTypeOp) + return _lower_cast(expr.op, expr.inputs[0]) + + +def invert_bytes(byte_string): + inverted_bytes = ~np.frombuffer(byte_string, dtype=np.uint8) + return inverted_bytes.tobytes() + + +class LowerInvertOp(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return generic_ops.InvertOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, generic_ops.InvertOp) + arg = expr.children[0] + if arg.output_type == dtypes.BYTES_DTYPE: + return generic_ops.PyUdfOp(invert_bytes, dtypes.BYTES_DTYPE).as_expr( + expr.inputs[0] + ) + return expr + + +class LowerIsinOp(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return generic_ops.IsInOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, generic_ops.IsInOp) + arg = expr.children[0] + new_values = [] + match_nulls = False + for val in expr.op.values: + # coercible, non-coercible + # float NaN/inf should be treated as distinct from 'true' null values + if cast(bool, pd.isna(val)) and not isinstance(val, float): + if expr.op.match_nulls: + match_nulls = True + elif dtypes.is_compatible(val, arg.output_type): + new_values.append(val) + else: + pass + + new_isin = ops.IsInOp(tuple(new_values), match_nulls=False).as_expr(arg) + if match_nulls: + return ops.coalesce_op.as_expr(new_isin, expression.const(True)) + else: + # polars propagates nulls, so need to coalesce to false + return ops.coalesce_op.as_expr(new_isin, expression.const(False)) + + +class LowerLenOp(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return string_ops.LenOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, string_ops.LenOp) + arg = expr.children[0] + + if dtypes.is_string_like(arg.output_type): + return string_ops.StrLenOp().as_expr(arg) + elif dtypes.is_array_like(arg.output_type): + return string_ops.ArrayLenOp().as_expr(arg) + else: + raise ValueError(f"Unexpected type: {arg.output_type}") + + +def _coerce_comparables( + expr1: expression.Expression, + expr2: expression.Expression, + *, + bools_only: bool = False, +): + if bools_only: + if ( + expr1.output_type != dtypes.BOOL_DTYPE + and expr2.output_type != dtypes.BOOL_DTYPE + ): + return expr1, expr2 + + target_type = dtypes.coerce_to_common(expr1.output_type, expr2.output_type) + if expr1.output_type != target_type: + expr1 = _lower_cast(ops.AsTypeOp(target_type), expr1) + if expr2.output_type != target_type: + expr2 = _lower_cast(ops.AsTypeOp(target_type), expr2) + return expr1, expr2 + + +def _lower_cast(cast_op: ops.AsTypeOp, arg: expression.Expression): + if arg.output_type == cast_op.to_type: + return arg + + if arg.output_type == dtypes.JSON_DTYPE: + return json_ops.JSONDecode(cast_op.to_type).as_expr(arg) + if ( + arg.output_type == dtypes.STRING_DTYPE + and cast_op.to_type == dtypes.DATETIME_DTYPE + ): + return datetime_ops.ParseDatetimeOp().as_expr(arg) + if ( + arg.output_type == dtypes.STRING_DTYPE + and cast_op.to_type == dtypes.TIMESTAMP_DTYPE + ): + return datetime_ops.ParseTimestampOp().as_expr(arg) + # date -> string casting + if ( + arg.output_type == dtypes.DATETIME_DTYPE + and cast_op.to_type == dtypes.STRING_DTYPE + ): + return datetime_ops.StrftimeOp("%Y-%m-%d %H:%M:%S").as_expr(arg) + if arg.output_type == dtypes.TIME_DTYPE and cast_op.to_type == dtypes.STRING_DTYPE: + return datetime_ops.StrftimeOp("%H:%M:%S.%6f").as_expr(arg) + if ( + arg.output_type == dtypes.TIMESTAMP_DTYPE + and cast_op.to_type == dtypes.STRING_DTYPE + ): + return datetime_ops.StrftimeOp("%Y-%m-%d %H:%M:%S%.6f%:::z").as_expr(arg) + if arg.output_type == dtypes.BOOL_DTYPE and cast_op.to_type == dtypes.STRING_DTYPE: + # bool -> decimal needs two-step cast + new_arg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(arg) + is_true_cond = ops.eq_op.as_expr(arg, expression.const(True)) + is_false_cond = ops.eq_op.as_expr(arg, expression.const(False)) + return ops.CaseWhenOp().as_expr( + is_true_cond, + expression.const("True"), + is_false_cond, + expression.const("False"), + ) + if arg.output_type == dtypes.BOOL_DTYPE and dtypes.is_numeric(cast_op.to_type): + # bool -> decimal needs two-step cast + new_arg = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(arg) + return cast_op.as_expr(new_arg) + if arg.output_type == dtypes.TIME_DTYPE and dtypes.is_numeric(cast_op.to_type): + # polars cast gives nanoseconds, so convert to microseconds + return numeric_ops.floordiv_op.as_expr( + cast_op.as_expr(arg), expression.const(1000) + ) + if dtypes.is_numeric(arg.output_type) and cast_op.to_type == dtypes.TIME_DTYPE: + return cast_op.as_expr(ops.mul_op.as_expr(expression.const(1000), arg)) + return cast_op.as_expr(arg) + + +LOWER_COMPARISONS = tuple( + CoerceArgsRule(op) + for op in ( + comparison_ops.EqOp, + comparison_ops.EqNullsMatchOp, + comparison_ops.NeOp, + comparison_ops.LtOp, + comparison_ops.GtOp, + comparison_ops.LeOp, + comparison_ops.GeOp, + ) +) + +POLARS_LOWERING_RULES = ( + *LOWER_COMPARISONS, + LowerAddRule(), + LowerSubRule(), + LowerMulRule(), + LowerDivRule(), + LowerFloorDivRule(), + LowerModRule(), + LowerAsTypeRule(), + LowerInvertOp(), + LowerIsinOp(), + LowerLenOp(), +) + + +def lower_ops_to_polars(root: bigframe_node.BigFrameNode) -> bigframe_node.BigFrameNode: + return op_lowering.lower_ops(root, rules=POLARS_LOWERING_RULES) diff --git a/bigframes/core/compile/polars/operations/__init__.py b/bigframes/core/compile/polars/operations/__init__.py new file mode 100644 index 0000000000..26444dcb67 --- /dev/null +++ b/bigframes/core/compile/polars/operations/__init__.py @@ -0,0 +1,21 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Operation implementations for the Polars LazyFrame compiler. + +This directory structure should reflect the same layout as the +`bigframes/operations` directory where the operations are defined. + +Prefer small groups of ops per file to keep file sizes manageable for text editors and LLMs. +""" diff --git a/bigframes/core/compile/polars/operations/generic_ops.py b/bigframes/core/compile/polars/operations/generic_ops.py new file mode 100644 index 0000000000..4051fa4995 --- /dev/null +++ b/bigframes/core/compile/polars/operations/generic_ops.py @@ -0,0 +1,58 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +BigFrames -> Polars compilation for the operations in bigframes.operations.generic_ops. + +Please keep implementations in sequential order by op name. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bigframes.core.compile.polars.compiler as polars_compiler +from bigframes.operations import generic_ops + +if TYPE_CHECKING: + import polars as pl + + +@polars_compiler.register_op(generic_ops.NotNullOp) +def notnull_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: generic_ops.NotNullOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.is_not_null() + + +@polars_compiler.register_op(generic_ops.IsNullOp) +def isnull_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: generic_ops.IsNullOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.is_null() + + +@polars_compiler.register_op(generic_ops.PyUdfOp) +def py_udf_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: generic_ops.PyUdfOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.map_elements( + op.fn, return_dtype=polars_compiler._DTYPE_MAPPING[op._output_type] + ) diff --git a/bigframes/core/compile/polars/operations/numeric_ops.py b/bigframes/core/compile/polars/operations/numeric_ops.py new file mode 100644 index 0000000000..440415014e --- /dev/null +++ b/bigframes/core/compile/polars/operations/numeric_ops.py @@ -0,0 +1,172 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +BigFrames -> Polars compilation for the operations in bigframes.operations.numeric_ops. + +Please keep implementations in sequential order by op name. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bigframes.core.compile.polars.compiler as polars_compiler +from bigframes.operations import numeric_ops + +if TYPE_CHECKING: + import polars as pl + + +@polars_compiler.register_op(numeric_ops.LnOp) +def ln_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.LnOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + import polars as pl + + return pl.when(input <= 0).then(float("nan")).otherwise(input.log()) + + +@polars_compiler.register_op(numeric_ops.Log10Op) +def log10_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.Log10Op, # type: ignore + input: pl.Expr, +) -> pl.Expr: + import polars as pl + + return pl.when(input <= 0).then(float("nan")).otherwise(input.log(base=10)) + + +@polars_compiler.register_op(numeric_ops.Log1pOp) +def log1p_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.Log1pOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + import polars as pl + + return pl.when(input <= -1).then(float("nan")).otherwise((input + 1).log()) + + +@polars_compiler.register_op(numeric_ops.SinOp) +def sin_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.sin() + + +@polars_compiler.register_op(numeric_ops.CosOp) +def cos_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.CosOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.cos() + + +@polars_compiler.register_op(numeric_ops.TanOp) +def tan_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.tan() + + +@polars_compiler.register_op(numeric_ops.SinhOp) +def sinh_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.sinh() + + +@polars_compiler.register_op(numeric_ops.CoshOp) +def cosh_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.CosOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.cosh() + + +@polars_compiler.register_op(numeric_ops.TanhOp) +def tanh_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.tanh() + + +@polars_compiler.register_op(numeric_ops.ArcsinOp) +def asin_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.ArcsinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.arcsin() + + +@polars_compiler.register_op(numeric_ops.ArccosOp) +def acos_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.ArccosOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.arccos() + + +@polars_compiler.register_op(numeric_ops.ArctanOp) +def atan_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.ArctanOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.arctan() + + +@polars_compiler.register_op(numeric_ops.SqrtOp) +def sqrt_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SqrtOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + import polars as pl + + return pl.when(input < 0).then(float("nan")).otherwise(input.sqrt()) + + +@polars_compiler.register_op(numeric_ops.IsNanOp) +def is_nan_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.IsNanOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.is_nan() + + +@polars_compiler.register_op(numeric_ops.IsFiniteOp) +def is_finite_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.IsFiniteOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.is_finite() diff --git a/bigframes/core/compile/polars/operations/struct_ops.py b/bigframes/core/compile/polars/operations/struct_ops.py new file mode 100644 index 0000000000..1573d4aa9b --- /dev/null +++ b/bigframes/core/compile/polars/operations/struct_ops.py @@ -0,0 +1,48 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +BigFrames -> Polars compilation for the operations in bigframes.operations.generic_ops. + +Please keep implementations in sequential order by op name. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bigframes_vendored.constants + +import bigframes.core.compile.polars.compiler as polars_compiler +from bigframes.operations import struct_ops + +if TYPE_CHECKING: + import polars as pl + + +@polars_compiler.register_op(struct_ops.StructFieldOp) +def struct_field_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: struct_ops.StructFieldOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + if isinstance(op.name_or_index, str): + name = op.name_or_index + else: + raise NotImplementedError( + "Referencing a struct field by number not implemented in polars compiler. " + f"{bigframes_vendored.constants.FEEDBACK_LINK}" + ) + + return input.struct.field(name) diff --git a/bigframes/core/compile/sqlglot/__init__.py b/bigframes/core/compile/sqlglot/__init__.py new file mode 100644 index 0000000000..9e3f123807 --- /dev/null +++ b/bigframes/core/compile/sqlglot/__init__.py @@ -0,0 +1,32 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from bigframes.core.compile.sqlglot.compiler import compile_sql +import bigframes.core.compile.sqlglot.expressions.ai_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.array_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.blob_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.bool_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.comparison_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.date_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.datetime_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.generic_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.geo_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.json_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.numeric_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.string_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.struct_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.timedelta_ops # noqa: F401 + +__all__ = ["compile_sql"] diff --git a/bigframes/core/compile/sqlglot/aggregate_compiler.py b/bigframes/core/compile/sqlglot/aggregate_compiler.py new file mode 100644 index 0000000000..b86ae196f6 --- /dev/null +++ b/bigframes/core/compile/sqlglot/aggregate_compiler.py @@ -0,0 +1,76 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes.core import agg_expressions, window_spec +from bigframes.core.compile.sqlglot.aggregations import ( + binary_compiler, + nullary_compiler, + ordered_unary_compiler, + unary_compiler, +) +from bigframes.core.compile.sqlglot.expressions import typed_expr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + + +def compile_aggregate( + aggregate: agg_expressions.Aggregation, + order_by: tuple[sge.Expression, ...], +) -> sge.Expression: + """Compiles BigFrames aggregation expression into SQLGlot expression.""" + if isinstance(aggregate, agg_expressions.NullaryAggregation): + return nullary_compiler.compile(aggregate.op) + if isinstance(aggregate, agg_expressions.UnaryAggregation): + column = typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(aggregate.arg), + aggregate.arg.output_type, + ) + if not aggregate.op.order_independent: + return ordered_unary_compiler.compile( + aggregate.op, column, order_by=order_by + ) + else: + return unary_compiler.compile(aggregate.op, column) + elif isinstance(aggregate, agg_expressions.BinaryAggregation): + left = typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(aggregate.left), + aggregate.left.output_type, + ) + right = typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(aggregate.right), + aggregate.right.output_type, + ) + return binary_compiler.compile(aggregate.op, left, right) + else: + raise ValueError(f"Unexpected aggregation: {aggregate}") + + +def compile_analytic( + aggregate: agg_expressions.Aggregation, + window: window_spec.WindowSpec, +) -> sge.Expression: + if isinstance(aggregate, agg_expressions.NullaryAggregation): + return nullary_compiler.compile(aggregate.op, window) + if isinstance(aggregate, agg_expressions.UnaryAggregation): + column = typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(aggregate.arg), + aggregate.arg.output_type, + ) + return unary_compiler.compile(aggregate.op, column, window) + elif isinstance(aggregate, agg_expressions.BinaryAggregation): + raise NotImplementedError("binary analytic operations not yet supported") + else: + raise ValueError(f"Unexpected analytic operation: {aggregate}") diff --git a/scripts/__init__.py b/bigframes/core/compile/sqlglot/aggregations/__init__.py similarity index 95% rename from scripts/__init__.py rename to bigframes/core/compile/sqlglot/aggregations/__init__.py index 6d5e14bcf4..0a2669d7a2 100644 --- a/scripts/__init__.py +++ b/bigframes/core/compile/sqlglot/aggregations/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2024 Google LLC +# Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py new file mode 100644 index 0000000000..856b5e2f3a --- /dev/null +++ b/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py @@ -0,0 +1,58 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import sqlglot.expressions as sge + +from bigframes.core import window_spec +import bigframes.core.compile.sqlglot.aggregations.op_registration as reg +from bigframes.core.compile.sqlglot.aggregations.windows import apply_window_if_present +import bigframes.core.compile.sqlglot.expressions.typed_expr as typed_expr +from bigframes.operations import aggregations as agg_ops + +BINARY_OP_REGISTRATION = reg.OpRegistration() + + +def compile( + op: agg_ops.WindowOp, + left: typed_expr.TypedExpr, + right: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return BINARY_OP_REGISTRATION[op](op, left, right, window=window) + + +@BINARY_OP_REGISTRATION.register(agg_ops.CorrOp) +def _( + op: agg_ops.CorrOp, + left: typed_expr.TypedExpr, + right: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + result = sge.func("CORR", left.expr, right.expr) + return apply_window_if_present(result, window) + + +@BINARY_OP_REGISTRATION.register(agg_ops.CovOp) +def _( + op: agg_ops.CovOp, + left: typed_expr.TypedExpr, + right: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + result = sge.func("COVAR_SAMP", left.expr, right.expr) + return apply_window_if_present(result, window) diff --git a/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py new file mode 100644 index 0000000000..a582a9d4c5 --- /dev/null +++ b/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py @@ -0,0 +1,53 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import sqlglot.expressions as sge + +from bigframes.core import window_spec +import bigframes.core.compile.sqlglot.aggregations.op_registration as reg +from bigframes.core.compile.sqlglot.aggregations.windows import apply_window_if_present +from bigframes.operations import aggregations as agg_ops + +NULLARY_OP_REGISTRATION = reg.OpRegistration() + + +def compile( + op: agg_ops.WindowOp, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return NULLARY_OP_REGISTRATION[op](op, window=window) + + +@NULLARY_OP_REGISTRATION.register(agg_ops.SizeOp) +def _( + op: agg_ops.SizeOp, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) + + +@NULLARY_OP_REGISTRATION.register(agg_ops.RowNumberOp) +def _( + op: agg_ops.RowNumberOp, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + result: sge.Expression = sge.func("ROW_NUMBER") + if window is None: + # ROW_NUMBER always needs an OVER clause. + return sge.Window(this=result) - 1 + return apply_window_if_present(result, window, include_framing_clauses=False) - 1 diff --git a/bigframes/core/compile/sqlglot/aggregations/op_registration.py b/bigframes/core/compile/sqlglot/aggregations/op_registration.py new file mode 100644 index 0000000000..a26429f27e --- /dev/null +++ b/bigframes/core/compile/sqlglot/aggregations/op_registration.py @@ -0,0 +1,56 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +from sqlglot import expressions as sge + +from bigframes.operations import aggregations as agg_ops + +# We should've been more specific about input types. Unfortunately, +# MyPy doesn't support more rigorous checks. +CompilationFunc = typing.Callable[..., sge.Expression] + + +class OpRegistration: + def __init__(self) -> None: + self._registered_ops: dict[str, CompilationFunc] = {} + + def register( + self, op: agg_ops.WindowOp | type[agg_ops.WindowOp] + ) -> typing.Callable[[CompilationFunc], CompilationFunc]: + def decorator(item: CompilationFunc): + def arg_checker(*args, **kwargs): + if not isinstance(args[0], agg_ops.WindowOp): + raise ValueError( + "The first parameter must be a window operator. " + f"Got {type(args[0])}" + ) + return item(*args, **kwargs) + + key = str(op) + if key in self._registered_ops: + raise ValueError(f"{key} is already registered") + self._registered_ops[key] = item + return arg_checker + + return decorator + + def __getitem__(self, op: str | agg_ops.WindowOp) -> CompilationFunc: + key = op if isinstance(op, type) else type(op) + if str(key) not in self._registered_ops: + raise ValueError(f"{key} is not registered") + return self._registered_ops[str(key)] diff --git a/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py new file mode 100644 index 0000000000..594d75fd3c --- /dev/null +++ b/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py @@ -0,0 +1,60 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +import bigframes.core.compile.sqlglot.aggregations.op_registration as reg +import bigframes.core.compile.sqlglot.expressions.typed_expr as typed_expr +from bigframes.operations import aggregations as agg_ops + +ORDERED_UNARY_OP_REGISTRATION = reg.OpRegistration() + + +def compile( + op: agg_ops.WindowOp, + column: typed_expr.TypedExpr, + *, + order_by: tuple[sge.Expression, ...] = (), +) -> sge.Expression: + return ORDERED_UNARY_OP_REGISTRATION[op](op, column, order_by=order_by) + + +@ORDERED_UNARY_OP_REGISTRATION.register(agg_ops.ArrayAggOp) +def _( + op: agg_ops.ArrayAggOp, + column: typed_expr.TypedExpr, + *, + order_by: tuple[sge.Expression, ...], +) -> sge.Expression: + expr = column.expr + if len(order_by) > 0: + expr = sge.Order(this=column.expr, expressions=list(order_by)) + return sge.IgnoreNulls(this=sge.ArrayAgg(this=expr)) + + +@ORDERED_UNARY_OP_REGISTRATION.register(agg_ops.StringAggOp) +def _( + op: agg_ops.StringAggOp, + column: typed_expr.TypedExpr, + *, + order_by: tuple[sge.Expression, ...], +) -> sge.Expression: + expr = column.expr + if len(order_by) > 0: + expr = sge.Order(this=expr, expressions=list(order_by)) + + expr = sge.GroupConcat(this=expr, separator=sge.convert(op.sep)) + return sge.func("COALESCE", expr, sge.convert("")) diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py new file mode 100644 index 0000000000..ec711c7fa1 --- /dev/null +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -0,0 +1,600 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import pandas as pd +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes.core import window_spec +import bigframes.core.compile.sqlglot.aggregations.op_registration as reg +from bigframes.core.compile.sqlglot.aggregations.windows import apply_window_if_present +import bigframes.core.compile.sqlglot.expressions.typed_expr as typed_expr +import bigframes.core.compile.sqlglot.sqlglot_ir as ir +from bigframes.operations import aggregations as agg_ops + +UNARY_OP_REGISTRATION = reg.OpRegistration() + + +def compile( + op: agg_ops.WindowOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return UNARY_OP_REGISTRATION[op](op, column, window=window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.AllOp) +def _( + op: agg_ops.AllOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # BQ will return null for empty column, result would be false in pandas. + result = apply_window_if_present(sge.func("LOGICAL_AND", column.expr), window) + return sge.func("IFNULL", result, sge.true()) + + +@UNARY_OP_REGISTRATION.register(agg_ops.AnyOp) +def _( + op: agg_ops.AnyOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + expr = apply_window_if_present(sge.func("LOGICAL_OR", expr), window) + + # BQ will return null for empty column, result would be false in pandas. + return sge.func("COALESCE", expr, sge.convert(False)) + + +@UNARY_OP_REGISTRATION.register(agg_ops.ApproxQuartilesOp) +def _( + op: agg_ops.ApproxQuartilesOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if window is not None: + raise NotImplementedError("Approx Quartiles with windowing is not supported.") + # APPROX_QUANTILES returns an array of the quartiles, so we need to index it. + # The op.quartile is 1-based for the quartile, but array is 0-indexed. + # The quartiles are Q0, Q1, Q2, Q3, Q4. op.quartile is 1, 2, or 3. + # The array has 5 elements (for N=4 intervals). + # So we want the element at index `op.quartile`. + approx_quantiles_expr = sge.func("APPROX_QUANTILES", column.expr, sge.convert(4)) + return sge.Bracket( + this=approx_quantiles_expr, + expressions=[sge.func("OFFSET", sge.convert(op.quartile))], + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.ApproxTopCountOp) +def _( + op: agg_ops.ApproxTopCountOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if window is not None: + raise NotImplementedError("Approx top count with windowing is not supported.") + return sge.func("APPROX_TOP_COUNT", column.expr, sge.convert(op.number)) + + +@UNARY_OP_REGISTRATION.register(agg_ops.AnyValueOp) +def _( + op: agg_ops.AnyValueOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present(sge.func("ANY_VALUE", column.expr), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.CountOp) +def _( + op: agg_ops.CountOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present(sge.func("COUNT", column.expr), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.CutOp) +def _( + op: agg_ops.CutOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if isinstance(op.bins, int): + case_expr = _cut_ops_w_int_bins(op, column, op.bins, window) + else: # Interpret as intervals + case_expr = _cut_ops_w_intervals(op, column, op.bins, window) + return case_expr + + +def _cut_ops_w_int_bins( + op: agg_ops.CutOp, + column: typed_expr.TypedExpr, + bins: int, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Case: + case_expr = sge.Case() + col_min = apply_window_if_present( + sge.func("MIN", column.expr), window or window_spec.WindowSpec() + ) + col_max = apply_window_if_present( + sge.func("MAX", column.expr), window or window_spec.WindowSpec() + ) + adj: sge.Expression = sge.Sub(this=col_max, expression=col_min) * sge.convert(0.001) + bin_width: sge.Expression = sge.func( + "IEEE_DIVIDE", + sge.Sub(this=col_max, expression=col_min), + sge.convert(bins), + ) + + for this_bin in range(bins): + value: sge.Expression + if op.labels is False: + value = ir._literal(this_bin, dtypes.INT_DTYPE) + elif isinstance(op.labels, typing.Iterable): + value = ir._literal(list(op.labels)[this_bin], dtypes.STRING_DTYPE) + else: + left_adj: sge.Expression = ( + adj if this_bin == 0 and op.right else sge.convert(0) + ) + right_adj: sge.Expression = ( + adj if this_bin == bins - 1 and not op.right else sge.convert(0) + ) + + left: sge.Expression = ( + col_min + sge.convert(this_bin) * bin_width - left_adj + ) + right: sge.Expression = ( + col_min + sge.convert(this_bin + 1) * bin_width + right_adj + ) + if op.right: + left_identifier = sge.Identifier(this="left_exclusive", quoted=True) + right_identifier = sge.Identifier(this="right_inclusive", quoted=True) + else: + left_identifier = sge.Identifier(this="left_inclusive", quoted=True) + right_identifier = sge.Identifier(this="right_exclusive", quoted=True) + + value = sge.Struct( + expressions=[ + sge.PropertyEQ(this=left_identifier, expression=left), + sge.PropertyEQ(this=right_identifier, expression=right), + ] + ) + + condition: sge.Expression + if this_bin == bins - 1: + condition = sge.Is(this=column.expr, expression=sge.Not(this=sge.Null())) + else: + if op.right: + condition = sge.LTE( + this=column.expr, + expression=(col_min + sge.convert(this_bin + 1) * bin_width), + ) + else: + condition = sge.LT( + this=column.expr, + expression=(col_min + sge.convert(this_bin + 1) * bin_width), + ) + case_expr = case_expr.when(condition, value) + return case_expr + + +def _cut_ops_w_intervals( + op: agg_ops.CutOp, + column: typed_expr.TypedExpr, + bins: typing.Iterable[typing.Tuple[typing.Any, typing.Any]], + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Case: + case_expr = sge.Case() + for this_bin, interval in enumerate(bins): + left: sge.Expression = ir._literal( + interval[0], dtypes.infer_literal_type(interval[0]) + ) + right: sge.Expression = ir._literal( + interval[1], dtypes.infer_literal_type(interval[1]) + ) + condition: sge.Expression + if op.right: + condition = sge.And( + this=sge.GT(this=column.expr, expression=left), + expression=sge.LTE(this=column.expr, expression=right), + ) + else: + condition = sge.And( + this=sge.GTE(this=column.expr, expression=left), + expression=sge.LT(this=column.expr, expression=right), + ) + + value: sge.Expression + if op.labels is False: + value = ir._literal(this_bin, dtypes.INT_DTYPE) + elif isinstance(op.labels, typing.Iterable): + value = ir._literal(list(op.labels)[this_bin], dtypes.STRING_DTYPE) + else: + if op.right: + left_identifier = sge.Identifier(this="left_exclusive", quoted=True) + right_identifier = sge.Identifier(this="right_inclusive", quoted=True) + else: + left_identifier = sge.Identifier(this="left_inclusive", quoted=True) + right_identifier = sge.Identifier(this="right_exclusive", quoted=True) + + value = sge.Struct( + expressions=[ + sge.PropertyEQ(this=left_identifier, expression=left), + sge.PropertyEQ(this=right_identifier, expression=right), + ] + ) + case_expr = case_expr.when(condition, value) + return case_expr + + +@UNARY_OP_REGISTRATION.register(agg_ops.DenseRankOp) +def _( + op: agg_ops.DenseRankOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present( + sge.func("DENSE_RANK"), window, include_framing_clauses=False + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.FirstOp) +def _( + op: agg_ops.FirstOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # FIRST_VALUE in BQ respects nulls by default. + return apply_window_if_present(sge.FirstValue(this=column.expr), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.FirstNonNullOp) +def _( + op: agg_ops.FirstNonNullOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present( + sge.IgnoreNulls(this=sge.FirstValue(this=column.expr)), window + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.LastOp) +def _( + op: agg_ops.LastOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # LAST_VALUE in BQ respects nulls by default. + return apply_window_if_present(sge.LastValue(this=column.expr), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.LastNonNullOp) +def _( + op: agg_ops.LastNonNullOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present( + sge.IgnoreNulls(this=sge.LastValue(this=column.expr)), window + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.DiffOp) +def _( + op: agg_ops.DiffOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + shift_op_impl = UNARY_OP_REGISTRATION[agg_ops.ShiftOp(0)] + shifted = shift_op_impl(agg_ops.ShiftOp(op.periods), column, window) + if column.dtype == dtypes.BOOL_DTYPE: + return sge.NEQ(this=column.expr, expression=shifted) + + if column.dtype in (dtypes.INT_DTYPE, dtypes.FLOAT_DTYPE): + return sge.Sub(this=column.expr, expression=shifted) + + if column.dtype == dtypes.TIMESTAMP_DTYPE: + return sge.TimestampDiff( + this=column.expr, + expression=shifted, + unit=sge.Identifier(this="MICROSECOND"), + ) + + if column.dtype == dtypes.DATETIME_DTYPE: + return sge.DatetimeDiff( + this=column.expr, + expression=shifted, + unit=sge.Identifier(this="MICROSECOND"), + ) + + raise TypeError(f"Cannot perform diff on type {column.dtype}") + + +@UNARY_OP_REGISTRATION.register(agg_ops.MaxOp) +def _( + op: agg_ops.MaxOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present(sge.func("MAX", column.expr), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.MeanOp) +def _( + op: agg_ops.MeanOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=expr, to="INT64") + + expr = sge.func("AVG", expr) + + should_floor_result = ( + op.should_floor_result or column.dtype == dtypes.TIMEDELTA_DTYPE + ) + if should_floor_result: + expr = sge.Cast(this=sge.func("FLOOR", expr), to="INT64") + return apply_window_if_present(expr, window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.MedianOp) +def _( + op: agg_ops.MedianOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + approx_quantiles = sge.func("APPROX_QUANTILES", column.expr, sge.convert(2)) + return sge.Bracket( + this=approx_quantiles, expressions=[sge.func("OFFSET", sge.convert(1))] + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.MinOp) +def _( + op: agg_ops.MinOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present(sge.func("MIN", column.expr), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.NuniqueOp) +def _( + op: agg_ops.NuniqueOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present( + sge.func("COUNT", sge.Distinct(expressions=[column.expr])), window + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.PopVarOp) +def _( + op: agg_ops.PopVarOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=expr, to="INT64") + + expr = sge.func("VAR_POP", expr) + return apply_window_if_present(expr, window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.ProductOp) +def _( + op: agg_ops.ProductOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # Need to short-circuit as log with zeroes is illegal sql + is_zero = sge.EQ(this=column.expr, expression=sge.convert(0)) + + # There is no product sql aggregate function, so must implement as a sum of logs, and then + # apply power after. Note, log and power base must be equal! This impl uses natural log. + logs = ( + sge.Case() + .when(is_zero, sge.convert(0)) + .else_(sge.func("LN", sge.func("ABS", column.expr))) + ) + logs_sum = apply_window_if_present(sge.func("SUM", logs), window) + magnitude = sge.func("EXP", logs_sum) + + # Can't determine sign from logs, so have to determine parity of count of negative inputs + is_negative = ( + sge.Case() + .when( + sge.LT(this=sge.func("SIGN", column.expr), expression=sge.convert(0)), + sge.convert(1), + ) + .else_(sge.convert(0)) + ) + negative_count = apply_window_if_present(sge.func("SUM", is_negative), window) + negative_count_parity = sge.Mod( + this=negative_count, expression=sge.convert(2) + ) # 1 if result should be negative, otherwise 0 + + any_zeroes = apply_window_if_present(sge.func("LOGICAL_OR", is_zero), window) + + float_result = ( + sge.Case() + .when(any_zeroes, sge.convert(0)) + .else_( + sge.Mul( + this=magnitude, + expression=sge.If( + this=sge.EQ(this=negative_count_parity, expression=sge.convert(1)), + true=sge.convert(-1), + false=sge.convert(1), + ), + ) + ) + ) + return float_result + + +@UNARY_OP_REGISTRATION.register(agg_ops.QcutOp) +def _( + op: agg_ops.QcutOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + percent_ranks_order_by = sge.Ordered(this=column.expr, desc=False) + percent_ranks = apply_window_if_present( + sge.func("PERCENT_RANK"), + window, + include_framing_clauses=False, + order_by_override=[percent_ranks_order_by], + ) + if isinstance(op.quantiles, int): + scaled_rank = percent_ranks * sge.convert(op.quantiles) + # Calculate the 0-based bucket index. + bucket_index = sge.func("CEIL", scaled_rank) - sge.convert(1) + safe_bucket_index = sge.func("GREATEST", bucket_index, 0) + + return sge.If( + this=sge.Is(this=column.expr, expression=sge.Null()), + true=sge.Null(), + false=sge.Cast(this=safe_bucket_index, to="INT64"), + ) + else: + case = sge.Case() + first_quantile = sge.convert(op.quantiles[0]) + case = case.when( + sge.LT(this=percent_ranks, expression=first_quantile), sge.Null() + ) + for bucket_n in range(len(op.quantiles) - 1): + quantile = sge.convert(op.quantiles[bucket_n + 1]) + bucket = sge.convert(bucket_n) + case = case.when(sge.LTE(this=percent_ranks, expression=quantile), bucket) + return case.else_(sge.Null()) + + +@UNARY_OP_REGISTRATION.register(agg_ops.QuantileOp) +def _( + op: agg_ops.QuantileOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # TODO: Support interpolation argument + # TODO: Support percentile_disc + result: sge.Expression = sge.func("PERCENTILE_CONT", column.expr, sge.convert(op.q)) + if window is None: + # PERCENTILE_CONT is a navigation function, not an aggregate function, so it always needs an OVER clause. + result = sge.Window(this=result) + else: + result = apply_window_if_present(result, window) + if op.should_floor_result: + result = sge.Cast(this=sge.func("FLOOR", result), to="INT64") + return result + + +@UNARY_OP_REGISTRATION.register(agg_ops.RankOp) +def _( + op: agg_ops.RankOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present( + sge.func("RANK"), window, include_framing_clauses=False + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.SizeUnaryOp) +def _( + op: agg_ops.SizeUnaryOp, + _, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.StdOp) +def _( + op: agg_ops.StdOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=expr, to="INT64") + + expr = sge.func("STDDEV", expr) + if op.should_floor_result or column.dtype == dtypes.TIMEDELTA_DTYPE: + expr = sge.Cast(this=sge.func("FLOOR", expr), to="INT64") + return apply_window_if_present(expr, window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.ShiftOp) +def _( + op: agg_ops.ShiftOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if op.periods == 0: # No-op + return column.expr + if op.periods > 0: + return apply_window_if_present( + sge.func("LAG", column.expr, sge.convert(op.periods)), + window, + include_framing_clauses=False, + ) + return apply_window_if_present( + sge.func("LEAD", column.expr, sge.convert(-op.periods)), + window, + include_framing_clauses=False, + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.SumOp) +def _( + op: agg_ops.SumOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=column.expr, to="INT64") + + expr = apply_window_if_present(sge.func("SUM", expr), window) + + # Will be null if all inputs are null. Pandas defaults to zero sum though. + zero = pd.to_timedelta(0) if column.dtype == dtypes.TIMEDELTA_DTYPE else 0 + return sge.func("IFNULL", expr, ir._literal(zero, column.dtype)) + + +@UNARY_OP_REGISTRATION.register(agg_ops.VarOp) +def _( + op: agg_ops.VarOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=expr, to="INT64") + + expr = sge.func("VAR_SAMP", expr) + return apply_window_if_present(expr, window) diff --git a/bigframes/core/compile/sqlglot/aggregations/windows.py b/bigframes/core/compile/sqlglot/aggregations/windows.py new file mode 100644 index 0000000000..d1a68b2ef7 --- /dev/null +++ b/bigframes/core/compile/sqlglot/aggregations/windows.py @@ -0,0 +1,180 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import typing + +import sqlglot.expressions as sge + +from bigframes.core import utils, window_spec +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +import bigframes.core.expression as ex +import bigframes.core.ordering as ordering_spec +import bigframes.dtypes as dtypes + + +def apply_window_if_present( + value: sge.Expression, + window: typing.Optional[window_spec.WindowSpec] = None, + include_framing_clauses: bool = True, + order_by_override: typing.Optional[typing.List[sge.Ordered]] = None, +) -> sge.Expression: + if window is None: + return value + + if window.is_row_bounded and not window.ordering: + raise ValueError("No ordering provided for ordered analytic function") + elif ( + not window.is_row_bounded + and not window.is_range_bounded + and not window.ordering + ): + # Unbound grouping window. + order_by = None + elif window.is_range_bounded: + order_by = get_window_order_by((window.ordering[0],)) + else: + order_by = get_window_order_by(window.ordering) + + order = None + if order_by_override is not None and len(order_by_override) > 0: + order = sge.Order(expressions=order_by_override) + elif order_by: + order = sge.Order(expressions=order_by) + + group_by = ( + [_compile_group_by_key(key) for key in window.grouping_keys] + if window.grouping_keys + else None + ) + + # This is the key change. Don't create a spec for the default window frame + # if there's no ordering. This avoids generating an `ORDER BY NULL` clause. + if window.is_unbounded and not order: + return sge.Window(this=value, partition_by=group_by) + + if window.is_unbounded and not include_framing_clauses: + return sge.Window(this=value, partition_by=group_by, order=order) + + kind = ( + "RANGE" if isinstance(window.bounds, window_spec.RangeWindowBounds) else "ROWS" + ) + + start: typing.Union[int, float, None] = None + end: typing.Union[int, float, None] = None + if isinstance(window.bounds, window_spec.RangeWindowBounds): + if window.bounds.start is not None: + start = utils.timedelta_to_micros(window.bounds.start) + if window.bounds.end is not None: + end = utils.timedelta_to_micros(window.bounds.end) + elif window.bounds: + start = window.bounds.start + end = window.bounds.end + + start_value, start_side = _get_window_bounds(start, is_preceding=True) + end_value, end_side = _get_window_bounds(end, is_preceding=False) + + spec = sge.WindowSpec( + kind=kind, + start=start_value, + start_side=start_side, + end=end_value, + end_side=end_side, + over="OVER", + ) + + return sge.Window(this=value, partition_by=group_by, order=order, spec=spec) + + +def get_window_order_by( + ordering: typing.Tuple[ordering_spec.OrderingExpression, ...], + override_null_order: bool = False, +) -> typing.Optional[tuple[sge.Ordered, ...]]: + """Returns the SQL order by clause for a window specification. + Args: + ordering (Tuple[ordering_spec.OrderingExpression, ...]): + A tuple of ordering specification objects. + override_null_order (bool): + If True, overrides BigQuery's default null ordering behavior, which + is sometimes incompatible with ordered aggregations. The generated SQL + will include extra expressions to correctly enforce NULL FIRST/LAST. + """ + if not ordering: + return None + + order_by = [] + for ordering_spec_item in ordering: + expr = scalar_compiler.scalar_op_compiler.compile_expression( + ordering_spec_item.scalar_expression + ) + desc = not ordering_spec_item.direction.is_ascending + nulls_first = not ordering_spec_item.na_last + + if override_null_order: + is_null_expr = sge.Is(this=expr, expression=sge.Null()) + if nulls_first and desc: + order_by.append( + sge.Ordered( + this=is_null_expr, + desc=desc, + nulls_first=nulls_first, + ) + ) + elif (not nulls_first) and (not desc): + order_by.append( + sge.Ordered( + this=is_null_expr, + desc=desc, + nulls_first=nulls_first, + ) + ) + + order_by.append( + sge.Ordered( + this=expr, + desc=desc, + nulls_first=nulls_first, + ) + ) + return tuple(order_by) + + +def _get_window_bounds( + value, is_preceding: bool +) -> tuple[typing.Union[str, sge.Expression], typing.Optional[str]]: + """Compiles a single boundary value into its SQL components.""" + if value is None: + side = "PRECEDING" if is_preceding else "FOLLOWING" + return "UNBOUNDED", side + + if value == 0: + return "CURRENT ROW", None + + side = "PRECEDING" if value < 0 else "FOLLOWING" + return sge.convert(abs(value)), side + + +def _compile_group_by_key(key: ex.Expression) -> sge.Expression: + expr = scalar_compiler.scalar_op_compiler.compile_expression(key) + # The group_by keys has been rewritten by bind_schema_to_node + assert isinstance(key, ex.ResolvedDerefOp) + + # Some types need to be converted to another type to enable groupby + if key.dtype == dtypes.FLOAT_DTYPE: + expr = sge.Cast(this=expr, to="STRING") + elif key.dtype == dtypes.GEO_DTYPE: + expr = sge.Cast(this=expr, to="BYTES") + elif key.dtype == dtypes.JSON_DTYPE: + expr = sge.func("TO_JSON_STRING", expr) + return expr diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py new file mode 100644 index 0000000000..870e7064b8 --- /dev/null +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -0,0 +1,392 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import dataclasses +import functools +import typing + +import sqlglot.expressions as sge + +from bigframes.core import ( + agg_expressions, + expression, + guid, + identifiers, + nodes, + pyarrow_utils, + rewrite, +) +from bigframes.core.compile import configs +import bigframes.core.compile.sqlglot.aggregate_compiler as aggregate_compiler +from bigframes.core.compile.sqlglot.aggregations import windows +from bigframes.core.compile.sqlglot.expressions import typed_expr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +import bigframes.core.compile.sqlglot.sqlglot_ir as ir +import bigframes.core.ordering as bf_ordering +from bigframes.core.rewrite import schema_binding + + +def compile_sql(request: configs.CompileRequest) -> configs.CompileResult: + """Compiles a BigFrameNode according to the request into SQL using SQLGlot.""" + + # Generator for unique identifiers. + uid_gen = guid.SequentialUIDGenerator() + output_names = tuple((expression.DerefOp(id), id.sql) for id in request.node.ids) + result_node = nodes.ResultNode( + request.node, + output_cols=output_names, + limit=request.peek_count, + ) + if request.sort_rows: + # Can only pullup slice if we are doing ORDER BY in outermost SELECT + # Need to do this before replacing unsupported ops, as that will rewrite slice ops + result_node = rewrite.pull_up_limits(result_node) + result_node = _replace_unsupported_ops(result_node) + # prune before pulling up order to avoid unnnecessary row_number() ops + result_node = typing.cast(nodes.ResultNode, rewrite.column_pruning(result_node)) + result_node = rewrite.defer_order( + result_node, output_hidden_row_keys=request.materialize_all_order_keys + ) + if request.sort_rows: + result_node = typing.cast(nodes.ResultNode, rewrite.column_pruning(result_node)) + result_node = _remap_variables(result_node, uid_gen) + result_node = typing.cast( + nodes.ResultNode, rewrite.defer_selection(result_node) + ) + sql = _compile_result_node(result_node, uid_gen) + return configs.CompileResult( + sql, result_node.schema.to_bigquery(), result_node.order_by + ) + + ordering: typing.Optional[bf_ordering.RowOrdering] = result_node.order_by + result_node = dataclasses.replace(result_node, order_by=None) + result_node = typing.cast(nodes.ResultNode, rewrite.column_pruning(result_node)) + + result_node = _remap_variables(result_node, uid_gen) + result_node = typing.cast(nodes.ResultNode, rewrite.defer_selection(result_node)) + sql = _compile_result_node(result_node, uid_gen) + # Return the ordering iff no extra columns are needed to define the row order + if ordering is not None: + output_order = ( + ordering if ordering.referenced_columns.issubset(result_node.ids) else None + ) + assert (not request.materialize_all_order_keys) or (output_order is not None) + return configs.CompileResult(sql, result_node.schema.to_bigquery(), output_order) + + +def _remap_variables( + node: nodes.ResultNode, uid_gen: guid.SequentialUIDGenerator +) -> nodes.ResultNode: + """Remaps `ColumnId`s in the BFET of a `ResultNode` to produce deterministic UIDs.""" + + result_node, _ = rewrite.remap_variables( + node, map(identifiers.ColumnId, uid_gen.get_uid_stream("bfcol_")) + ) + return typing.cast(nodes.ResultNode, result_node) + + +def _compile_result_node( + root: nodes.ResultNode, uid_gen: guid.SequentialUIDGenerator +) -> str: + # Have to bind schema as the final step before compilation. + root = typing.cast(nodes.ResultNode, schema_binding.bind_schema_to_tree(root)) + selected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( + (name, scalar_compiler.scalar_op_compiler.compile_expression(ref)) + for ref, name in root.output_cols + ) + sqlglot_ir = compile_node(root.child, uid_gen).select(selected_cols) + + if root.order_by is not None: + ordering_cols = tuple( + sge.Ordered( + this=scalar_compiler.scalar_op_compiler.compile_expression( + ordering.scalar_expression + ), + desc=ordering.direction.is_ascending is False, + nulls_first=ordering.na_last is False, + ) + for ordering in root.order_by.all_ordering_columns + ) + sqlglot_ir = sqlglot_ir.order_by(ordering_cols) + + if root.limit is not None: + sqlglot_ir = sqlglot_ir.limit(root.limit) + + return sqlglot_ir.sql + + +@functools.lru_cache(maxsize=5000) +def compile_node( + node: nodes.BigFrameNode, uid_gen: guid.SequentialUIDGenerator +) -> ir.SQLGlotIR: + """Compiles the given BigFrameNode from bottem-up into SQLGlotIR.""" + bf_to_sqlglot: dict[nodes.BigFrameNode, ir.SQLGlotIR] = {} + child_results: tuple[ir.SQLGlotIR, ...] = () + for current_node in list(node.iter_nodes_topo()): + if current_node.child_nodes == (): + # For leaf node, generates a dumpy child to pass the UID generator. + child_results = tuple([ir.SQLGlotIR(uid_gen=uid_gen)]) + else: + # Child nodes should have been compiled in the reverse topological order. + child_results = tuple( + bf_to_sqlglot[child] for child in current_node.child_nodes + ) + result = _compile_node(current_node, *child_results) + bf_to_sqlglot[current_node] = result + + return bf_to_sqlglot[node] + + +@functools.singledispatch +def _compile_node( + node: nodes.BigFrameNode, *compiled_children: ir.SQLGlotIR +) -> ir.SQLGlotIR: + """Defines transformation but isn't cached, always use compile_node instead""" + raise ValueError(f"Can't compile unrecognized node: {node}") + + +@_compile_node.register +def compile_readlocal(node: nodes.ReadLocalNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + pa_table = node.local_data_source.data + pa_table = pa_table.select([item.source_id for item in node.scan_list.items]) + pa_table = pa_table.rename_columns([item.id.sql for item in node.scan_list.items]) + + offsets = node.offsets_col.sql if node.offsets_col else None + if offsets: + pa_table = pyarrow_utils.append_offsets(pa_table, offsets) + + return ir.SQLGlotIR.from_pyarrow(pa_table, node.schema, uid_gen=child.uid_gen) + + +@_compile_node.register +def compile_readtable(node: nodes.ReadTableNode, child: ir.SQLGlotIR): + table = node.source.table + return ir.SQLGlotIR.from_table( + table.project_id, + table.dataset_id, + table.table_id, + col_names=[col.source_id for col in node.scan_list.items], + alias_names=[col.id.sql for col in node.scan_list.items], + uid_gen=child.uid_gen, + sql_predicate=node.source.sql_predicate, + system_time=node.source.at_time, + ) + + +@_compile_node.register +def compile_selection(node: nodes.SelectionNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + selected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( + (id.sql, scalar_compiler.scalar_op_compiler.compile_expression(expr)) + for expr, id in node.input_output_pairs + ) + return child.select(selected_cols) + + +@_compile_node.register +def compile_projection(node: nodes.ProjectionNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + projected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( + (id.sql, scalar_compiler.scalar_op_compiler.compile_expression(expr)) + for expr, id in node.assignments + ) + return child.project(projected_cols) + + +@_compile_node.register +def compile_filter(node: nodes.FilterNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + condition = scalar_compiler.scalar_op_compiler.compile_expression(node.predicate) + return child.filter(tuple([condition])) + + +@_compile_node.register +def compile_join( + node: nodes.JoinNode, left: ir.SQLGlotIR, right: ir.SQLGlotIR +) -> ir.SQLGlotIR: + conditions = tuple( + ( + typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(left), + left.output_type, + ), + typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(right), + right.output_type, + ), + ) + for left, right in node.conditions + ) + + return left.join( + right, + join_type=node.type, + conditions=conditions, + joins_nulls=node.joins_nulls, + ) + + +@_compile_node.register +def compile_isin_join( + node: nodes.InNode, left: ir.SQLGlotIR, right: ir.SQLGlotIR +) -> ir.SQLGlotIR: + right_field = node.right_child.fields[0] + conditions = ( + typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(node.left_col), + node.left_col.output_type, + ), + typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression( + expression.DerefOp(right_field.id) + ), + right_field.dtype, + ), + ) + + return left.isin_join( + right, + indicator_col=node.indicator_col.sql, + conditions=conditions, + joins_nulls=node.joins_nulls, + ) + + +@_compile_node.register +def compile_concat(node: nodes.ConcatNode, *children: ir.SQLGlotIR) -> ir.SQLGlotIR: + assert len(children) >= 1 + uid_gen = children[0].uid_gen + + output_ids = [id.sql for id in node.output_ids] + return ir.SQLGlotIR.from_union( + [child.expr for child in children], + output_ids=output_ids, + uid_gen=uid_gen, + ) + + +@_compile_node.register +def compile_explode(node: nodes.ExplodeNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + offsets_col = node.offsets_col.sql if (node.offsets_col is not None) else None + columns = tuple(ref.id.sql for ref in node.column_ids) + return child.explode(columns, offsets_col) + + +@_compile_node.register +def compile_random_sample( + node: nodes.RandomSampleNode, child: ir.SQLGlotIR +) -> ir.SQLGlotIR: + return child.sample(node.fraction) + + +@_compile_node.register +def compile_aggregate(node: nodes.AggregateNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + # The BigQuery ordered aggregation cannot support for NULL FIRST/LAST, + # so we need to add extra expressions to enforce the null ordering. + ordering_cols = windows.get_window_order_by(node.order_by, override_null_order=True) + aggregations: tuple[tuple[str, sge.Expression], ...] = tuple( + ( + id.sql, + aggregate_compiler.compile_aggregate( + agg, order_by=ordering_cols if ordering_cols else () + ), + ) + for agg, id in node.aggregations + ) + by_cols: tuple[sge.Expression, ...] = tuple( + scalar_compiler.scalar_op_compiler.compile_expression(by_col) + for by_col in node.by_column_ids + ) + + dropna_cols = [] + if node.dropna: + for key, by_col in zip(node.by_column_ids, by_cols): + if node.child.field_by_id[key.id].nullable: + dropna_cols.append(by_col) + + return child.aggregate(aggregations, by_cols, tuple(dropna_cols)) + + +@_compile_node.register +def compile_window(node: nodes.WindowOpNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + window_spec = node.window_spec + result = child + for cdef in node.agg_exprs: + assert isinstance(cdef.expression, agg_expressions.Aggregation) + if cdef.expression.op.order_independent and window_spec.is_unbounded: + # notably percentile_cont does not support ordering clause + window_spec = window_spec.without_order() + + window_op = aggregate_compiler.compile_analytic(cdef.expression, window_spec) + + inputs: tuple[sge.Expression, ...] = tuple( + scalar_compiler.scalar_op_compiler.compile_expression( + expression.DerefOp(column) + ) + for column in cdef.expression.column_references + ) + + clauses: list[tuple[sge.Expression, sge.Expression]] = [] + if window_spec.min_periods and len(inputs) > 0: + if not cdef.expression.op.nulls_count_for_min_values: + # Most operations do not count NULL values towards min_periods + not_null_columns = [ + sge.Not(this=sge.Is(this=column, expression=sge.Null())) + for column in inputs + ] + # All inputs must be non-null for observation to count + if not not_null_columns: + is_observation_expr: sge.Expression = sge.convert(True) + else: + is_observation_expr = not_null_columns[0] + for expr in not_null_columns[1:]: + is_observation_expr = sge.And( + this=is_observation_expr, expression=expr + ) + is_observation = ir._cast(is_observation_expr, "INT64") + observation_count = windows.apply_window_if_present( + sge.func("SUM", is_observation), window_spec + ) + else: + # Operations like count treat even NULLs as valid observations + # for the sake of min_periods notnull is just used to convert + # null values to non-null (FALSE) values to be counted. + is_observation = ir._cast( + sge.Not(this=sge.Is(this=inputs[0], expression=sge.Null())), + "INT64", + ) + observation_count = windows.apply_window_if_present( + sge.func("COUNT", is_observation), window_spec + ) + + clauses.append( + ( + observation_count < sge.convert(window_spec.min_periods), + sge.Null(), + ) + ) + if clauses: + when_expressions = [sge.When(this=cond, true=res) for cond, res in clauses] + window_op = sge.Case(ifs=when_expressions, default=window_op) + + # TODO: check if we can directly window the expression. + result = result.window( + window_op=window_op, + output_column_id=cdef.id.sql, + ) + return result + + +def _replace_unsupported_ops(node: nodes.BigFrameNode): + node = nodes.bottom_up(node, rewrite.rewrite_slice) + node = nodes.bottom_up(node, rewrite.rewrite_range_rolling) + return node diff --git a/bigframes/core/compile/sqlglot/expressions/__init__.py b/bigframes/core/compile/sqlglot/expressions/__init__.py new file mode 100644 index 0000000000..f42d5c7d99 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/__init__.py @@ -0,0 +1,21 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Expression implementations for the SQLGlot-based compiler. + +This directory structure should reflect the same layout as the +`bigframes/operations` directory where the expressions are defined. + +Prefer a few ops per file to keep file sizes manageable for text editors and LLMs. +""" diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py new file mode 100644 index 0000000000..a8a36cb6c0 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -0,0 +1,148 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import asdict + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot import scalar_compiler +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr + +register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op + + +@register_nary_op(ops.AIGenerate, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIGenerate) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.GENERATE", *args) + + +@register_nary_op(ops.AIGenerateBool, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIGenerateBool) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.GENERATE_BOOL", *args) + + +@register_nary_op(ops.AIGenerateInt, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIGenerateInt) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.GENERATE_INT", *args) + + +@register_nary_op(ops.AIGenerateDouble, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIGenerateDouble) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.GENERATE_DOUBLE", *args) + + +@register_nary_op(ops.AIIf, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIIf) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.IF", *args) + + +@register_nary_op(ops.AIClassify, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIClassify) -> sge.Expression: + category_literals = [sge.Literal.string(cat) for cat in op.categories] + categories_arg = sge.Kwarg( + this="categories", expression=sge.array(*category_literals) + ) + + args = [ + _construct_prompt(exprs, op.prompt_context, param_name="input"), + categories_arg, + ] + _construct_named_args(op) + + return sge.func("AI.CLASSIFY", *args) + + +@register_nary_op(ops.AIScore, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIScore) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.SCORE", *args) + + +def _construct_prompt( + exprs: tuple[TypedExpr, ...], + prompt_context: tuple[str | None, ...], + param_name: str = "prompt", +) -> sge.Kwarg: + prompt: list[str | sge.Expression] = [] + column_ref_idx = 0 + + for elem in prompt_context: + if elem is None: + prompt.append(exprs[column_ref_idx].expr) + column_ref_idx += 1 + else: + prompt.append(sge.Literal.string(elem)) + + return sge.Kwarg(this=param_name, expression=sge.Tuple(expressions=prompt)) + + +def _construct_named_args(op: ops.NaryOp) -> list[sge.Kwarg]: + args = [] + + op_args = asdict(op) + + connection_id = op_args.get("connection_id", None) + if connection_id is not None: + args.append( + sge.Kwarg( + this="connection_id", expression=sge.Literal.string(connection_id) + ) + ) + + endpoit = op_args.get("endpoint", None) + if endpoit is not None: + args.append(sge.Kwarg(this="endpoint", expression=sge.Literal.string(endpoit))) + + request_type = op_args.get("request_type", None) + if request_type is not None: + args.append( + sge.Kwarg( + this="request_type", expression=sge.Literal.string(request_type.upper()) + ) + ) + + model_params = op_args.get("model_params", None) + if model_params is not None: + args.append( + sge.Kwarg( + this="model_params", + # sge.JSON requires the SQLGlot version to be at least 25.18.0 + # PARSE_JSON won't work as the function requires a JSON literal. + expression=sge.JSON(this=sge.Literal.string(model_params)), + ) + ) + + output_schema = op_args.get("output_schema", None) + if output_schema is not None: + args.append( + sge.Kwarg( + this="output_schema", + expression=sge.Literal.string(output_schema), + ) + ) + + return args diff --git a/bigframes/core/compile/sqlglot/expressions/array_ops.py b/bigframes/core/compile/sqlglot/expressions/array_ops.py new file mode 100644 index 0000000000..28b3693caf --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/array_ops.py @@ -0,0 +1,155 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import sqlglot as sg +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.string_ops import ( + string_index, + string_slice, +) +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +import bigframes.dtypes as dtypes + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op + + +@register_unary_op(ops.ArrayIndexOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArrayIndexOp) -> sge.Expression: + if expr.dtype == dtypes.STRING_DTYPE: + return string_index(expr, op.index) + + return sge.Bracket( + this=expr.expr, + expressions=[sge.convert(op.index)], + safe=True, + offset=False, + ) + + +@register_unary_op(ops.ArrayReduceOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArrayReduceOp) -> sge.Expression: + sub_expr = sg.to_identifier("bf_arr_reduce_uid") + sub_type = dtypes.get_array_inner_type(expr.dtype) + + if op.aggregation.order_independent: + from bigframes.core.compile.sqlglot.aggregations import unary_compiler + + agg_expr = unary_compiler.compile(op.aggregation, TypedExpr(sub_expr, sub_type)) + else: + from bigframes.core.compile.sqlglot.aggregations import ordered_unary_compiler + + agg_expr = ordered_unary_compiler.compile( + op.aggregation, TypedExpr(sub_expr, sub_type) + ) + + return ( + sge.select(agg_expr) + .from_( + sge.Unnest( + expressions=[expr.expr], + alias=sge.TableAlias(columns=[sub_expr]), + ) + ) + .subquery() + ) + + +@register_unary_op(ops.ArraySliceOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: + if expr.dtype == dtypes.STRING_DTYPE: + return string_slice(expr, op.start, op.stop) + else: + return _array_slice(expr, op) + + +@register_unary_op(ops.ArrayToStringOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArrayToStringOp) -> sge.Expression: + return sge.ArrayToString(this=expr.expr, expression=f"'{op.delimiter}'") + + +@register_nary_op(ops.ToArrayOp) +def _(*exprs: TypedExpr) -> sge.Expression: + do_upcast_bool = any( + dtypes.is_numeric(expr.dtype, include_bool=False) for expr in exprs + ) + if do_upcast_bool: + sg_exprs = [_coerce_bool_to_int(expr) for expr in exprs] + else: + sg_exprs = [expr.expr for expr in exprs] + return sge.Array(expressions=sg_exprs) + + +def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: + """Coerce boolean expression to integer.""" + if typed_expr.dtype == dtypes.BOOL_DTYPE: + return sge.Cast(this=typed_expr.expr, to="INT64") + return typed_expr.expr + + +def _string_slice(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: + # local name for each element in the array + el = sg.to_identifier("el") + # local name for the index in the array + slice_idx = sg.to_identifier("slice_idx") + + conditions: typing.List[sge.Predicate] = [slice_idx >= op.start] + if op.stop is not None: + conditions.append(slice_idx < op.stop) + + selected_elements = ( + sge.select(el) + .from_( + sge.Unnest( + expressions=[expr.expr], + alias=sge.TableAlias(columns=[el]), + offset=slice_idx, + ) + ) + .where(*conditions) + ) + + return sge.array(selected_elements) + + +def _array_slice(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: + # local name for each element in the array + el = sg.to_identifier("el") + # local name for the index in the array + slice_idx = sg.to_identifier("slice_idx") + + conditions: typing.List[sge.Predicate] = [slice_idx >= op.start] + if op.stop is not None: + conditions.append(slice_idx < op.stop) + + selected_elements = ( + sge.select(el) + .from_( + sge.Unnest( + expressions=[expr.expr], + alias=sge.TableAlias(columns=[el]), + offset=slice_idx, + ) + ) + .where(*conditions) + ) + + return sge.array(selected_elements) diff --git a/bigframes/core/compile/sqlglot/expressions/blob_ops.py b/bigframes/core/compile/sqlglot/expressions/blob_ops.py new file mode 100644 index 0000000000..03708f80c6 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/blob_ops.py @@ -0,0 +1,39 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +@register_unary_op(ops.obj_fetch_metadata_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("OBJ.FETCH_METADATA", expr.expr) + + +@register_unary_op(ops.ObjGetAccessUrl) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("OBJ.GET_ACCESS_URL", expr.expr) + + +@register_binary_op(ops.obj_make_ref_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("OBJ.MAKE_REF", left.expr, right.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/bool_ops.py b/bigframes/core/compile/sqlglot/expressions/bool_ops.py new file mode 100644 index 0000000000..41076b666a --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/bool_ops.py @@ -0,0 +1,47 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +@register_binary_op(ops.and_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.dtype == dtypes.BOOL_DTYPE and right.dtype == dtypes.BOOL_DTYPE: + return sge.And(this=left.expr, expression=right.expr) + return sge.BitwiseAnd(this=left.expr, expression=right.expr) + + +@register_binary_op(ops.or_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.dtype == dtypes.BOOL_DTYPE and right.dtype == dtypes.BOOL_DTYPE: + return sge.Or(this=left.expr, expression=right.expr) + return sge.BitwiseOr(this=left.expr, expression=right.expr) + + +@register_binary_op(ops.xor_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.dtype == dtypes.BOOL_DTYPE and right.dtype == dtypes.BOOL_DTYPE: + left_expr = sge.And(this=left.expr, expression=sge.Not(this=right.expr)) + right_expr = sge.And(this=sge.Not(this=left.expr), expression=right.expr) + return sge.Or(this=left_expr, expression=right_expr) + return sge.BitwiseXor(this=left.expr, expression=right.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py new file mode 100644 index 0000000000..89d3b4a682 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py @@ -0,0 +1,139 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import pandas as pd +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +@register_unary_op(ops.IsInOp, pass_op=True) +def _(expr: TypedExpr, op: ops.IsInOp) -> sge.Expression: + values = [] + is_numeric_expr = dtypes.is_numeric(expr.dtype) + for value in op.values: + if value is None: + continue + dtype = dtypes.bigframes_type(type(value)) + if expr.dtype == dtype or is_numeric_expr and dtypes.is_numeric(dtype): + values.append(sge.convert(value)) + + if op.match_nulls: + contains_nulls = any(_is_null(value) for value in op.values) + if contains_nulls: + return sge.Is(this=expr.expr, expression=sge.Null()) | sge.In( + this=expr.expr, expressions=values + ) + + if len(values) == 0: + return sge.convert(False) + + return sge.func( + "COALESCE", sge.In(this=expr.expr, expressions=values), sge.convert(False) + ) + + +@register_binary_op(ops.eq_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.EQ(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.eq_null_match_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = left.expr + if right.dtype != dtypes.BOOL_DTYPE: + left_expr = _coerce_bool_to_int(left) + + right_expr = right.expr + if left.dtype != dtypes.BOOL_DTYPE: + right_expr = _coerce_bool_to_int(right) + + sentinel = sge.convert("$NULL_SENTINEL$") + left_coalesce = sge.Coalesce( + this=sge.Cast(this=left_expr, to="STRING"), expressions=[sentinel] + ) + right_coalesce = sge.Coalesce( + this=sge.Cast(this=right_expr, to="STRING"), expressions=[sentinel] + ) + return sge.EQ(this=left_coalesce, expression=right_coalesce) + + +@register_binary_op(ops.ge_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.GTE(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.gt_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.GT(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.lt_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.LT(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.le_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.LTE(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.maximum_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Greatest(expressions=[left.expr, right.expr]) + + +@register_binary_op(ops.minimum_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Least(this=left.expr, expressions=right.expr) + + +@register_binary_op(ops.ne_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.NEQ(this=left_expr, expression=right_expr) + + +# Helpers +def _is_null(value) -> bool: + # float NaN/inf should be treated as distinct from 'true' null values + return typing.cast(bool, pd.isna(value)) and not isinstance(value, float) + + +def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: + """Coerce boolean expression to integer.""" + if typed_expr.dtype == dtypes.BOOL_DTYPE: + return sge.Cast(this=typed_expr.expr, to="INT64") + return typed_expr.expr diff --git a/bigframes/core/compile/sqlglot/expressions/constants.py b/bigframes/core/compile/sqlglot/expressions/constants.py new file mode 100644 index 0000000000..e005a1ed78 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/constants.py @@ -0,0 +1,37 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import sqlglot.expressions as sge + +_ZERO = sge.Cast(this=sge.convert(0), to="INT64") +_NAN = sge.Cast(this=sge.convert("NaN"), to="FLOAT64") +_INF = sge.Cast(this=sge.convert("Infinity"), to="FLOAT64") +_NEG_INF = sge.Cast(this=sge.convert("-Infinity"), to="FLOAT64") + +# Approx Highest number you can pass in to EXP function and get a valid FLOAT64 result +# FLOAT64 has 11 exponent bits, so max values is about 2**(2**10) +# ln(2**(2**10)) == (2**10)*ln(2) ~= 709.78, so EXP(x) for x>709.78 will overflow. +_FLOAT64_EXP_BOUND = sge.convert(709.78) + +# The natural logarithm of the maximum value for a signed 64-bit integer. +# This is used to check for potential overflows in power operations involving integers +# by checking if `exponent * log(base)` exceeds this value. +_INT64_LOG_BOUND = math.log(2**63 - 1) + +# Represents the largest integer N where all integers from -N to N can be +# represented exactly as a float64. Float64 types have a 53-bit significand precision, +# so integers beyond this value may lose precision. +_FLOAT64_MAX_INT_PRECISION = 2**53 diff --git a/bigframes/core/compile/sqlglot/expressions/date_ops.py b/bigframes/core/compile/sqlglot/expressions/date_ops.py new file mode 100644 index 0000000000..be772d978d --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/date_ops.py @@ -0,0 +1,72 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.date_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Date(this=expr.expr) + + +@register_unary_op(ops.day_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="DAY"), expression=expr.expr) + + +@register_unary_op(ops.dayofweek_op) +def _(expr: TypedExpr) -> sge.Expression: + return dayofweek_op_impl(expr) + + +@register_unary_op(ops.dayofyear_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="DAYOFYEAR"), expression=expr.expr) + + +@register_unary_op(ops.iso_day_op) +def _(expr: TypedExpr) -> sge.Expression: + # Plus 1 because iso day of week uses 1-based indexing + return dayofweek_op_impl(expr) + sge.convert(1) + + +@register_unary_op(ops.iso_week_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="ISOWEEK"), expression=expr.expr) + + +@register_unary_op(ops.iso_year_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="ISOYEAR"), expression=expr.expr) + + +# Helpers +def dayofweek_op_impl(expr: TypedExpr) -> sge.Expression: + # BigQuery SQL Extract(DAYOFWEEK) returns 1 for Sunday through 7 for Saturday. + # We want 0 for Monday through 6 for Sunday to be compatible with Pandas. + extract_expr = sge.Extract( + this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr + ) + return sge.Cast( + this=sge.Mod(this=extract_expr + sge.convert(5), expression=sge.convert(7)), + to="INT64", + ) diff --git a/bigframes/core/compile/sqlglot/expressions/datetime_ops.py b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py new file mode 100644 index 0000000000..78e17ae33b --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py @@ -0,0 +1,438 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes import operations as ops +from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +def _calculate_resample_first(y: TypedExpr, origin: str) -> sge.Expression: + if origin == "epoch": + return sge.convert(0) + elif origin == "start_day": + return sge.func( + "UNIX_MICROS", + sge.Cast( + this=sge.Cast( + this=y.expr, to=sge.DataType(this=sge.DataType.Type.DATE) + ), + to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ), + ), + ) + elif origin == "start": + return sge.func( + "UNIX_MICROS", + sge.Cast(this=y.expr, to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ)), + ) + else: + raise ValueError(f"Origin {origin} not supported") + + +@register_binary_op(ops.DatetimeToIntegerLabelOp, pass_op=True) +def datetime_to_integer_label_op( + x: TypedExpr, y: TypedExpr, op: ops.DatetimeToIntegerLabelOp +) -> sge.Expression: + # Determine if the frequency is fixed by checking if 'op.freq.nanos' is defined. + try: + return _datetime_to_integer_label_fixed_frequency(x, y, op) + except ValueError: + return _datetime_to_integer_label_non_fixed_frequency(x, y, op) + + +def _datetime_to_integer_label_fixed_frequency( + x: TypedExpr, y: TypedExpr, op: ops.DatetimeToIntegerLabelOp +) -> sge.Expression: + """ + This function handles fixed frequency conversions where the unit can range + from microseconds (us) to days. + """ + us = op.freq.nanos / 1000 + x_int = sge.func( + "UNIX_MICROS", + sge.Cast(this=x.expr, to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ)), + ) + first = _calculate_resample_first(y, op.origin) # type: ignore + x_int_label = sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub(this=x_int, expression=first), + sge.convert(int(us)), + ) + ), + to=sge.DataType.build("INT64"), + ) + return x_int_label + + +def _datetime_to_integer_label_non_fixed_frequency( + x: TypedExpr, y: TypedExpr, op: ops.DatetimeToIntegerLabelOp +) -> sge.Expression: + """ + This function handles non-fixed frequency conversions for units ranging + from weeks to years. + """ + rule_code = op.freq.rule_code + n = op.freq.n + if rule_code == "W-SUN": # Weekly + us = n * 7 * 24 * 60 * 60 * 1000000 + x_trunc = sge.TimestampTrunc(this=x.expr, unit=sge.Var(this="WEEK(MONDAY)")) + y_trunc = sge.TimestampTrunc(this=y.expr, unit=sge.Var(this="WEEK(MONDAY)")) + x_plus_6 = sge.Add( + this=x_trunc, + expression=sge.Interval( + this=sge.convert(6), unit=sge.Identifier(this="DAY") + ), + ) + y_plus_6 = sge.Add( + this=y_trunc, + expression=sge.Interval( + this=sge.convert(6), unit=sge.Identifier(this="DAY") + ), + ) + x_int = sge.func( + "UNIX_MICROS", + sge.Cast( + this=x_plus_6, to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ) + ), + ) + first = sge.func( + "UNIX_MICROS", + sge.Cast( + this=y_plus_6, to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ) + ), + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=x_int, expression=first), + true=sge.convert(0), + ) + ], + default=sge.Add( + this=sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub( + this=sge.Sub(this=x_int, expression=first), + expression=sge.convert(1), + ), + sge.convert(us), + ) + ), + to=sge.DataType.build("INT64"), + ), + expression=sge.convert(1), + ), + ) + elif rule_code == "ME": # Monthly + x_int = sge.Paren( # type: ignore + this=sge.Add( + this=sge.Mul( + this=sge.Extract( + this=sge.Identifier(this="YEAR"), expression=x.expr + ), + expression=sge.convert(12), + ), + expression=sge.Sub( + this=sge.Extract( + this=sge.Identifier(this="MONTH"), expression=x.expr + ), + expression=sge.convert(1), + ), + ) + ) + first = sge.Paren( # type: ignore + this=sge.Add( + this=sge.Mul( + this=sge.Extract( + this=sge.Identifier(this="YEAR"), expression=y.expr + ), + expression=sge.convert(12), + ), + expression=sge.Sub( + this=sge.Extract( + this=sge.Identifier(this="MONTH"), expression=y.expr + ), + expression=sge.convert(1), + ), + ) + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=x_int, expression=first), + true=sge.convert(0), + ) + ], + default=sge.Add( + this=sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub( + this=sge.Sub(this=x_int, expression=first), + expression=sge.convert(1), + ), + sge.convert(n), + ) + ), + to=sge.DataType.build("INT64"), + ), + expression=sge.convert(1), + ), + ) + elif rule_code == "QE-DEC": # Quarterly + x_int = sge.Paren( # type: ignore + this=sge.Add( + this=sge.Mul( + this=sge.Extract( + this=sge.Identifier(this="YEAR"), expression=x.expr + ), + expression=sge.convert(4), + ), + expression=sge.Sub( + this=sge.Extract( + this=sge.Identifier(this="QUARTER"), expression=x.expr + ), + expression=sge.convert(1), + ), + ) + ) + first = sge.Paren( # type: ignore + this=sge.Add( + this=sge.Mul( + this=sge.Extract( + this=sge.Identifier(this="YEAR"), expression=y.expr + ), + expression=sge.convert(4), + ), + expression=sge.Sub( + this=sge.Extract( + this=sge.Identifier(this="QUARTER"), expression=y.expr + ), + expression=sge.convert(1), + ), + ) + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=x_int, expression=first), + true=sge.convert(0), + ) + ], + default=sge.Add( + this=sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub( + this=sge.Sub(this=x_int, expression=first), + expression=sge.convert(1), + ), + sge.convert(n), + ) + ), + to=sge.DataType.build("INT64"), + ), + expression=sge.convert(1), + ), + ) + elif rule_code == "YE-DEC": # Yearly + x_int = sge.Extract(this=sge.Identifier(this="YEAR"), expression=x.expr) + first = sge.Extract(this=sge.Identifier(this="YEAR"), expression=y.expr) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=x_int, expression=first), + true=sge.convert(0), + ) + ], + default=sge.Add( + this=sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub( + this=sge.Sub(this=x_int, expression=first), + expression=sge.convert(1), + ), + sge.convert(n), + ) + ), + to=sge.DataType.build("INT64"), + ), + expression=sge.convert(1), + ), + ) + else: + raise ValueError(rule_code) + + +@register_unary_op(ops.FloorDtOp, pass_op=True) +def _(expr: TypedExpr, op: ops.FloorDtOp) -> sge.Expression: + pandas_to_bq_freq_map = { + "Y": "YEAR", + "Q": "QUARTER", + "M": "MONTH", + "W": "WEEK(MONDAY)", + "D": "DAY", + "h": "HOUR", + "min": "MINUTE", + "s": "SECOND", + "ms": "MILLISECOND", + "us": "MICROSECOND", + "ns": "NANOSECOND", + } + if op.freq not in pandas_to_bq_freq_map.keys(): + raise NotImplementedError( + f"Unsupported freq paramater: {op.freq}" + + " Supported freq parameters are: " + + ",".join(pandas_to_bq_freq_map.keys()) + ) + + bq_freq = pandas_to_bq_freq_map[op.freq] + return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this=bq_freq)) + + +@register_unary_op(ops.hour_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="HOUR"), expression=expr.expr) + + +@register_unary_op(ops.minute_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="MINUTE"), expression=expr.expr) + + +@register_unary_op(ops.month_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="MONTH"), expression=expr.expr) + + +@register_unary_op(ops.normalize_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this="DAY")) + + +@register_unary_op(ops.quarter_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="QUARTER"), expression=expr.expr) + + +@register_unary_op(ops.second_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="SECOND"), expression=expr.expr) + + +@register_unary_op(ops.StrftimeOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrftimeOp) -> sge.Expression: + func_name = "" + if expr.dtype == dtypes.DATE_DTYPE: + func_name = "FORMAT_DATE" + elif expr.dtype == dtypes.DATETIME_DTYPE: + func_name = "FORMAT_DATETIME" + elif expr.dtype == dtypes.TIME_DTYPE: + func_name = "FORMAT_TIME" + elif expr.dtype == dtypes.TIMESTAMP_DTYPE: + func_name = "FORMAT_TIMESTAMP" + + return sge.func(func_name, sge.convert(op.date_format), expr.expr) + + +@register_unary_op(ops.time_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TIME", expr.expr) + + +@register_unary_op(ops.ToDatetimeOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ToDatetimeOp) -> sge.Expression: + if op.format: + result = expr.expr + if expr.dtype != dtypes.STRING_DTYPE: + result = sge.Cast(this=result, to="STRING") + result = sge.func( + "PARSE_TIMESTAMP", sge.convert(op.format), result, sge.convert("UTC") + ) + return sge.Cast(this=result, to="DATETIME") + + if expr.dtype == dtypes.STRING_DTYPE: + return sge.TryCast(this=expr.expr, to="DATETIME") + + value = expr.expr + unit = op.unit or "ns" + factor = UNIT_TO_US_CONVERSION_FACTORS[unit] + if factor != 1: + value = sge.Mul(this=value, expression=sge.convert(factor)) + value = sge.func("TRUNC", value) + return sge.Cast( + this=sge.func("TIMESTAMP_MICROS", sge.Cast(this=value, to="INT64")), + to="DATETIME", + ) + + +@register_unary_op(ops.ToTimestampOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ToTimestampOp) -> sge.Expression: + if op.format: + result = expr.expr + if expr.dtype != dtypes.STRING_DTYPE: + result = sge.Cast(this=result, to="STRING") + return sge.func( + "PARSE_TIMESTAMP", sge.convert(op.format), expr.expr, sge.convert("UTC") + ) + + if expr.dtype == dtypes.STRING_DTYPE: + return sge.func("TIMESTAMP", expr.expr) + + value = expr.expr + unit = op.unit or "ns" + factor = UNIT_TO_US_CONVERSION_FACTORS[unit] + if factor != 1: + value = sge.Mul(this=value, expression=sge.convert(factor)) + value = sge.func("TRUNC", value) + return sge.Cast( + this=sge.func("TIMESTAMP_MICROS", sge.Cast(this=value, to="INT64")), + to="TIMESTAMP", + ) + + +@register_unary_op(ops.UnixMicros) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("UNIX_MICROS", expr.expr) + + +@register_unary_op(ops.UnixMillis) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("UNIX_MILLIS", expr.expr) + + +@register_unary_op(ops.UnixSeconds, pass_op=True) +def _(expr: TypedExpr, op: ops.UnixSeconds) -> sge.Expression: + return sge.func("UNIX_SECONDS", expr.expr) + + +@register_unary_op(ops.year_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="YEAR"), expression=expr.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py new file mode 100644 index 0000000000..e44a1b5c1d --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -0,0 +1,287 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot as sg +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes import operations as ops +from bigframes.core.compile.sqlglot import sqlglot_types +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op +register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op +register_ternary_op = scalar_compiler.scalar_op_compiler.register_ternary_op + + +@register_unary_op(ops.AsTypeOp, pass_op=True) +def _(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: + from_type = expr.dtype + to_type = op.to_type + sg_to_type = sqlglot_types.from_bigframes_dtype(to_type) + sg_expr = expr.expr + + if to_type == dtypes.JSON_DTYPE: + return _cast_to_json(expr, op) + + if from_type == dtypes.JSON_DTYPE: + return _cast_from_json(expr, op) + + if to_type == dtypes.INT_DTYPE: + result = _cast_to_int(expr, op) + if result is not None: + return result + + if to_type == dtypes.FLOAT_DTYPE and from_type == dtypes.BOOL_DTYPE: + sg_expr = _cast(sg_expr, "INT64", op.safe) + return _cast(sg_expr, sg_to_type, op.safe) + + if to_type == dtypes.BOOL_DTYPE: + if from_type == dtypes.BOOL_DTYPE: + return sg_expr + else: + return sge.NEQ(this=sg_expr, expression=sge.convert(0)) + + if to_type == dtypes.STRING_DTYPE: + sg_expr = _cast(sg_expr, sg_to_type, op.safe) + if from_type == dtypes.BOOL_DTYPE: + sg_expr = sge.func("INITCAP", sg_expr) + return sg_expr + + if dtypes.is_time_like(to_type) and from_type == dtypes.INT_DTYPE: + sg_expr = sge.func("TIMESTAMP_MICROS", sg_expr) + return _cast(sg_expr, sg_to_type, op.safe) + + return _cast(sg_expr, sg_to_type, op.safe) + + +@register_unary_op(ops.hash_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("FARM_FINGERPRINT", expr.expr) + + +@register_unary_op(ops.invert_op) +def _(expr: TypedExpr) -> sge.Expression: + if expr.dtype == dtypes.BOOL_DTYPE: + return sge.Not(this=sge.paren(expr.expr)) + return sge.BitwiseNot(this=sge.paren(expr.expr)) + + +@register_nary_op(ops.SqlScalarOp, pass_op=True) +def _(*operands: TypedExpr, op: ops.SqlScalarOp) -> sge.Expression: + return sg.parse_one( + op.sql_template.format( + *[operand.expr.sql(dialect="bigquery") for operand in operands] + ), + dialect="bigquery", + ) + + +@register_unary_op(ops.isnull_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Is(this=expr.expr, expression=sge.Null()) + + +@register_unary_op(ops.MapOp, pass_op=True) +def _(expr: TypedExpr, op: ops.MapOp) -> sge.Expression: + if len(op.mappings) == 0: + return expr.expr + return sge.Case( + this=expr.expr, + ifs=[ + sge.If(this=sge.convert(key), true=sge.convert(value)) + for key, value in op.mappings + ], + default=expr.expr, + ) + + +@register_unary_op(ops.notnull_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Not(this=sge.Is(this=expr.expr, expression=sge.Null())) + + +@register_ternary_op(ops.where_op) +def _( + original: TypedExpr, condition: TypedExpr, replacement: TypedExpr +) -> sge.Expression: + return sge.If(this=condition.expr, true=original.expr, false=replacement.expr) + + +@register_ternary_op(ops.clip_op) +def _( + original: TypedExpr, + lower: TypedExpr, + upper: TypedExpr, +) -> sge.Expression: + return sge.Greatest( + this=sge.Least(this=original.expr, expressions=[upper.expr]), + expressions=[lower.expr], + ) + + +@register_binary_op(ops.fillna_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Coalesce(this=left.expr, expressions=[right.expr]) + + +@register_nary_op(ops.case_when_op) +def _(*cases_and_outputs: TypedExpr) -> sge.Expression: + # Need to upcast BOOL to INT if any output is numeric + result_values = cases_and_outputs[1::2] + do_upcast_bool = any( + dtypes.is_numeric(t.dtype, include_bool=False) for t in result_values + ) + if do_upcast_bool: + result_values = tuple( + TypedExpr( + sge.Cast(this=val.expr, to="INT64"), + dtypes.INT_DTYPE, + ) + if val.dtype == dtypes.BOOL_DTYPE + else val + for val in result_values + ) + + return sge.Case( + ifs=[ + sge.If(this=predicate.expr, true=output.expr) + for predicate, output in zip(cases_and_outputs[::2], result_values) + ], + ) + + +@register_binary_op(ops.coalesce_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.expr == right.expr: + return left.expr + return sge.Coalesce(this=left.expr, expressions=[right.expr]) + + +@register_nary_op(ops.RowKey) +def _(*values: TypedExpr) -> sge.Expression: + # All inputs into hash must be non-null or resulting hash will be null + str_values = [_convert_to_nonnull_string_sqlglot(value) for value in values] + + full_row_hash_p1 = sge.func("FARM_FINGERPRINT", sge.Concat(expressions=str_values)) + + # By modifying value slightly, we get another hash uncorrelated with the first + full_row_hash_p2 = sge.func( + "FARM_FINGERPRINT", sge.Concat(expressions=[*str_values, sge.convert("_")]) + ) + + # Used to disambiguate between identical rows (which will have identical hash) + random_hash_p3 = sge.func("RAND") + + return sge.Concat( + expressions=[ + sge.Cast(this=full_row_hash_p1, to="STRING"), + sge.Cast(this=full_row_hash_p2, to="STRING"), + sge.Cast(this=random_hash_p3, to="STRING"), + ] + ) + + +# Helper functions +def _cast_to_json(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: + from_type = expr.dtype + sg_expr = expr.expr + + if from_type == dtypes.STRING_DTYPE: + func_name = "PARSE_JSON_IN_SAFE" if op.safe else "PARSE_JSON" + return sge.func(func_name, sg_expr) + if from_type in (dtypes.INT_DTYPE, dtypes.BOOL_DTYPE, dtypes.FLOAT_DTYPE): + sg_expr = sge.Cast(this=sg_expr, to="STRING") + return sge.func("PARSE_JSON", sg_expr) + raise TypeError(f"Cannot cast from {from_type} to {dtypes.JSON_DTYPE}") + + +def _cast_from_json(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: + to_type = op.to_type + sg_expr = expr.expr + func_name = "" + if to_type == dtypes.INT_DTYPE: + func_name = "INT64" + elif to_type == dtypes.FLOAT_DTYPE: + func_name = "FLOAT64" + elif to_type == dtypes.BOOL_DTYPE: + func_name = "BOOL" + elif to_type == dtypes.STRING_DTYPE: + func_name = "STRING" + if func_name: + func_name = "SAFE." + func_name if op.safe else func_name + return sge.func(func_name, sg_expr) + raise TypeError(f"Cannot cast from {dtypes.JSON_DTYPE} to {to_type}") + + +def _cast_to_int(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression | None: + from_type = expr.dtype + sg_expr = expr.expr + # Cannot cast DATETIME to INT directly so need to convert to TIMESTAMP first. + if from_type == dtypes.DATETIME_DTYPE: + sg_expr = _cast(sg_expr, "TIMESTAMP", op.safe) + return sge.func("UNIX_MICROS", sg_expr) + if from_type == dtypes.TIMESTAMP_DTYPE: + return sge.func("UNIX_MICROS", sg_expr) + if from_type == dtypes.TIME_DTYPE: + return sge.func( + "TIME_DIFF", + _cast(sg_expr, "TIME", op.safe), + sge.convert("00:00:00"), + "MICROSECOND", + ) + if from_type == dtypes.NUMERIC_DTYPE or from_type == dtypes.FLOAT_DTYPE: + sg_expr = sge.func("TRUNC", sg_expr) + return _cast(sg_expr, "INT64", op.safe) + return None + + +def _cast(expr: sge.Expression, to: str, safe: bool): + if safe: + return sge.TryCast(this=expr, to=to) + else: + return sge.Cast(this=expr, to=to) + + +def _convert_to_nonnull_string_sqlglot(expr: TypedExpr) -> sge.Expression: + col_type = expr.dtype + sg_expr = expr.expr + + if col_type == dtypes.STRING_DTYPE: + result = sg_expr + elif ( + dtypes.is_numeric(col_type) + or dtypes.is_time_or_date_like(col_type) + or col_type == dtypes.BYTES_DTYPE + ): + result = sge.Cast(this=sg_expr, to="STRING") + elif col_type == dtypes.GEO_DTYPE: + result = sge.func("ST_ASTEXT", sg_expr) + else: + # TO_JSON_STRING works with all data types, but isn't the most efficient + # Needed for JSON, STRUCT and ARRAY datatypes + result = sge.func("TO_JSON_STRING", sg_expr) + + # Escape backslashes and use backslash as delineator + escaped = sge.func( + "REPLACE", + sge.func("COALESCE", result, sge.convert("")), + sge.convert("\\"), + sge.convert("\\\\"), + ) + return sge.Concat(expressions=[sge.convert("\\"), escaped]) diff --git a/bigframes/core/compile/sqlglot/expressions/geo_ops.py b/bigframes/core/compile/sqlglot/expressions/geo_ops.py new file mode 100644 index 0000000000..5716dba0e4 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/geo_ops.py @@ -0,0 +1,131 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +@register_unary_op(ops.geo_area_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_AREA", expr.expr) + + +@register_unary_op(ops.geo_st_astext_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_ASTEXT", expr.expr) + + +@register_unary_op(ops.geo_st_boundary_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_BOUNDARY", expr.expr) + + +@register_unary_op(ops.GeoStBufferOp, pass_op=True) +def _(expr: TypedExpr, op: ops.GeoStBufferOp) -> sge.Expression: + return sge.func( + "ST_BUFFER", + expr.expr, + sge.convert(op.buffer_radius), + sge.convert(op.num_seg_quarter_circle), + sge.convert(op.use_spheroid), + ) + + +@register_unary_op(ops.geo_st_centroid_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_CENTROID", expr.expr) + + +@register_unary_op(ops.geo_st_convexhull_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_CONVEXHULL", expr.expr) + + +@register_binary_op(ops.geo_st_geogpoint_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("ST_GEOGPOINT", left.expr, right.expr) + + +@register_unary_op(ops.geo_st_geogfromtext_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("SAFE.ST_GEOGFROMTEXT", expr.expr) + + +@register_unary_op(ops.geo_st_isclosed_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_ISCLOSED", expr.expr) + + +@register_unary_op(ops.GeoStLengthOp, pass_op=True) +def _(expr: TypedExpr, op: ops.GeoStLengthOp) -> sge.Expression: + return sge.func("ST_LENGTH", expr.expr) + + +@register_unary_op(ops.GeoStRegionStatsOp, pass_op=True) +def _( + geography: TypedExpr, + op: ops.GeoStRegionStatsOp, +): + args = [geography.expr, sge.convert(op.raster_id)] + if op.band: + args.append(sge.Kwarg(this="band", expression=sge.convert(op.band))) + if op.include: + args.append(sge.Kwarg(this="include", expression=sge.convert(op.include))) + if op.options: + args.append( + sge.Kwarg(this="options", expression=sge.JSON(this=sge.convert(op.options))) + ) + return sge.func("ST_REGIONSTATS", *args) + + +@register_unary_op(ops.GeoStSimplifyOp, pass_op=True) +def _(expr: TypedExpr, op: ops.GeoStSimplifyOp) -> sge.Expression: + return sge.func( + "ST_SIMPLIFY", + expr.expr, + sge.convert(op.tolerance_meters), + ) + + +@register_unary_op(ops.geo_x_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("SAFE.ST_X", expr.expr) + + +@register_unary_op(ops.geo_y_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("SAFE.ST_Y", expr.expr) + + +@register_binary_op(ops.GeoStDistanceOp, pass_op=True) +def _(left: TypedExpr, right: TypedExpr, op: ops.GeoStDistanceOp) -> sge.Expression: + return sge.func("ST_DISTANCE", left.expr, right.expr, sge.convert(op.use_spheroid)) + + +@register_binary_op(ops.geo_st_difference_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("ST_DIFFERENCE", left.expr, right.expr) + + +@register_binary_op(ops.geo_st_intersection_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("ST_INTERSECTION", left.expr, right.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/json_ops.py b/bigframes/core/compile/sqlglot/expressions/json_ops.py new file mode 100644 index 0000000000..0a38e8e138 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/json_ops.py @@ -0,0 +1,84 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +@register_unary_op(ops.JSONExtract, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONExtract) -> sge.Expression: + return sge.func("JSON_EXTRACT", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONExtractArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONExtractArray) -> sge.Expression: + return sge.func("JSON_EXTRACT_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONExtractStringArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONExtractStringArray) -> sge.Expression: + return sge.func("JSON_EXTRACT_STRING_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONKeys, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONKeys) -> sge.Expression: + return sge.func("JSON_KEYS", expr.expr, sge.convert(op.max_depth)) + + +@register_unary_op(ops.JSONQuery, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONQuery) -> sge.Expression: + return sge.func("JSON_QUERY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONQueryArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONQueryArray) -> sge.Expression: + return sge.func("JSON_QUERY_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONValue, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONValue) -> sge.Expression: + return sge.func("JSON_VALUE", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONValueArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONValueArray) -> sge.Expression: + return sge.func("JSON_VALUE_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.ParseJSON) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("PARSE_JSON", expr.expr) + + +@register_unary_op(ops.ToJSON) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TO_JSON", expr.expr) + + +@register_unary_op(ops.ToJSONString) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TO_JSON_STRING", expr.expr) + + +@register_binary_op(ops.JSONSet, pass_op=True) +def _(left: TypedExpr, right: TypedExpr, op) -> sge.Expression: + return sge.func("JSON_SET", left.expr, sge.convert(op.json_path), right.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py new file mode 100644 index 0000000000..f7da28c5d2 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -0,0 +1,615 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import bigframes_vendored.constants as bf_constants +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes import operations as ops +import bigframes.core.compile.sqlglot.expressions.constants as constants +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +from bigframes.operations import numeric_ops + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +@register_unary_op(ops.abs_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Abs(this=expr.expr) + + +@register_unary_op(ops.arccosh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr < sge.convert(1), + true=constants._NAN, + ) + ], + default=sge.func("ACOSH", expr.expr), + ) + + +@register_unary_op(ops.arccos_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > sge.convert(1), + true=constants._NAN, + ) + ], + default=sge.func("ACOS", expr.expr), + ) + + +@register_unary_op(ops.arcsin_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > sge.convert(1), + true=constants._NAN, + ) + ], + default=sge.func("ASIN", expr.expr), + ) + + +@register_unary_op(ops.arcsinh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ASINH", expr.expr) + + +@register_binary_op(ops.arctan2_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.func("ATAN2", left_expr, right_expr) + + +@register_unary_op(ops.arctan_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ATAN", expr.expr) + + +@register_unary_op(ops.arctanh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > sge.convert(1), + true=constants._NAN, + ) + ], + default=sge.func("ATANH", expr.expr), + ) + + +@register_unary_op(ops.ceil_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Ceil(this=expr.expr) + + +@register_unary_op(ops.cos_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("COS", expr.expr) + + +@register_unary_op(ops.cosh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > sge.convert(709.78), + true=constants._INF, + ) + ], + default=sge.func("COSH", expr.expr), + ) + + +@register_binary_op(ops.cosine_distance_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("ML.DISTANCE", left.expr, right.expr, sge.Literal.string("COSINE")) + + +@register_unary_op(ops.exp_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr > constants._FLOAT64_EXP_BOUND, + true=constants._INF, + ) + ], + default=sge.func("EXP", expr.expr), + ) + + +@register_unary_op(ops.expm1_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr > constants._FLOAT64_EXP_BOUND, + true=constants._INF, + ) + ], + default=sge.func("EXP", expr.expr), + ) - sge.convert(1) + + +@register_unary_op(ops.floor_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Floor(this=expr.expr) + + +@register_unary_op(ops.ln_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr <= sge.convert(0), + true=constants._NAN, + ) + ], + default=sge.Ln(this=expr.expr), + ) + + +@register_unary_op(ops.log10_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr <= sge.convert(0), + true=constants._NAN, + ) + ], + default=sge.Log(this=expr.expr, expression=sge.convert(10)), + ) + + +@register_unary_op(ops.log1p_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr <= sge.convert(-1), + true=constants._NAN, + ) + ], + default=sge.Ln(this=sge.convert(1) + expr.expr), + ) + + +@register_unary_op(ops.neg_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Neg(this=sge.paren(expr.expr)) + + +@register_unary_op(ops.pos_op) +def _(expr: TypedExpr) -> sge.Expression: + return expr.expr + + +@register_binary_op(ops.pow_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + if left.dtype == dtypes.INT_DTYPE and right.dtype == dtypes.INT_DTYPE: + return _int_pow_op(left_expr, right_expr) + else: + return _float_pow_op(left_expr, right_expr) + + +def _int_pow_op( + left_expr: sge.Expression, right_expr: sge.Expression +) -> sge.Expression: + overflow_cond = sge.and_( + sge.NEQ(this=left_expr, expression=sge.convert(0)), + sge.GT( + this=sge.Mul( + this=right_expr, expression=sge.Ln(this=sge.Abs(this=left_expr)) + ), + expression=sge.convert(constants._INT64_LOG_BOUND), + ), + ) + + return sge.Case( + ifs=[ + sge.If( + this=overflow_cond, + true=sge.Null(), + ) + ], + default=sge.Cast( + this=sge.Pow( + this=sge.Cast( + this=left_expr, to=sge.DataType(this=sge.DataType.Type.DECIMAL) + ), + expression=right_expr, + ), + to="INT64", + ), + ) + + +def _float_pow_op( + left_expr: sge.Expression, right_expr: sge.Expression +) -> sge.Expression: + # Most conditions here seek to prevent calling BQ POW with inputs that would generate errors. + # See: https://cloud.google.com/bigquery/docs/reference/standard-sql/mathematical_functions#pow + overflow_cond = sge.and_( + sge.NEQ(this=left_expr, expression=constants._ZERO), + sge.GT( + this=sge.Mul( + this=right_expr, expression=sge.Ln(this=sge.Abs(this=left_expr)) + ), + expression=constants._FLOAT64_EXP_BOUND, + ), + ) + + # Float64 lose integer precision beyond 2**53, beyond this insufficient precision to get parity + exp_too_big = sge.GT( + this=sge.Abs(this=right_expr), + expression=sge.convert(constants._FLOAT64_MAX_INT_PRECISION), + ) + # Treat very large exponents as +=INF + norm_exp = sge.Case( + ifs=[ + sge.If( + this=exp_too_big, + true=sge.Mul(this=constants._INF, expression=sge.Sign(this=right_expr)), + ) + ], + default=right_expr, + ) + + pow_result = sge.Pow(this=left_expr, expression=norm_exp) + + # This cast is dangerous, need to only excuted where y_val has been bounds-checked + # Ibis needs try_cast binding to bq safe_cast + exponent_is_whole = sge.EQ( + this=sge.Cast(this=right_expr, to="INT64"), expression=right_expr + ) + odd_exponent = sge.and_( + sge.LT(this=left_expr, expression=constants._ZERO), + sge.EQ( + this=sge.Mod( + this=sge.Cast(this=right_expr, to="INT64"), expression=sge.convert(2) + ), + expression=sge.convert(1), + ), + ) + infinite_base = sge.EQ(this=sge.Abs(this=left_expr), expression=constants._INF) + + return sge.Case( + ifs=[ + # Might be able to do something more clever with x_val==0 case + sge.If( + this=sge.EQ(this=right_expr, expression=constants._ZERO), + true=sge.convert(1), + ), + sge.If( + this=sge.EQ(this=left_expr, expression=sge.convert(1)), + true=sge.convert(1), + ), # Need to ignore exponent, even if it is NA + sge.If( + this=sge.and_( + sge.EQ(this=left_expr, expression=constants._ZERO), + sge.LT(this=right_expr, expression=constants._ZERO), + ), + true=constants._INF, + ), # This case would error POW function in BQ + sge.If(this=infinite_base, true=pow_result), + sge.If( + this=exp_too_big, true=pow_result + ), # Bigquery can actually handle the +-inf cases gracefully + sge.If( + this=sge.and_( + sge.LT(this=left_expr, expression=constants._ZERO), + sge.Not(this=exponent_is_whole), + ), + true=constants._NAN, + ), + sge.If( + this=overflow_cond, + true=sge.Mul( + this=constants._INF, + expression=sge.Case( + ifs=[sge.If(this=odd_exponent, true=sge.convert(-1))], + default=sge.convert(1), + ), + ), + ), # finite overflows would cause bq to error + ], + default=pow_result, + ) + + +@register_unary_op(ops.sqrt_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr < sge.convert(0), + true=constants._NAN, + ) + ], + default=sge.Sqrt(this=expr.expr), + ) + + +@register_unary_op(ops.sin_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("SIN", expr.expr) + + +@register_unary_op(ops.sinh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > constants._FLOAT64_EXP_BOUND, + true=sge.func("SIGN", expr.expr) * constants._INF, + ) + ], + default=sge.func("SINH", expr.expr), + ) + + +@register_unary_op(ops.tan_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TAN", expr.expr) + + +@register_unary_op(ops.tanh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TANH", expr.expr) + + +@register_binary_op(ops.add_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.dtype == dtypes.STRING_DTYPE and right.dtype == dtypes.STRING_DTYPE: + # String addition + return sge.Concat(expressions=[left.expr, right.expr]) + + if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.Add(this=left_expr, expression=right_expr) + + if ( + dtypes.is_time_or_date_like(left.dtype) + and right.dtype == dtypes.TIMEDELTA_DTYPE + ): + left_expr = _coerce_date_to_datetime(left) + return sge.TimestampAdd( + this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") + ) + if ( + dtypes.is_time_or_date_like(right.dtype) + and left.dtype == dtypes.TIMEDELTA_DTYPE + ): + right_expr = _coerce_date_to_datetime(right) + return sge.TimestampAdd( + this=right_expr, expression=left.expr, unit=sge.Var(this="MICROSECOND") + ) + if left.dtype == dtypes.TIMEDELTA_DTYPE and right.dtype == dtypes.TIMEDELTA_DTYPE: + return sge.Add(this=left.expr, expression=right.expr) + + raise TypeError( + f"Cannot add type {left.dtype} and {right.dtype}. {bf_constants.FEEDBACK_LINK}" + ) + + +@register_binary_op(ops.div_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + + result = sge.func("IEEE_DIVIDE", left_expr, right_expr) + if left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): + return sge.Cast(this=sge.Floor(this=result), to="INT64") + else: + return result + + +@register_binary_op(ops.euclidean_distance_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func( + "ML.DISTANCE", left.expr, right.expr, sge.Literal.string("EUCLIDEAN") + ) + + +@register_binary_op(ops.floordiv_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + + result: sge.Expression = sge.Cast( + this=sge.Floor(this=sge.func("IEEE_DIVIDE", left_expr, right_expr)), to="INT64" + ) + + # DIV(N, 0) will error in bigquery, but needs to return `0` for int, and + # `inf`` for float in BQ so we short-circuit in this case. + # Multiplying left by zero propogates nulls. + zero_result = ( + constants._INF + if (left.dtype == dtypes.FLOAT_DTYPE or right.dtype == dtypes.FLOAT_DTYPE) + else constants._ZERO + ) + result = sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=right_expr, expression=constants._ZERO), + true=zero_result * left_expr, + ) + ], + default=result, + ) + + if dtypes.is_numeric(right.dtype) and left.dtype == dtypes.TIMEDELTA_DTYPE: + result = sge.Cast(this=sge.Floor(this=result), to="INT64") + + return result + + +@register_binary_op(ops.manhattan_distance_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func( + "ML.DISTANCE", left.expr, right.expr, sge.Literal.string("MANHATTAN") + ) + + +@register_binary_op(ops.mod_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + # In BigQuery returned value has the same sign as X. In pandas, the sign of y is used, so we need to flip the result if sign(x) != sign(y) + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + + # BigQuery MOD function doesn't support float types, so cast to BIGNUMERIC + if left.dtype == dtypes.FLOAT_DTYPE or right.dtype == dtypes.FLOAT_DTYPE: + left_expr = sge.Cast(this=left_expr, to="BIGNUMERIC") + right_expr = sge.Cast(this=right_expr, to="BIGNUMERIC") + + # MOD(N, 0) will error in bigquery, but needs to return null + bq_mod = sge.Mod(this=left_expr, expression=right_expr) + zero_result = ( + constants._NAN + if (left.dtype == dtypes.FLOAT_DTYPE or right.dtype == dtypes.FLOAT_DTYPE) + else constants._ZERO + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=right_expr, expression=constants._ZERO), + true=zero_result * left_expr, + ), + sge.If( + this=sge.and_( + right_expr < constants._ZERO, + bq_mod > constants._ZERO, + ), + true=right_expr + bq_mod, + ), + sge.If( + this=sge.and_( + right_expr > constants._ZERO, + bq_mod < constants._ZERO, + ), + true=right_expr + bq_mod, + ), + ], + default=bq_mod, + ) + + +@register_binary_op(ops.mul_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + + result = sge.Mul(this=left_expr, expression=right_expr) + + if (dtypes.is_numeric(left.dtype) and right.dtype == dtypes.TIMEDELTA_DTYPE) or ( + left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype) + ): + return sge.Cast(this=sge.Floor(this=result), to="INT64") + else: + return result + + +@register_binary_op(ops.round_op) +def _(expr: TypedExpr, n_digits: TypedExpr) -> sge.Expression: + rounded = sge.Round(this=expr.expr, decimals=n_digits.expr) + if expr.dtype == dtypes.INT_DTYPE: + return sge.Cast(this=rounded, to="INT64") + return rounded + + +@register_binary_op(ops.sub_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.Sub(this=left_expr, expression=right_expr) + + if ( + dtypes.is_time_or_date_like(left.dtype) + and right.dtype == dtypes.TIMEDELTA_DTYPE + ): + left_expr = _coerce_date_to_datetime(left) + return sge.TimestampSub( + this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") + ) + if dtypes.is_time_or_date_like(left.dtype) and dtypes.is_time_or_date_like( + right.dtype + ): + left_expr = _coerce_date_to_datetime(left) + right_expr = _coerce_date_to_datetime(right) + return sge.TimestampDiff( + this=left_expr, expression=right_expr, unit=sge.Var(this="MICROSECOND") + ) + + if left.dtype == dtypes.TIMEDELTA_DTYPE and right.dtype == dtypes.TIMEDELTA_DTYPE: + return sge.Sub(this=left.expr, expression=right.expr) + + raise TypeError( + f"Cannot subtract type {left.dtype} and {right.dtype}. {bf_constants.FEEDBACK_LINK}" + ) + + +@register_binary_op(ops.unsafe_pow_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + """For internal use only - where domain and overflow checks are not needed.""" + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.Pow(this=left_expr, expression=right_expr) + + +@register_unary_op(numeric_ops.isnan_op) +def isnan(arg: TypedExpr) -> sge.Expression: + return sge.IsNan(this=arg.expr) + + +@register_unary_op(numeric_ops.isfinite_op) +def isfinite(arg: TypedExpr) -> sge.Expression: + return sge.Not( + this=sge.Or( + this=sge.IsInf(this=arg.expr), + right=sge.IsNan(this=arg.expr), + ), + ) + + +def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: + """Coerce boolean expression to integer.""" + if typed_expr.dtype == dtypes.BOOL_DTYPE: + return sge.Cast(this=typed_expr.expr, to="INT64") + return typed_expr.expr + + +def _coerce_date_to_datetime(typed_expr: TypedExpr) -> sge.Expression: + """Coerce date expression to datetime.""" + if typed_expr.dtype == dtypes.DATE_DTYPE: + return sge.Cast(this=typed_expr.expr, to="DATETIME") + return typed_expr.expr diff --git a/bigframes/core/compile/sqlglot/expressions/string_ops.py b/bigframes/core/compile/sqlglot/expressions/string_ops.py new file mode 100644 index 0000000000..6af9b6a526 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/string_ops.py @@ -0,0 +1,380 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import functools +import typing + +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +@register_unary_op(ops.capitalize_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Initcap(this=expr.expr, expression=sge.convert("")) + + +@register_unary_op(ops.StrContainsOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrContainsOp) -> sge.Expression: + return sge.Like(this=expr.expr, expression=sge.convert(f"%{op.pat}%")) + + +@register_unary_op(ops.StrContainsRegexOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrContainsRegexOp) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(op.pat)) + + +@register_unary_op(ops.StrExtractOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrExtractOp) -> sge.Expression: + # Cannot use BigQuery's REGEXP_EXTRACT function, which only allows one + # capturing group. + pat_expr = sge.convert(op.pat) + if op.n != 0: + pat_expr = sge.func("CONCAT", sge.convert(".*?"), pat_expr, sge.convert(".*")) + else: + pat_expr = sge.func("CONCAT", sge.convert(".*?("), pat_expr, sge.convert(").*")) + + rex_replace = sge.func("REGEXP_REPLACE", expr.expr, pat_expr, sge.convert(r"\1")) + rex_contains = sge.func("REGEXP_CONTAINS", expr.expr, sge.convert(op.pat)) + return sge.If(this=rex_contains, true=rex_replace, false=sge.null()) + + +@register_unary_op(ops.StrFindOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrFindOp) -> sge.Expression: + # INSTR is 1-based, so we need to adjust the start position. + start = sge.convert(op.start + 1) if op.start is not None else sge.convert(1) + if op.end is not None: + # BigQuery's INSTR doesn't support `end`, so we need to use SUBSTR. + return sge.func( + "INSTR", + sge.Substring( + this=expr.expr, + start=start, + length=sge.convert(op.end - (op.start or 0)), + ), + sge.convert(op.substr), + ) - sge.convert(1) + else: + return sge.func( + "INSTR", + expr.expr, + sge.convert(op.substr), + start, + ) - sge.convert(1) + + +@register_unary_op(ops.StrLstripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrLstripOp) -> sge.Expression: + return sge.func("LTRIM", expr.expr, sge.convert(op.to_strip)) + + +@register_unary_op(ops.StrRstripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrRstripOp) -> sge.Expression: + return sge.func("RTRIM", expr.expr, sge.convert(op.to_strip)) + + +@register_unary_op(ops.StrPadOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrPadOp) -> sge.Expression: + expr_length = sge.Length(this=expr.expr) + fillchar = sge.convert(op.fillchar) + pad_length = sge.func("GREATEST", expr_length, sge.convert(op.length)) + + if op.side == "left": + return sge.func("LPAD", expr.expr, pad_length, fillchar) + elif op.side == "right": + return sge.func("RPAD", expr.expr, pad_length, fillchar) + else: # side == both + lpad_amount = ( + sge.Cast( + this=sge.Floor( + this=sge.func( + "SAFE_DIVIDE", + sge.Sub(this=pad_length, expression=expr_length), + sge.convert(2), + ) + ), + to="INT64", + ) + + expr_length + ) + return sge.func( + "RPAD", + sge.func("LPAD", expr.expr, lpad_amount, fillchar), + pad_length, + fillchar, + ) + + +@register_unary_op(ops.StrRepeatOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrRepeatOp) -> sge.Expression: + return sge.Repeat(this=expr.expr, times=sge.convert(op.repeats)) + + +@register_unary_op(ops.EndsWithOp, pass_op=True) +def _(expr: TypedExpr, op: ops.EndsWithOp) -> sge.Expression: + if not op.pat: + return sge.false() + + def to_endswith(pat: str) -> sge.Expression: + return sge.func("ENDS_WITH", expr.expr, sge.convert(pat)) + + conditions = [to_endswith(pat) for pat in op.pat] + return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) + + +@register_unary_op(ops.isalnum_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^(\p{N}|\p{L})+$")) + + +@register_unary_op(ops.isalpha_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{L}+$")) + + +@register_unary_op(ops.isdecimal_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^(\p{Nd})+$")) + + +@register_unary_op(ops.isdigit_op) +def _(expr: TypedExpr) -> sge.Expression: + regexp_pattern = ( + r"^[\p{Nd}\x{00B9}\x{00B2}\x{00B3}\x{2070}\x{2074}-\x{2079}\x{2080}-\x{2089}]+$" + ) + return sge.RegexpLike(this=expr.expr, expression=sge.convert(regexp_pattern)) + + +@register_unary_op(ops.islower_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.And( + this=sge.EQ( + this=sge.Lower(this=expr.expr), + expression=expr.expr, + ), + expression=sge.NEQ( + this=sge.Upper(this=expr.expr), + expression=expr.expr, + ), + ) + + +@register_unary_op(ops.isnumeric_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\pN+$")) + + +@register_unary_op(ops.isspace_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\s+$")) + + +@register_unary_op(ops.isupper_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.And( + this=sge.EQ( + this=sge.Upper(this=expr.expr), + expression=expr.expr, + ), + expression=sge.NEQ( + this=sge.Lower(this=expr.expr), + expression=expr.expr, + ), + ) + + +@register_unary_op(ops.len_op) +def _(expr: TypedExpr) -> sge.Expression: + if dtypes.is_array_like(expr.dtype): + return sge.func("ARRAY_LENGTH", expr.expr) + + return sge.Length(this=expr.expr) + + +@register_unary_op(ops.lower_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Lower(this=expr.expr) + + +@register_unary_op(ops.ReplaceStrOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ReplaceStrOp) -> sge.Expression: + return sge.func("REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl)) + + +@register_unary_op(ops.RegexReplaceStrOp, pass_op=True) +def _(expr: TypedExpr, op: ops.RegexReplaceStrOp) -> sge.Expression: + return sge.func( + "REGEXP_REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl) + ) + + +@register_unary_op(ops.reverse_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("REVERSE", expr.expr) + + +@register_unary_op(ops.StartsWithOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StartsWithOp) -> sge.Expression: + if not op.pat: + return sge.false() + + def to_startswith(pat: str) -> sge.Expression: + return sge.func("STARTS_WITH", expr.expr, sge.convert(pat)) + + conditions = [to_startswith(pat) for pat in op.pat] + return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) + + +@register_unary_op(ops.StrStripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrStripOp) -> sge.Expression: + return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip)) + + +@register_unary_op(ops.StringSplitOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StringSplitOp) -> sge.Expression: + return sge.Split(this=expr.expr, expression=sge.convert(op.pat)) + + +@register_unary_op(ops.StrGetOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrGetOp) -> sge.Expression: + return string_index(expr, op.i) + + +@register_unary_op(ops.StrSliceOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: + return string_slice(expr, op.start, op.end) + + +@register_unary_op(ops.upper_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Upper(this=expr.expr) + + +@register_binary_op(ops.strconcat_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Concat(expressions=[left.expr, right.expr]) + + +@register_unary_op(ops.ZfillOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ZfillOp) -> sge.Expression: + length_expr = sge.Greatest( + expressions=[sge.Length(this=expr.expr), sge.convert(op.width)] + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.func( + "STARTS_WITH", + expr.expr, + sge.convert("-"), + ), + true=sge.Concat( + expressions=[ + sge.convert("-"), + sge.func( + "LPAD", + sge.Substring(this=expr.expr, start=sge.convert(2)), + length_expr - 1, + sge.convert("0"), + ), + ] + ), + ) + ], + default=sge.func("LPAD", expr.expr, length_expr, sge.convert("0")), + ) + + +def string_index(expr: TypedExpr, index: int) -> sge.Expression: + sub_str = sge.Substring( + this=expr.expr, + start=sge.convert(index + 1), + length=sge.convert(1), + ) + return sge.If( + this=sge.NEQ(this=sub_str, expression=sge.convert("")), + true=sub_str, + false=sge.Null(), + ) + + +def string_slice( + expr: TypedExpr, op_start: typing.Optional[int], op_end: typing.Optional[int] +) -> sge.Expression: + column_length = sge.Length(this=expr.expr) + if op_start is None: + start = 0 + else: + start = op_start + + start_expr = sge.convert(start) if start < 0 else sge.convert(start + 1) + length_expr: typing.Optional[sge.Expression] + if op_end is None: + length_expr = None + elif op_end < 0: + if start < 0: + start_expr = sge.Greatest( + expressions=[ + sge.convert(1), + column_length + sge.convert(start + 1), + ] + ) + length_expr = sge.Greatest( + expressions=[ + sge.convert(0), + column_length + sge.convert(op_end), + ] + ) - sge.Greatest( + expressions=[ + sge.convert(0), + column_length + sge.convert(start), + ] + ) + else: + length_expr = sge.Greatest( + expressions=[ + sge.convert(0), + column_length + sge.convert(op_end - start), + ] + ) + else: # op.end >= 0 + if start < 0: + start_expr = sge.Greatest( + expressions=[ + sge.convert(1), + column_length + sge.convert(start + 1), + ] + ) + length_expr = sge.convert(op_end) - sge.Greatest( + expressions=[ + sge.convert(0), + column_length + sge.convert(start), + ] + ) + else: + length_expr = sge.convert(op_end - start) + + return sge.Substring( + this=expr.expr, + start=start_expr, + length=length_expr, + ) diff --git a/bigframes/core/compile/sqlglot/expressions/struct_ops.py b/bigframes/core/compile/sqlglot/expressions/struct_ops.py new file mode 100644 index 0000000000..b6ec101eb1 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/struct_ops.py @@ -0,0 +1,53 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import pandas as pd +import pyarrow as pa +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.StructFieldOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StructFieldOp) -> sge.Expression: + if isinstance(op.name_or_index, str): + name = op.name_or_index + else: + pa_type = typing.cast(pd.ArrowDtype, expr.dtype) + pa_struct_type = typing.cast(pa.StructType, pa_type.pyarrow_dtype) + name = pa_struct_type.field(op.name_or_index).name + + return sge.Column( + this=sge.to_identifier(name, quoted=True), + catalog=expr.expr, + ) + + +@register_nary_op(ops.StructOp, pass_op=True) +def _(*exprs: TypedExpr, op: ops.StructOp) -> sge.Struct: + return sge.Struct( + expressions=[ + sge.PropertyEQ(this=sge.to_identifier(col), expression=expr.expr) + for col, expr in zip(op.column_names, exprs) + ] + ) diff --git a/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py b/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py new file mode 100644 index 0000000000..f5b9f891c1 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py @@ -0,0 +1,44 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes import operations as ops +from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.timedelta_floor_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Floor(this=expr.expr) + + +@register_unary_op(ops.ToTimedeltaOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ToTimedeltaOp) -> sge.Expression: + value = expr.expr + if expr.dtype == dtypes.TIMEDELTA_DTYPE: + return value + + factor = UNIT_TO_US_CONVERSION_FACTORS[op.unit] + if factor != 1: + value = sge.Mul(this=value, expression=sge.convert(factor)) + if expr.dtype == dtypes.FLOAT_DTYPE: + value = sge.Cast(this=sge.Floor(this=value), to=sge.DataType(this="INT64")) + return value diff --git a/bigframes/core/compile/sqlglot/expressions/typed_expr.py b/bigframes/core/compile/sqlglot/expressions/typed_expr.py new file mode 100644 index 0000000000..e693dd94a2 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/typed_expr.py @@ -0,0 +1,27 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import dataclasses + +import sqlglot.expressions as sge + +from bigframes import dtypes + + +@dataclasses.dataclass(frozen=True) +class TypedExpr: + """SQLGlot expression with type.""" + + expr: sge.Expression + dtype: dtypes.ExpressionType diff --git a/bigframes/core/compile/sqlglot/scalar_compiler.py b/bigframes/core/compile/sqlglot/scalar_compiler.py new file mode 100644 index 0000000000..1da58871c7 --- /dev/null +++ b/bigframes/core/compile/sqlglot/scalar_compiler.py @@ -0,0 +1,221 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import functools +import typing + +import sqlglot.expressions as sge + +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.sqlglot_ir as ir +import bigframes.core.expression as ex +import bigframes.operations as ops + + +class ScalarOpCompiler: + # Mapping of operation name to implemenations + _registry: dict[ + str, + typing.Callable[[typing.Sequence[TypedExpr], ops.RowOp], sge.Expression], + ] = {} + + # A set of SQLGlot classes that may need to be parenthesized + SQLGLOT_NEEDS_PARENS = { + # Numeric operations + sge.Add, + sge.Sub, + sge.Mul, + sge.Div, + sge.Mod, + sge.Pow, + # Comparison operations + sge.GTE, + sge.GT, + sge.LTE, + sge.LT, + sge.EQ, + sge.NEQ, + # Logical operations + sge.And, + sge.Or, + sge.Xor, + # Bitwise operations + sge.BitwiseAnd, + sge.BitwiseOr, + sge.BitwiseXor, + sge.BitwiseLeftShift, + sge.BitwiseRightShift, + sge.BitwiseNot, + # Other operations + sge.Is, + } + + @functools.singledispatchmethod + def compile_expression( + self, + expression: ex.Expression, + ) -> sge.Expression: + """Compiles BigFrames scalar expression into SQLGlot expression.""" + raise NotImplementedError(f"Unrecognized expression: {expression}") + + @compile_expression.register + def _(self, expr: ex.DerefOp) -> sge.Expression: + return sge.Column(this=sge.to_identifier(expr.id.sql, quoted=True)) + + @compile_expression.register + def _(self, expr: ex.ScalarConstantExpression) -> sge.Expression: + return ir._literal(expr.value, expr.dtype) + + @compile_expression.register + def _(self, expr: ex.OpExpression) -> sge.Expression: + # Non-recursively compiles the children scalar expressions. + inputs = tuple( + TypedExpr(self.compile_expression(sub_expr), sub_expr.output_type) + for sub_expr in expr.inputs + ) + return self.compile_row_op(expr.op, inputs) + + def compile_row_op( + self, op: ops.RowOp, inputs: typing.Sequence[TypedExpr] + ) -> sge.Expression: + impl = self._registry[op.name] + return impl(inputs, op) + + def register_unary_op( + self, + op_ref: typing.Union[ops.UnaryOp, type[ops.UnaryOp]], + pass_op: bool = False, + ): + """ + Decorator to register a unary op implementation. + + Args: + op_ref (UnaryOp or UnaryOp type): + Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., sge.Expression]): + def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): + if pass_op: + return impl(args[0], op) + else: + return impl(args[0]) + + self._register(key, normalized_impl) + return impl + + return decorator + + def register_binary_op( + self, + op_ref: typing.Union[ops.BinaryOp, type[ops.BinaryOp]], + pass_op: bool = False, + ): + """ + Decorator to register a binary op implementation. + + Args: + op_ref (BinaryOp or BinaryOp type): + Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., sge.Expression]): + def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): + left = self._add_parentheses(args[0]) + right = self._add_parentheses(args[1]) + if pass_op: + return impl(left, right, op) + else: + return impl(left, right) + + self._register(key, normalized_impl) + return impl + + return decorator + + def register_ternary_op( + self, op_ref: typing.Union[ops.TernaryOp, type[ops.TernaryOp]] + ): + """ + Decorator to register a ternary op implementation. + + Args: + op_ref (TernaryOp or TernaryOp type): + Class or instance of operator that is implemented by the decorated function. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., sge.Expression]): + def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): + return impl(args[0], args[1], args[2]) + + self._register(key, normalized_impl) + return impl + + return decorator + + def register_nary_op( + self, op_ref: typing.Union[ops.NaryOp, type[ops.NaryOp]], pass_op: bool = False + ): + """ + Decorator to register a nary op implementation. + + Args: + op_ref (NaryOp or NaryOp type): + Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., sge.Expression]): + def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): + if pass_op: + return impl(*args, op=op) + else: + return impl(*args) + + self._register(key, normalized_impl) + return impl + + return decorator + + def _register( + self, + op_name: str, + impl: typing.Callable[[typing.Sequence[TypedExpr], ops.RowOp], sge.Expression], + ): + if op_name in self._registry: + raise ValueError(f"Operation name {op_name} already registered") + self._registry[op_name] = impl + + @classmethod + def _add_parentheses(cls, expr: TypedExpr) -> TypedExpr: + if type(expr.expr) in cls.SQLGLOT_NEEDS_PARENS: + return TypedExpr(sge.paren(expr.expr, copy=False), expr.dtype) + return expr + + +# Singleton compiler +scalar_op_compiler = ScalarOpCompiler() diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py new file mode 100644 index 0000000000..176564fe23 --- /dev/null +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -0,0 +1,841 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +import datetime +import functools +import typing + +from google.cloud import bigquery +import numpy as np +import pandas as pd +import pyarrow as pa +import sqlglot as sg +import sqlglot.dialects.bigquery +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes.core import guid, local_data, schema, utils +from bigframes.core.compile.sqlglot.expressions import constants, typed_expr +import bigframes.core.compile.sqlglot.sqlglot_types as sgt + +# shapely.wkt.dumps was moved to shapely.io.to_wkt in 2.0. +try: + from shapely.io import to_wkt # type: ignore +except ImportError: + from shapely.wkt import dumps # type: ignore + + to_wkt = dumps + + +@dataclasses.dataclass(frozen=True) +class SQLGlotIR: + """Helper class to build SQLGlot Query and generate SQL string.""" + + expr: sge.Select = sg.select() + """The SQLGlot expression representing the query.""" + + dialect = sqlglot.dialects.bigquery.BigQuery + """The SQL dialect used for generation.""" + + quoted: bool = True + """Whether to quote identifiers in the generated SQL.""" + + pretty: bool = True + """Whether to pretty-print the generated SQL.""" + + uid_gen: guid.SequentialUIDGenerator = guid.SequentialUIDGenerator() + """Generator for unique identifiers.""" + + @property + def sql(self) -> str: + """Generate SQL string from the given expression.""" + return self.expr.sql(dialect=self.dialect, pretty=self.pretty) + + @classmethod + def from_pyarrow( + cls, + pa_table: pa.Table, + schema: schema.ArraySchema, + uid_gen: guid.SequentialUIDGenerator, + ) -> SQLGlotIR: + """Builds SQLGlot expression from a pyarrow table. + + This is used to represent in-memory data as a SQL query. + """ + dtype_expr = sge.DataType( + this=sge.DataType.Type.STRUCT, + expressions=[ + sge.ColumnDef( + this=sge.to_identifier(field.column, quoted=True), + kind=sgt.from_bigframes_dtype(field.dtype), + ) + for field in schema.items + ], + nested=True, + ) + data_expr = [ + sge.Struct( + expressions=tuple( + _literal( + value=value, + dtype=field.dtype, + ) + for value, field in zip(tuple(row_dict.values()), schema.items) + ) + ) + for row_dict in local_data._iter_table(pa_table, schema) + ] + expr = sge.Unnest( + expressions=[ + sge.DataType( + this=sge.DataType.Type.ARRAY, + expressions=[dtype_expr], + nested=True, + values=data_expr, + ), + ], + ) + return cls(expr=sg.select(sge.Star()).from_(expr), uid_gen=uid_gen) + + @classmethod + def from_table( + cls, + project_id: str, + dataset_id: str, + table_id: str, + col_names: typing.Sequence[str], + alias_names: typing.Sequence[str], + uid_gen: guid.SequentialUIDGenerator, + sql_predicate: typing.Optional[str] = None, + system_time: typing.Optional[datetime.datetime] = None, + ) -> SQLGlotIR: + """Builds a SQLGlotIR expression from a BigQuery table. + + Args: + project_id (str): The project ID of the BigQuery table. + dataset_id (str): The dataset ID of the BigQuery table. + table_id (str): The table ID of the BigQuery table. + col_names (typing.Sequence[str]): The names of the columns to select. + alias_names (typing.Sequence[str]): The aliases for the selected columns. + uid_gen (guid.SequentialUIDGenerator): A generator for unique identifiers. + sql_predicate (typing.Optional[str]): An optional SQL predicate for filtering. + system_time (typing.Optional[str]): An optional system time for time-travel queries. + """ + selections = [ + sge.Alias( + this=sge.to_identifier(col_name, quoted=cls.quoted), + alias=sge.to_identifier(alias_name, quoted=cls.quoted), + ) + if col_name != alias_name + else sge.to_identifier(col_name, quoted=cls.quoted) + for col_name, alias_name in zip(col_names, alias_names) + ] + version = ( + sge.Version( + this="TIMESTAMP", + expression=sge.Literal(this=system_time.isoformat(), is_string=True), + kind="AS OF", + ) + if system_time + else None + ) + table_expr = sge.Table( + this=sg.to_identifier(table_id, quoted=cls.quoted), + db=sg.to_identifier(dataset_id, quoted=cls.quoted), + catalog=sg.to_identifier(project_id, quoted=cls.quoted), + version=version, + ) + select_expr = sge.Select().select(*selections).from_(table_expr) + if sql_predicate: + select_expr = select_expr.where( + sg.parse_one(sql_predicate, dialect="bigquery"), append=False + ) + return cls(expr=select_expr, uid_gen=uid_gen) + + @classmethod + def from_query_string( + cls, + query_string: str, + ) -> SQLGlotIR: + """Builds a SQLGlot expression from a query string""" + uid_gen: guid.SequentialUIDGenerator = guid.SequentialUIDGenerator() + cte_name = sge.to_identifier( + next(uid_gen.get_uid_stream("bfcte_")), quoted=cls.quoted + ) + cte = sge.CTE( + this=query_string, + alias=cte_name, + ) + select_expr = sge.Select().select(sge.Star()).from_(sge.Table(this=cte_name)) + select_expr = _set_query_ctes(select_expr, [cte]) + return cls(expr=select_expr, uid_gen=uid_gen) + + @classmethod + def from_union( + cls, + selects: typing.Sequence[sge.Select], + output_ids: typing.Sequence[str], + uid_gen: guid.SequentialUIDGenerator, + ) -> SQLGlotIR: + """Builds a SQLGlot expression by unioning of multiple select expressions.""" + assert ( + len(list(selects)) >= 2 + ), f"At least two select expressions must be provided, but got {selects}." + + existing_ctes: list[sge.CTE] = [] + union_selects: list[sge.Expression] = [] + for select in selects: + assert isinstance( + select, sge.Select + ), f"All provided expressions must be of type sge.Select, but got {type(select)}" + + select_expr = select.copy() + select_expr, select_ctes = _pop_query_ctes(select_expr) + existing_ctes = [*existing_ctes, *select_ctes] + + new_cte_name = sge.to_identifier( + next(uid_gen.get_uid_stream("bfcte_")), quoted=cls.quoted + ) + new_cte = sge.CTE( + this=select_expr, + alias=new_cte_name, + ) + existing_ctes = [*existing_ctes, new_cte] + + selections = [ + sge.Alias( + this=sge.to_identifier(expr.alias_or_name, quoted=cls.quoted), + alias=sge.to_identifier(output_id, quoted=cls.quoted), + ) + for expr, output_id in zip(select_expr.expressions, output_ids) + ] + union_selects.append( + sge.Select().select(*selections).from_(sge.Table(this=new_cte_name)) + ) + + union_expr = typing.cast( + sge.Select, + functools.reduce( + lambda x, y: sge.Union( + this=x, expression=y, distinct=False, copy=False + ), + union_selects, + ), + ) + final_select_expr = sge.Select().select(sge.Star()).from_(union_expr.subquery()) + final_select_expr = _set_query_ctes(final_select_expr, existing_ctes) + return cls(expr=final_select_expr, uid_gen=uid_gen) + + def select( + self, + selected_cols: tuple[tuple[str, sge.Expression], ...], + ) -> SQLGlotIR: + """Replaces new selected columns of the current SELECT clause.""" + selections = [ + sge.Alias( + this=expr, + alias=sge.to_identifier(id, quoted=self.quoted), + ) + if expr.alias_or_name != id + else expr + for id, expr in selected_cols + ] + + new_expr = _select_to_cte( + self.expr, + sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ), + ) + new_expr = new_expr.select(*selections, append=False) + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + def project( + self, + projected_cols: tuple[tuple[str, sge.Expression], ...], + ) -> SQLGlotIR: + """Adds new columns to the SELECT clause.""" + projected_cols_expr = [ + sge.Alias( + this=expr, + alias=sge.to_identifier(id, quoted=self.quoted), + ) + for id, expr in projected_cols + ] + new_expr = _select_to_cte( + self.expr, + sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ), + ) + new_expr = new_expr.select(*projected_cols_expr, append=True) + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + def order_by( + self, + ordering: tuple[sge.Ordered, ...], + ) -> SQLGlotIR: + """Adds an ORDER BY clause to the query.""" + if len(ordering) == 0: + return SQLGlotIR(expr=self.expr.copy(), uid_gen=self.uid_gen) + new_expr = self.expr.order_by(*ordering) + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + def limit( + self, + limit: int | None, + ) -> SQLGlotIR: + """Adds a LIMIT clause to the query.""" + if limit is not None: + new_expr = self.expr.limit(limit) + else: + new_expr = self.expr.copy() + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + def filter( + self, + conditions: tuple[sge.Expression, ...], + ) -> SQLGlotIR: + """Filters the query by adding a WHERE clause.""" + condition = _and(conditions) + if condition is None: + return SQLGlotIR(expr=self.expr.copy(), uid_gen=self.uid_gen) + + new_expr = _select_to_cte( + self.expr, + sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ), + ) + return SQLGlotIR( + expr=new_expr.where(condition, append=False), uid_gen=self.uid_gen + ) + + def join( + self, + right: SQLGlotIR, + join_type: typing.Literal["inner", "outer", "left", "right", "cross"], + conditions: tuple[tuple[typed_expr.TypedExpr, typed_expr.TypedExpr], ...], + *, + joins_nulls: bool = True, + ) -> SQLGlotIR: + """Joins the current query with another SQLGlotIR instance.""" + left_cte_name = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ) + right_cte_name = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ) + + left_select = _select_to_cte(self.expr, left_cte_name) + right_select = _select_to_cte(right.expr, right_cte_name) + + left_select, left_ctes = _pop_query_ctes(left_select) + right_select, right_ctes = _pop_query_ctes(right_select) + merged_ctes = [*left_ctes, *right_ctes] + + join_on = _and( + tuple( + _join_condition(left, right, joins_nulls) for left, right in conditions + ) + ) + + join_type_str = join_type if join_type != "outer" else "full outer" + new_expr = ( + sge.Select() + .select(sge.Star()) + .from_(sge.Table(this=left_cte_name)) + .join(sge.Table(this=right_cte_name), on=join_on, join_type=join_type_str) + ) + new_expr = _set_query_ctes(new_expr, merged_ctes) + + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + def isin_join( + self, + right: SQLGlotIR, + indicator_col: str, + conditions: tuple[typed_expr.TypedExpr, typed_expr.TypedExpr], + joins_nulls: bool = True, + ) -> SQLGlotIR: + """Joins the current query with another SQLGlotIR instance.""" + left_cte_name = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ) + + left_select = _select_to_cte(self.expr, left_cte_name) + # Prefer subquery over CTE for the IN clause's right side to improve SQL readability. + right_select = right.expr + + left_select, left_ctes = _pop_query_ctes(left_select) + right_select, right_ctes = _pop_query_ctes(right_select) + merged_ctes = [*left_ctes, *right_ctes] + + left_condition = typed_expr.TypedExpr( + sge.Column(this=conditions[0].expr, table=left_cte_name), + conditions[0].dtype, + ) + + new_column: sge.Expression + if joins_nulls: + right_table_name = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bft_")), quoted=self.quoted + ) + right_condition = typed_expr.TypedExpr( + sge.Column(this=conditions[1].expr, table=right_table_name), + conditions[1].dtype, + ) + new_column = sge.Exists( + this=sge.Select() + .select(sge.convert(1)) + .from_(sge.Alias(this=right_select.subquery(), alias=right_table_name)) + .where( + _join_condition(left_condition, right_condition, joins_nulls=True) + ) + ) + else: + new_column = sge.In( + this=left_condition.expr, + expressions=[right_select.subquery()], + ) + + new_column = sge.Alias( + this=new_column, + alias=sge.to_identifier(indicator_col, quoted=self.quoted), + ) + + new_expr = ( + sge.Select() + .select(sge.Column(this=sge.Star(), table=left_cte_name), new_column) + .from_(sge.Table(this=left_cte_name)) + ) + new_expr = _set_query_ctes(new_expr, merged_ctes) + + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + def explode( + self, + column_names: tuple[str, ...], + offsets_col: typing.Optional[str], + ) -> SQLGlotIR: + """Unnests one or more array columns.""" + num_columns = len(list(column_names)) + assert num_columns > 0, "At least one column must be provided for explode." + if num_columns == 1: + return self._explode_single_column(column_names[0], offsets_col) + else: + return self._explode_multiple_columns(column_names, offsets_col) + + def sample(self, fraction: float) -> SQLGlotIR: + """Uniform samples a fraction of the rows.""" + uuid_col = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcol_")), quoted=self.quoted + ) + uuid_expr = sge.Alias(this=sge.func("RAND"), alias=uuid_col) + condition = sge.LT( + this=uuid_col, + expression=_literal(fraction, dtypes.FLOAT_DTYPE), + ) + + new_cte_name = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ) + new_expr = _select_to_cte( + self.expr.select(uuid_expr, append=True), new_cte_name + ).where(condition, append=False) + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + def aggregate( + self, + aggregations: tuple[tuple[str, sge.Expression], ...], + by_cols: tuple[sge.Expression, ...], + dropna_cols: tuple[sge.Expression, ...], + ) -> SQLGlotIR: + """Applies the aggregation expressions. + + Args: + aggregations: output_column_id, aggregation_expr tuples + by_cols: column expressions for aggregation + dropna_cols: columns whether null keys should be dropped + """ + aggregations_expr = [ + sge.Alias( + this=expr, + alias=sge.to_identifier(id, quoted=self.quoted), + ) + for id, expr in aggregations + ] + + new_expr = _select_to_cte( + self.expr, + sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ), + ) + new_expr = new_expr.group_by(*by_cols).select( + *[*by_cols, *aggregations_expr], append=False + ) + + condition = _and( + tuple( + sg.not_(sge.Is(this=drop_col, expression=sge.Null())) + for drop_col in dropna_cols + ) + ) + if condition is not None: + new_expr = new_expr.where(condition, append=False) + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + def window( + self, + window_op: sge.Expression, + output_column_id: str, + ) -> SQLGlotIR: + return self.project(((output_column_id, window_op),)) + + def insert( + self, + destination: bigquery.TableReference, + ) -> str: + """Generates an INSERT INTO SQL statement from the current SELECT clause.""" + return sge.insert(self.expr.subquery(), _table(destination)).sql( + dialect=self.dialect, pretty=self.pretty + ) + + def replace( + self, + destination: bigquery.TableReference, + ) -> str: + """Generates a MERGE statement to replace the destination table's contents. + by the current SELECT clause. + """ + # Workaround for SQLGlot breaking change: + # https://github.com/tobymao/sqlglot/pull/4495 + whens_expr = [ + sge.When(matched=False, source=True, then=sge.Delete()), + sge.When(matched=False, then=sge.Insert(this=sge.Var(this="ROW"))), + ] + whens_str = "\n".join( + when_expr.sql(dialect=self.dialect, pretty=self.pretty) + for when_expr in whens_expr + ) + + merge_str = sge.Merge( + this=_table(destination), + using=self.expr.subquery(), + on=_literal(False, dtypes.BOOL_DTYPE), + ).sql(dialect=self.dialect, pretty=self.pretty) + return f"{merge_str}\n{whens_str}" + + def _explode_single_column( + self, column_name: str, offsets_col: typing.Optional[str] + ) -> SQLGlotIR: + """Helper method to handle the case of exploding a single column.""" + offset = ( + sge.to_identifier(offsets_col, quoted=self.quoted) if offsets_col else None + ) + column = sge.to_identifier(column_name, quoted=self.quoted) + unnested_column_alias = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcol_")), quoted=self.quoted + ) + unnest_expr = sge.Unnest( + expressions=[column], + alias=sge.TableAlias(columns=[unnested_column_alias]), + offset=offset, + ) + selection = sge.Star(replace=[unnested_column_alias.as_(column)]) + + # TODO: "CROSS" if not keep_empty else "LEFT" + # TODO: overlaps_with_parent to replace existing column. + new_expr = _select_to_cte( + self.expr, + sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ), + ) + new_expr = new_expr.select(selection, append=False).join( + unnest_expr, join_type="CROSS" + ) + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + def _explode_multiple_columns( + self, + column_names: tuple[str, ...], + offsets_col: typing.Optional[str], + ) -> SQLGlotIR: + """Helper method to handle the case of exploding multiple columns.""" + offset = ( + sge.to_identifier(offsets_col, quoted=self.quoted) if offsets_col else None + ) + columns = [ + sge.to_identifier(column_name, quoted=self.quoted) + for column_name in column_names + ] + + # If there are multiple columns, we need to unnest by zipping the arrays: + # https://cloud.google.com/bigquery/docs/arrays#zipping_arrays + column_lengths = [ + sge.func("ARRAY_LENGTH", sge.to_identifier(column, quoted=self.quoted)) - 1 + for column in columns + ] + generate_array = sge.func( + "GENERATE_ARRAY", + sge.convert(0), + sge.func("LEAST", *column_lengths), + ) + unnested_offset_alias = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcol_")), quoted=self.quoted + ) + unnest_expr = sge.Unnest( + expressions=[generate_array], + alias=sge.TableAlias(columns=[unnested_offset_alias]), + offset=offset, + ) + selection = sge.Star( + replace=[ + sge.Bracket( + this=column, + expressions=[unnested_offset_alias], + safe=True, + offset=False, + ).as_(column) + for column in columns + ] + ) + new_expr = _select_to_cte( + self.expr, + sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ), + ) + new_expr = new_expr.select(selection, append=False).join( + unnest_expr, join_type="CROSS" + ) + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + + +def _select_to_cte(expr: sge.Select, cte_name: sge.Identifier) -> sge.Select: + """Transforms a given sge.Select query by pushing its main SELECT statement + into a new CTE and then generates a 'SELECT * FROM new_cte_name' + for the new query.""" + select_expr = expr.copy() + select_expr, existing_ctes = _pop_query_ctes(select_expr) + new_cte = sge.CTE( + this=select_expr, + alias=cte_name, + ) + new_select_expr = sge.Select().select(sge.Star()).from_(sge.Table(this=cte_name)) + new_select_expr = _set_query_ctes(new_select_expr, [*existing_ctes, new_cte]) + return new_select_expr + + +def _literal(value: typing.Any, dtype: dtypes.Dtype) -> sge.Expression: + sqlglot_type = sgt.from_bigframes_dtype(dtype) if dtype else None + if sqlglot_type is None: + if not pd.isna(value): + raise ValueError(f"Cannot infer SQLGlot type from None dtype: {value}") + return sge.Null() + + if value is None: + return _cast(sge.Null(), sqlglot_type) + if dtypes.is_struct_like(dtype): + items = [ + _literal(value=value[field_name], dtype=field_dtype).as_( + field_name, quoted=True + ) + for field_name, field_dtype in dtypes.get_struct_fields(dtype).items() + ] + return sge.Struct.from_arg_list(items) + elif dtypes.is_array_like(dtype): + value_type = dtypes.get_array_inner_type(dtype) + values = sge.Array( + expressions=[_literal(value=v, dtype=value_type) for v in value] + ) + return values if len(value) > 0 else _cast(values, sqlglot_type) + elif pd.isna(value): + return _cast(sge.Null(), sqlglot_type) + elif dtype == dtypes.JSON_DTYPE: + return sge.ParseJSON(this=sge.convert(str(value))) + elif dtype == dtypes.BYTES_DTYPE: + return _cast(str(value), sqlglot_type) + elif dtypes.is_time_like(dtype): + if isinstance(value, str): + return _cast(sge.convert(value), sqlglot_type) + if isinstance(value, np.generic): + value = value.item() + return _cast(sge.convert(value.isoformat()), sqlglot_type) + elif dtype in (dtypes.NUMERIC_DTYPE, dtypes.BIGNUMERIC_DTYPE): + return _cast(sge.convert(value), sqlglot_type) + elif dtypes.is_geo_like(dtype): + wkt = value if isinstance(value, str) else to_wkt(value) + return sge.func("ST_GEOGFROMTEXT", sge.convert(wkt)) + elif dtype == dtypes.TIMEDELTA_DTYPE: + return sge.convert(utils.timedelta_to_micros(value)) + elif dtype == dtypes.FLOAT_DTYPE: + if np.isinf(value): + return constants._INF if value > 0 else constants._NEG_INF + return sge.convert(value) + else: + if isinstance(value, np.generic): + value = value.item() + return sge.convert(value) + + +def _cast(arg: typing.Any, to: str) -> sge.Cast: + return sge.Cast(this=arg, to=to) + + +def _table(table: bigquery.TableReference) -> sge.Table: + return sge.Table( + this=sg.to_identifier(table.table_id, quoted=True), + db=sg.to_identifier(table.dataset_id, quoted=True), + catalog=sg.to_identifier(table.project, quoted=True), + ) + + +def _and(conditions: tuple[sge.Expression, ...]) -> typing.Optional[sge.Expression]: + """Chains multiple expressions together using a logical AND.""" + if not conditions: + return None + + return functools.reduce( + lambda left, right: sge.And(this=left, expression=right), conditions + ) + + +def _join_condition( + left: typed_expr.TypedExpr, + right: typed_expr.TypedExpr, + joins_nulls: bool, +) -> typing.Union[sge.EQ, sge.And]: + """Generates a join condition to match pandas's null-handling logic. + + Pandas treats null values as distinct from each other, leading to a + cross-join-like behavior for null keys. In contrast, BigQuery SQL treats + null values as equal, leading to a inner-join-like behavior. + + This function generates the appropriate SQL condition to replicate the + desired pandas behavior in BigQuery. + + Args: + left: The left-side join key. + right: The right-side join key. + joins_nulls: If True, generates complex logic to handle nulls/NaNs. + Otherwise, uses a simple equality check where appropriate. + """ + is_floating_types = ( + left.dtype == dtypes.FLOAT_DTYPE and right.dtype == dtypes.FLOAT_DTYPE + ) + if not is_floating_types and not joins_nulls: + return sge.EQ(this=left.expr, expression=right.expr) + + is_numeric_types = dtypes.is_numeric( + left.dtype, include_bool=False + ) and dtypes.is_numeric(right.dtype, include_bool=False) + if is_numeric_types: + return _join_condition_for_numeric(left, right) + else: + return _join_condition_for_others(left, right) + + +def _join_condition_for_others( + left: typed_expr.TypedExpr, + right: typed_expr.TypedExpr, +) -> sge.And: + """Generates a join condition for non-numeric types to match pandas's + null-handling logic. + """ + left_str = _cast(left.expr, "STRING") + right_str = _cast(right.expr, "STRING") + left_0 = sge.func("COALESCE", left_str, _literal("0", dtypes.STRING_DTYPE)) + left_1 = sge.func("COALESCE", left_str, _literal("1", dtypes.STRING_DTYPE)) + right_0 = sge.func("COALESCE", right_str, _literal("0", dtypes.STRING_DTYPE)) + right_1 = sge.func("COALESCE", right_str, _literal("1", dtypes.STRING_DTYPE)) + return sge.And( + this=sge.EQ(this=left_0, expression=right_0), + expression=sge.EQ(this=left_1, expression=right_1), + ) + + +def _join_condition_for_numeric( + left: typed_expr.TypedExpr, + right: typed_expr.TypedExpr, +) -> sge.And: + """Generates a join condition for non-numeric types to match pandas's + null-handling logic. Specifically for FLOAT types, Pandas treats NaN aren't + equal so need to coalesce as well with different constants. + """ + is_floating_types = ( + left.dtype == dtypes.FLOAT_DTYPE and right.dtype == dtypes.FLOAT_DTYPE + ) + left_0 = sge.func("COALESCE", left.expr, _literal(0, left.dtype)) + left_1 = sge.func("COALESCE", left.expr, _literal(1, left.dtype)) + right_0 = sge.func("COALESCE", right.expr, _literal(0, right.dtype)) + right_1 = sge.func("COALESCE", right.expr, _literal(1, right.dtype)) + if not is_floating_types: + return sge.And( + this=sge.EQ(this=left_0, expression=right_0), + expression=sge.EQ(this=left_1, expression=right_1), + ) + + left_2 = sge.If( + this=sge.IsNan(this=left.expr), true=_literal(2, left.dtype), false=left_0 + ) + left_3 = sge.If( + this=sge.IsNan(this=left.expr), true=_literal(3, left.dtype), false=left_1 + ) + right_2 = sge.If( + this=sge.IsNan(this=right.expr), true=_literal(2, right.dtype), false=right_0 + ) + right_3 = sge.If( + this=sge.IsNan(this=right.expr), true=_literal(3, right.dtype), false=right_1 + ) + return sge.And( + this=sge.EQ(this=left_2, expression=right_2), + expression=sge.EQ(this=left_3, expression=right_3), + ) + + +def _set_query_ctes( + expr: sge.Select, + ctes: list[sge.CTE], +) -> sge.Select: + """Sets the CTEs of a given sge.Select expression.""" + new_expr = expr.copy() + with_expr = sge.With(expressions=ctes) if len(ctes) > 0 else None + + if "with" in new_expr.arg_types.keys(): + new_expr.set("with", with_expr) + elif "with_" in new_expr.arg_types.keys(): + new_expr.set("with_", with_expr) + else: + raise ValueError("The expression does not support CTEs.") + return new_expr + + +def _pop_query_ctes( + expr: sge.Select, +) -> tuple[sge.Select, list[sge.CTE]]: + """Pops the CTEs of a given sge.Select expression.""" + if "with" in expr.arg_types.keys(): + expr_ctes = expr.args.pop("with", []) + return expr, expr_ctes + elif "with_" in expr.arg_types.keys(): + expr_ctes = expr.args.pop("with_", []) + return expr, expr_ctes + else: + raise ValueError("The expression does not support CTEs.") diff --git a/bigframes/core/compile/sqlglot/sqlglot_types.py b/bigframes/core/compile/sqlglot/sqlglot_types.py new file mode 100644 index 0000000000..64e4363ddf --- /dev/null +++ b/bigframes/core/compile/sqlglot/sqlglot_types.py @@ -0,0 +1,81 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import bigframes_vendored.constants as constants +import numpy as np +import pandas as pd +import pyarrow as pa +import sqlglot as sg + +import bigframes.dtypes + + +def from_bigframes_dtype( + bigframes_dtype: typing.Union[ + bigframes.dtypes.DtypeString, bigframes.dtypes.Dtype, np.dtype[typing.Any] + ], +) -> str: + if bigframes_dtype == bigframes.dtypes.INT_DTYPE: + return "INT64" + elif bigframes_dtype == bigframes.dtypes.FLOAT_DTYPE: + return "FLOAT64" + elif bigframes_dtype == bigframes.dtypes.STRING_DTYPE: + return "STRING" + elif bigframes_dtype == bigframes.dtypes.BOOL_DTYPE: + return "BOOLEAN" + elif bigframes_dtype == bigframes.dtypes.DATE_DTYPE: + return "DATE" + elif bigframes_dtype == bigframes.dtypes.TIME_DTYPE: + return "TIME" + elif bigframes_dtype == bigframes.dtypes.DATETIME_DTYPE: + return "DATETIME" + elif bigframes_dtype == bigframes.dtypes.TIMESTAMP_DTYPE: + return "TIMESTAMP" + elif bigframes_dtype == bigframes.dtypes.BYTES_DTYPE: + return "BYTES" + elif bigframes_dtype == bigframes.dtypes.NUMERIC_DTYPE: + return "NUMERIC" + elif bigframes_dtype == bigframes.dtypes.BIGNUMERIC_DTYPE: + return "BIGNUMERIC" + elif bigframes_dtype == bigframes.dtypes.JSON_DTYPE: + return "JSON" + elif bigframes_dtype == bigframes.dtypes.GEO_DTYPE: + return "GEOGRAPHY" + elif bigframes_dtype == bigframes.dtypes.TIMEDELTA_DTYPE: + return "INT64" + elif isinstance(bigframes_dtype, pd.ArrowDtype): + if pa.types.is_list(bigframes_dtype.pyarrow_dtype): + inner_bigframes_dtype = bigframes.dtypes.arrow_dtype_to_bigframes_dtype( + bigframes_dtype.pyarrow_dtype.value_type + ) + return f"ARRAY<{from_bigframes_dtype(inner_bigframes_dtype)}>" + elif pa.types.is_struct(bigframes_dtype.pyarrow_dtype): + struct_type = typing.cast(pa.StructType, bigframes_dtype.pyarrow_dtype) + inner_fields: list[str] = [] + for i in range(struct_type.num_fields): + field = struct_type.field(i) + key = sg.to_identifier(field.name).sql("bigquery") + dtype = from_bigframes_dtype( + bigframes.dtypes.arrow_dtype_to_bigframes_dtype(field.type) + ) + inner_fields.append(f"{key} {dtype}") + return "STRUCT<{}>".format(", ".join(inner_fields)) + + raise ValueError( + f"Unsupported type for {bigframes_dtype}. {constants.FEEDBACK_LINK}" + ) diff --git a/bigframes/core/convert.py b/bigframes/core/convert.py index 94a0564556..1546c2f87e 100644 --- a/bigframes/core/convert.py +++ b/bigframes/core/convert.py @@ -54,7 +54,7 @@ def to_bf_series( bigframes.pandas.Series """ if isinstance(obj, series.Series): - return obj + return obj.copy() if session is None: session = global_session.get_global_session() @@ -118,7 +118,7 @@ def to_bf_dataframe( session: Optional[session.Session] = None, ) -> dataframe.DataFrame: if isinstance(obj, dataframe.DataFrame): - return obj + return obj.copy() if isinstance(obj, pd.DataFrame): if session is None: diff --git a/bigframes/core/events.py b/bigframes/core/events.py new file mode 100644 index 0000000000..d0e5f7ad69 --- /dev/null +++ b/bigframes/core/events.py @@ -0,0 +1,237 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +import datetime +import threading +from typing import Any, Callable, Optional, Set +import uuid + +import google.cloud.bigquery._job_helpers +import google.cloud.bigquery.job.query +import google.cloud.bigquery.table + +import bigframes.session.executor + + +class Subscriber: + def __init__(self, callback: Callable[[Event], None], *, publisher: Publisher): + self._publisher = publisher + self._callback = callback + self._subscriber_id = uuid.uuid4() + + def __call__(self, *args, **kwargs): + return self._callback(*args, **kwargs) + + def __hash__(self) -> int: + return hash(self._subscriber_id) + + def __eq__(self, value: object): + if not isinstance(value, Subscriber): + return NotImplemented + return value._subscriber_id == self._subscriber_id + + def close(self): + self._publisher.unsubscribe(self) + del self._publisher + del self._callback + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_value is not None: + self( + UnknownErrorEvent( + exc_type=exc_type, + exc_value=exc_value, + traceback=traceback, + ) + ) + self.close() + + +class Publisher: + def __init__(self): + self._subscribers_lock = threading.Lock() + self._subscribers: Set[Subscriber] = set() + + def subscribe(self, callback: Callable[[Event], None]) -> Subscriber: + # TODO(b/448176657): figure out how to handle subscribers/publishers in + # a background thread. Maybe subscribers should be thread-local? + subscriber = Subscriber(callback, publisher=self) + with self._subscribers_lock: + self._subscribers.add(subscriber) + return subscriber + + def unsubscribe(self, subscriber: Subscriber): + with self._subscribers_lock: + self._subscribers.remove(subscriber) + + def publish(self, event: Event): + with self._subscribers_lock: + for subscriber in self._subscribers: + subscriber(event) + + +class Event: + pass + + +@dataclasses.dataclass(frozen=True) +class SessionClosed(Event): + session_id: str + + +class ExecutionStarted(Event): + pass + + +class ExecutionRunning(Event): + pass + + +@dataclasses.dataclass(frozen=True) +class ExecutionFinished(Event): + result: Optional[bigframes.session.executor.ExecuteResult] = None + + +@dataclasses.dataclass(frozen=True) +class UnknownErrorEvent(Event): + exc_type: Any + exc_value: Any + traceback: Any + + +@dataclasses.dataclass(frozen=True) +class BigQuerySentEvent(ExecutionRunning): + """Query sent to BigQuery.""" + + query: str + billing_project: Optional[str] = None + location: Optional[str] = None + job_id: Optional[str] = None + request_id: Optional[str] = None + + @classmethod + def from_bqclient(cls, event: google.cloud.bigquery._job_helpers.QuerySentEvent): + return cls( + query=event.query, + billing_project=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + + +@dataclasses.dataclass(frozen=True) +class BigQueryRetryEvent(ExecutionRunning): + """Query sent another time because the previous attempt failed.""" + + query: str + billing_project: Optional[str] = None + location: Optional[str] = None + job_id: Optional[str] = None + request_id: Optional[str] = None + + @classmethod + def from_bqclient(cls, event: google.cloud.bigquery._job_helpers.QueryRetryEvent): + return cls( + query=event.query, + billing_project=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + + +@dataclasses.dataclass(frozen=True) +class BigQueryReceivedEvent(ExecutionRunning): + """Query received and acknowledged by the BigQuery API.""" + + billing_project: Optional[str] = None + location: Optional[str] = None + job_id: Optional[str] = None + statement_type: Optional[str] = None + state: Optional[str] = None + query_plan: Optional[list[google.cloud.bigquery.job.query.QueryPlanEntry]] = None + created: Optional[datetime.datetime] = None + started: Optional[datetime.datetime] = None + ended: Optional[datetime.datetime] = None + + @classmethod + def from_bqclient( + cls, event: google.cloud.bigquery._job_helpers.QueryReceivedEvent + ): + return cls( + billing_project=event.billing_project, + location=event.location, + job_id=event.job_id, + statement_type=event.statement_type, + state=event.state, + query_plan=event.query_plan, + created=event.created, + started=event.started, + ended=event.ended, + ) + + +@dataclasses.dataclass(frozen=True) +class BigQueryFinishedEvent(ExecutionRunning): + """Query finished successfully.""" + + billing_project: Optional[str] = None + location: Optional[str] = None + query_id: Optional[str] = None + job_id: Optional[str] = None + destination: Optional[google.cloud.bigquery.table.TableReference] = None + total_rows: Optional[int] = None + total_bytes_processed: Optional[int] = None + slot_millis: Optional[int] = None + created: Optional[datetime.datetime] = None + started: Optional[datetime.datetime] = None + ended: Optional[datetime.datetime] = None + + @classmethod + def from_bqclient( + cls, event: google.cloud.bigquery._job_helpers.QueryFinishedEvent + ): + return cls( + billing_project=event.billing_project, + location=event.location, + query_id=event.query_id, + job_id=event.job_id, + destination=event.destination, + total_rows=event.total_rows, + total_bytes_processed=event.total_bytes_processed, + slot_millis=event.slot_millis, + created=event.created, + started=event.started, + ended=event.ended, + ) + + +@dataclasses.dataclass(frozen=True) +class BigQueryUnknownEvent(ExecutionRunning): + """Got unknown event from the BigQuery client library.""" + + # TODO: should we just skip sending unknown events? + + event: object + + @classmethod + def from_bqclient(cls, event): + return cls(event) diff --git a/bigframes/core/expression.py b/bigframes/core/expression.py index afd290827d..89bcb9b920 100644 --- a/bigframes/core/expression.py +++ b/bigframes/core/expression.py @@ -16,16 +16,17 @@ import abc import dataclasses +import functools import itertools import typing -from typing import Generator, Mapping, TypeVar, Union +from typing import Callable, Generator, Mapping, TypeVar, Union import pandas as pd +from bigframes import dtypes +from bigframes.core import field import bigframes.core.identifiers as ids -import bigframes.dtypes as dtypes import bigframes.operations -import bigframes.operations.aggregations as agg_ops def const( @@ -42,108 +43,7 @@ def free_var(id: str) -> UnboundVariableExpression: return UnboundVariableExpression(id) -@dataclasses.dataclass(frozen=True) -class Aggregation(abc.ABC): - """Represents windowing or aggregation over a column.""" - - op: agg_ops.WindowOp = dataclasses.field() - - @abc.abstractmethod - def output_type( - self, input_types: dict[ids.ColumnId, dtypes.ExpressionType] - ) -> dtypes.ExpressionType: - ... - - @property - def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: - return () - - @abc.abstractmethod - def remap_column_refs( - self, - name_mapping: Mapping[ids.ColumnId, ids.ColumnId], - allow_partial_bindings: bool = False, - ) -> Aggregation: - ... - - -@dataclasses.dataclass(frozen=True) -class NullaryAggregation(Aggregation): - op: agg_ops.NullaryWindowOp = dataclasses.field() - - def output_type( - self, input_types: dict[ids.ColumnId, bigframes.dtypes.Dtype] - ) -> dtypes.ExpressionType: - return self.op.output_type() - - def remap_column_refs( - self, - name_mapping: Mapping[ids.ColumnId, ids.ColumnId], - allow_partial_bindings: bool = False, - ) -> NullaryAggregation: - return self - - -@dataclasses.dataclass(frozen=True) -class UnaryAggregation(Aggregation): - op: agg_ops.UnaryWindowOp = dataclasses.field() - arg: Union[DerefOp, ScalarConstantExpression] = dataclasses.field() - - def output_type( - self, input_types: dict[ids.ColumnId, bigframes.dtypes.Dtype] - ) -> dtypes.ExpressionType: - return self.op.output_type(self.arg.output_type(input_types)) - - @property - def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: - return self.arg.column_references - - def remap_column_refs( - self, - name_mapping: Mapping[ids.ColumnId, ids.ColumnId], - allow_partial_bindings: bool = False, - ) -> UnaryAggregation: - return UnaryAggregation( - self.op, - self.arg.remap_column_refs( - name_mapping, allow_partial_bindings=allow_partial_bindings - ), - ) - - -@dataclasses.dataclass(frozen=True) -class BinaryAggregation(Aggregation): - op: agg_ops.BinaryAggregateOp = dataclasses.field() - left: Union[DerefOp, ScalarConstantExpression] = dataclasses.field() - right: Union[DerefOp, ScalarConstantExpression] = dataclasses.field() - - def output_type( - self, input_types: dict[ids.ColumnId, bigframes.dtypes.Dtype] - ) -> dtypes.ExpressionType: - return self.op.output_type( - self.left.output_type(input_types), self.right.output_type(input_types) - ) - - @property - def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: - return (*self.left.column_references, *self.right.column_references) - - def remap_column_refs( - self, - name_mapping: Mapping[ids.ColumnId, ids.ColumnId], - allow_partial_bindings: bool = False, - ) -> BinaryAggregation: - return BinaryAggregation( - self.op, - self.left.remap_column_refs( - name_mapping, allow_partial_bindings=allow_partial_bindings - ), - self.right.remap_column_refs( - name_mapping, allow_partial_bindings=allow_partial_bindings - ), - ) - - +T = TypeVar("T") TExpression = TypeVar("TExpression", bound="Expression") @@ -189,10 +89,17 @@ def remap_column_refs( def is_const(self) -> bool: ... + @property + @abc.abstractmethod + def is_resolved(self) -> bool: + """ + Returns true if and only if the expression's output type and nullability is available. + """ + ... + + @property @abc.abstractmethod - def output_type( - self, input_types: dict[ids.ColumnId, dtypes.ExpressionType] - ) -> dtypes.ExpressionType: + def output_type(self) -> dtypes.ExpressionType: ... @abc.abstractmethod @@ -230,6 +137,25 @@ def is_identity(self) -> bool: """True for identity operation that does not transform input.""" return False + @functools.cached_property + def is_scalar_expr(self) -> bool: + """True if expression represents scalar value or expression over scalar values (no windows or aggregations)""" + return all(expr.is_scalar_expr for expr in self.children) + + @abc.abstractmethod + def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: + ... + + def bottom_up(self, t: Callable[[Expression], Expression]) -> Expression: + expr = self.transform_children(lambda child: child.bottom_up(t)) + expr = t(expr) + return expr + + def top_down(self, t: Callable[[Expression], Expression]) -> Expression: + expr = t(self) + expr = expr.transform_children(lambda child: child.top_down(t)) + return expr + def walk(self) -> Generator[Expression, None, None]: yield self for child in self.children: @@ -256,9 +182,12 @@ def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: def nullable(self) -> bool: return pd.isna(self.value) # type: ignore - def output_type( - self, input_types: dict[ids.ColumnId, bigframes.dtypes.Dtype] - ) -> dtypes.ExpressionType: + @property + def is_resolved(self) -> bool: + return True + + @property + def output_type(self) -> dtypes.ExpressionType: return self.dtype def bind_variables( @@ -289,6 +218,9 @@ def __eq__(self, other): return self.value == other.value and self.dtype == other.dtype + def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: + return self + @dataclasses.dataclass(frozen=True) class UnboundVariableExpression(Expression): @@ -308,9 +240,12 @@ def is_const(self) -> bool: def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: return () - def output_type( - self, input_types: dict[ids.ColumnId, bigframes.dtypes.Dtype] - ) -> dtypes.ExpressionType: + @property + def is_resolved(self): + return False + + @property + def output_type(self) -> dtypes.ExpressionType: raise ValueError(f"Type of variable {self.id} has not been fixed.") def bind_refs( @@ -337,10 +272,13 @@ def is_bijective(self) -> bool: def is_identity(self) -> bool: return True + def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: + return self + @dataclasses.dataclass(frozen=True) class DerefOp(Expression): - """A variable expression representing an unbound variable.""" + """An expression that refers to a column by ID.""" id: ids.ColumnId @@ -357,13 +295,13 @@ def nullable(self) -> bool: # Safe default, need to actually bind input schema to determine return True - def output_type( - self, input_types: dict[ids.ColumnId, bigframes.dtypes.Dtype] - ) -> dtypes.ExpressionType: - if self.id in input_types: - return input_types[self.id] - else: - raise ValueError(f"Type of variable {self.id} has not been fixed.") + @property + def is_resolved(self) -> bool: + return False + + @property + def output_type(self) -> dtypes.ExpressionType: + raise ValueError(f"Type of variable {self.id} has not been fixed.") def bind_variables( self, bindings: Mapping[str, Expression], allow_partial_bindings: bool = False @@ -389,12 +327,39 @@ def is_bijective(self) -> bool: def is_identity(self) -> bool: return True + def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: + return self + + +@dataclasses.dataclass(frozen=True) +class ResolvedDerefOp(DerefOp): + """An expression that refers to a column by ID and resolved with schema bound.""" + + dtype: dtypes.Dtype + is_nullable: bool + + @classmethod + def from_field(cls, f: field.Field): + return cls(id=f.id, dtype=f.dtype, is_nullable=f.nullable) + + @property + def is_resolved(self) -> bool: + return True + + @property + def nullable(self) -> bool: + return self.is_nullable + + @property + def output_type(self) -> dtypes.ExpressionType: + return self.dtype + @dataclasses.dataclass(frozen=True) class OpExpression(Expression): """An expression representing a scalar operation applied to 1 or more argument sub-expressions.""" - op: bigframes.operations.RowOp + op: bigframes.operations.ScalarOp inputs: typing.Tuple[Expression, ...] @property @@ -429,13 +394,18 @@ def nullable(self) -> bool: ) return not null_free - def output_type( - self, input_types: dict[ids.ColumnId, dtypes.ExpressionType] - ) -> dtypes.ExpressionType: - operand_types = tuple( - map(lambda x: x.output_type(input_types=input_types), self.inputs) - ) - return self.op.output_type(*operand_types) + @functools.cached_property + def is_resolved(self) -> bool: + return all(input.is_resolved for input in self.inputs) + + @functools.cached_property + def output_type(self) -> dtypes.ExpressionType: + if not self.is_resolved: + raise ValueError(f"Type of expression {self.op.name} has not been fixed.") + + input_types = [input.output_type for input in self.inputs] + + return self.op.output_type(*input_types) def bind_variables( self, bindings: Mapping[str, Expression], allow_partial_bindings: bool = False @@ -474,5 +444,29 @@ def deterministic(self) -> bool: all(input.deterministic for input in self.inputs) and self.op.deterministic ) + def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: + new_inputs = tuple(t(input) for input in self.inputs) + if new_inputs != self.inputs: + return dataclasses.replace(self, inputs=new_inputs) + return self + + +def bind_schema_fields( + expr: Expression, field_by_id: Mapping[ids.ColumnId, field.Field] +) -> Expression: + """ + Updates `DerefOp` expressions by replacing column IDs with actual schema fields(columns). + + We can only deduct an expression's output type and nullability after binding schema fields to + all its deref expressions. + """ + if expr.is_resolved: + return expr + + expr_by_id = { + id: ResolvedDerefOp.from_field(field) for id, field in field_by_id.items() + } + return expr.bind_refs(expr_by_id) + RefOrConstant = Union[DerefOp, ScalarConstantExpression] diff --git a/bigframes/core/expression_factoring.py b/bigframes/core/expression_factoring.py new file mode 100644 index 0000000000..b58330f5a4 --- /dev/null +++ b/bigframes/core/expression_factoring.py @@ -0,0 +1,463 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import collections +import dataclasses +import functools +import itertools +from typing import ( + Callable, + cast, + Dict, + Generator, + Hashable, + Iterable, + Iterator, + Mapping, + Optional, + Sequence, + Tuple, + TypeVar, +) + +from bigframes.core import ( + agg_expressions, + expression, + graphs, + identifiers, + nodes, + window_spec, +) + +_MAX_INLINE_COMPLEXITY = 10 + +T = TypeVar("T") + + +def unique_nodes( + roots: Sequence[expression.Expression], +) -> Generator[expression.Expression, None, None]: + """Walks the tree for unique nodes""" + seen = set() + stack: list[expression.Expression] = list(roots) + while stack: + item = stack.pop() + if item not in seen: + yield item + seen.add(item) + stack.extend(item.children) + + +def iter_nodes_topo( + roots: Sequence[expression.Expression], +) -> Generator[expression.Expression, None, None]: + """Returns nodes in reverse topological order, using Kahn's algorithm.""" + child_to_parents: Dict[ + expression.Expression, list[expression.Expression] + ] = collections.defaultdict(list) + out_degree: Dict[expression.Expression, int] = collections.defaultdict(int) + + queue: collections.deque[expression.Expression] = collections.deque() + for node in unique_nodes(roots): + num_children = len(node.children) + out_degree[node] = num_children + if num_children == 0: + queue.append(node) + for child in node.children: + child_to_parents[child].append(node) + + while queue: + item = queue.popleft() + yield item + parents = child_to_parents.get(item, []) + for parent in parents: + out_degree[parent] -= 1 + if out_degree[parent] == 0: + queue.append(parent) + + +def reduce_up( + roots: Sequence[expression.Expression], + reduction: Callable[[expression.Expression, Tuple[T, ...]], T], +) -> Tuple[T, ...]: + """Apply a bottom-up reduction to the forest.""" + results: dict[expression.Expression, T] = {} + for node in list(iter_nodes_topo(roots)): + # child nodes have already been transformed + child_results = tuple(results[child] for child in node.children) + result = reduction(node, child_results) + results[node] = result + + return tuple(results[root] for root in roots) + + +def apply_col_exprs_to_plan( + plan: nodes.BigFrameNode, col_exprs: Sequence[nodes.ColumnDef] +) -> nodes.BigFrameNode: + target_ids = tuple(named_expr.id for named_expr in col_exprs) + + fragments = fragmentize_expression(col_exprs) + return push_into_tree(plan, fragments, target_ids) + + +def apply_agg_exprs_to_plan( + plan: nodes.BigFrameNode, + agg_defs: Sequence[nodes.ColumnDef], + grouping_keys: Sequence[expression.DerefOp], +) -> nodes.BigFrameNode: + factored_aggs = [factor_aggregation(agg_def) for agg_def in agg_defs] + all_inputs = list( + itertools.chain(*(factored_agg.agg_inputs for factored_agg in factored_aggs)) + ) + window_def = window_spec.WindowSpec(grouping_keys=tuple(grouping_keys)) + windowized_inputs = [ + nodes.ColumnDef(windowize(cdef.expression, window_def), cdef.id) + for cdef in all_inputs + ] + plan = apply_col_exprs_to_plan(plan, windowized_inputs) + all_aggs = list( + itertools.chain(*(factored_agg.agg_exprs for factored_agg in factored_aggs)) + ) + plan = nodes.AggregateNode( + plan, + tuple((cdef.expression, cdef.id) for cdef in all_aggs), # type: ignore + by_column_ids=tuple(grouping_keys), + ) + + post_scalar_exprs = tuple( + (factored_agg.root_scalar_expr for factored_agg in factored_aggs) + ) + plan = nodes.ProjectionNode( + plan, tuple((cdef.expression, cdef.id) for cdef in post_scalar_exprs) + ) + final_ids = itertools.chain( + (ref.id for ref in grouping_keys), (cdef.id for cdef in post_scalar_exprs) + ) + plan = nodes.SelectionNode( + plan, tuple(nodes.AliasedRef.identity(ident) for ident in final_ids) + ) + + return plan + + +@dataclasses.dataclass(frozen=True, eq=False) +class FactoredExpression: + root_expr: expression.Expression + sub_exprs: Tuple[nodes.ColumnDef, ...] + + +def fragmentize_expression( + roots: Sequence[nodes.ColumnDef], +) -> Sequence[nodes.ColumnDef]: + """ + The goal of this functions is to factor out an expression into multiple sub-expressions. + """ + # TODO: Fragmentize a bit less aggressively + factored_exprs = reduce_up([root.expression for root in roots], gather_fragments) + root_exprs = ( + nodes.ColumnDef(factored.root_expr, root.id) + for factored, root in zip(factored_exprs, roots) + ) + return ( + *root_exprs, + *dedupe( + itertools.chain.from_iterable( + factored_expr.sub_exprs for factored_expr in factored_exprs + ) + ), + ) + + +@dataclasses.dataclass(frozen=True, eq=False) +class FactoredAggregation: + """ + A three part recomposition of a general aggregating expression. + + 1. agg_inputs: This is a set of (*col) -> col transformation that preprocess inputs for the aggregations ops + 2. agg_exprs: This is a set of pure aggregations (eg sum, mean, min, max) ops referencing the outputs of (1) + 3. root_scalar_expr: This is the final set, takes outputs of (2), applies scalar expression to produce final result. + """ + + # pure scalar expression + root_scalar_expr: nodes.ColumnDef + # pure agg expression, only refs cols and consts + agg_exprs: Tuple[nodes.ColumnDef, ...] + # can be analytic, scalar op, const, col refs + agg_inputs: Tuple[nodes.ColumnDef, ...] + + +def windowize( + root: expression.Expression, window: window_spec.WindowSpec +) -> expression.Expression: + def windowize_local(expr: expression.Expression): + if isinstance(expr, agg_expressions.Aggregation): + if not expr.op.can_be_windowized: + raise ValueError(f"Op: {expr.op} cannot be windowized.") + return agg_expressions.WindowExpression(expr, window) + if isinstance(expr, agg_expressions.WindowExpression): + raise ValueError(f"Expression {expr} already windowed!") + return expr + + return root.bottom_up(windowize_local) + + +def factor_aggregation(root: nodes.ColumnDef) -> FactoredAggregation: + """ + Factor an aggregation def into three components. + 1. Input column expressions (includes analytic expressions) + 2. The set of underlying primitive aggregations + 3. A final post-aggregate scalar expression + """ + final_aggs = list(dedupe(find_final_aggregations(root.expression))) + agg_inputs = list( + dedupe(itertools.chain.from_iterable(map(find_agg_inputs, final_aggs))) + ) + + agg_input_defs = tuple( + nodes.ColumnDef(expr, identifiers.ColumnId.unique()) for expr in agg_inputs + ) + agg_inputs_dict = { + cdef.expression: expression.DerefOp(cdef.id) for cdef in agg_input_defs + } + + agg_expr_to_ids = {expr: identifiers.ColumnId.unique() for expr in final_aggs} + + isolated_aggs = tuple( + nodes.ColumnDef(sub_expressions(expr, agg_inputs_dict), agg_expr_to_ids[expr]) + for expr in final_aggs + ) + agg_outputs_dict = { + expr: expression.DerefOp(id) for expr, id in agg_expr_to_ids.items() + } + + root_scalar_expr = nodes.ColumnDef( + sub_expressions(root.expression, agg_outputs_dict), root.id # type: ignore + ) + + return FactoredAggregation( + root_scalar_expr=root_scalar_expr, + agg_exprs=isolated_aggs, + agg_inputs=agg_input_defs, + ) + + +def sub_expressions( + root: expression.Expression, + replacements: Mapping[expression.Expression, expression.Expression], +) -> expression.Expression: + return root.top_down(lambda x: replacements.get(x, x)) + + +def find_final_aggregations( + root: expression.Expression, +) -> Iterator[agg_expressions.Aggregation]: + if isinstance(root, agg_expressions.Aggregation): + yield root + elif isinstance(root, expression.OpExpression): + for child in root.children: + yield from find_final_aggregations(child) + elif isinstance(root, expression.ScalarConstantExpression): + return + else: + # eg, window expression, column references not allowed + raise ValueError(f"Unexpected node: {root}") + + +def find_agg_inputs( + root: agg_expressions.Aggregation, +) -> Iterator[expression.Expression]: + for child in root.children: + if not isinstance( + child, (expression.DerefOp, expression.ScalarConstantExpression) + ): + yield child + + +def gather_fragments( + root: expression.Expression, fragmentized_children: Sequence[FactoredExpression] +) -> FactoredExpression: + replacements: list[expression.Expression] = [] + named_exprs = [] # root -> leaf dependency order + for child_result in fragmentized_children: + child_expr = child_result.root_expr + is_leaf = isinstance( + child_expr, (expression.DerefOp, expression.ScalarConstantExpression) + ) + is_window_agg = isinstance( + root, agg_expressions.WindowExpression + ) and isinstance(child_expr, agg_expressions.Aggregation) + do_inline = is_leaf | is_window_agg + if not do_inline: + id = identifiers.ColumnId.unique() + replacements.append(expression.DerefOp(id)) + named_exprs.append(nodes.ColumnDef(child_result.root_expr, id)) + named_exprs.extend(child_result.sub_exprs) + else: + replacements.append(child_result.root_expr) + named_exprs.extend(child_result.sub_exprs) + new_root = replace_children(root, replacements) + return FactoredExpression(new_root, tuple(named_exprs)) + + +def replace_children( + root: expression.Expression, new_children: Sequence[expression.Expression] +): + mapping = {root.children[i]: new_children[i] for i in range(len(root.children))} + return root.transform_children(lambda x: mapping.get(x, x)) + + +def push_into_tree( + root: nodes.BigFrameNode, + exprs: Sequence[nodes.ColumnDef], + target_ids: Sequence[identifiers.ColumnId], +) -> nodes.BigFrameNode: + curr_root = root + by_id = {expr.id: expr for expr in exprs} + # id -> id + graph = graphs.DiGraph( + (expr.id for expr in exprs), + ( + (expr.id, child_id) + for expr in exprs + for child_id in expr.expression.column_references + if child_id in by_id.keys() + ), + ) + # TODO: Also prevent inlining expensive or non-deterministic + # We avoid inlining multi-parent ids, as they would be inlined multiple places, potentially increasing work and/or compiled text size + multi_parent_ids = set(id for id in graph.nodes if len(list(graph.parents(id))) > 2) + scalar_ids = set(expr.id for expr in exprs if expr.expression.is_scalar_expr) + + analytic_defs = filter( + lambda x: isinstance(x.expression, agg_expressions.WindowExpression), exprs + ) + analytic_by_window = grouped( + map( + lambda x: (cast(agg_expressions.WindowExpression, x.expression).window, x), + analytic_defs, + ) + ) + + def graph_extract_scalar_exprs() -> Sequence[nodes.ColumnDef]: + results: dict[identifiers.ColumnId, expression.Expression] = dict() + while ( + True + ): # Will converge as each loop either reduces graph size, or fails to find any candidate and breaks + candidate_ids = list( + id + for id in graph.sinks + if (id in scalar_ids) + and not any( + ( + child in multi_parent_ids + and id in results.keys() + and not is_simple(results[id]) + ) + for child in graph.children(id) + ) + ) + if len(candidate_ids) == 0: + break + for id in candidate_ids: + graph.remove_node(id) + new_exprs = { + id: by_id[id].expression.bind_refs( + results, allow_partial_bindings=True + ) + } + results.update(new_exprs) + # TODO: We can prune expressions that won't be reused here, + return tuple(nodes.ColumnDef(expr, id) for id, expr in results.items()) + + def graph_extract_window_expr() -> Optional[ + Tuple[Sequence[nodes.ColumnDef], window_spec.WindowSpec] + ]: + for id in graph.sinks: + next_def = by_id[id] + if isinstance(next_def.expression, agg_expressions.WindowExpression): + window = next_def.expression.window + window_exprs = [ + cdef + for cdef in analytic_by_window[window] + if cdef.id in graph.sinks + ] + agg_exprs = tuple( + nodes.ColumnDef( + cast( + agg_expressions.WindowExpression, cdef.expression + ).analytic_expr, + cdef.id, + ) + for cdef in window_exprs + ) + for cdef in window_exprs: + graph.remove_node(cdef.id) + return (agg_exprs, window) + + return None + + while not graph.empty: + pre_size = len(graph.nodes) + scalar_exprs = graph_extract_scalar_exprs() + if scalar_exprs: + curr_root = nodes.ProjectionNode( + curr_root, tuple((x.expression, x.id) for x in scalar_exprs) + ) + while result := graph_extract_window_expr(): + defs, window = result + assert len(defs) > 0 + curr_root = nodes.WindowOpNode( + curr_root, + tuple(defs), + window, + ) + if len(graph.nodes) >= pre_size: + raise ValueError("graph didn't shrink") + # TODO: Try to get the ordering right earlier, so can avoid this extra node. + post_ids = (*root.ids, *target_ids) + if tuple(curr_root.ids) != post_ids: + curr_root = nodes.SelectionNode( + curr_root, tuple(nodes.AliasedRef.identity(id) for id in post_ids) + ) + return curr_root + + +@functools.cache +def is_simple(expr: expression.Expression) -> bool: + count = 0 + for part in expr.walk(): + count += 1 + if count > _MAX_INLINE_COMPLEXITY: + return False + return True + + +K = TypeVar("K", bound=Hashable) +V = TypeVar("V") + + +def grouped(values: Iterable[tuple[K, V]]) -> dict[K, list[V]]: + result = collections.defaultdict(list) + for k, v in values: + result[k].append(v) + return result + + +def dedupe(values: Iterable[K]) -> Iterator[K]: + seen = set() + for k in values: + if k not in seen: + seen.add(k) + yield k diff --git a/bigframes/core/field.py b/bigframes/core/field.py new file mode 100644 index 0000000000..c5b7dd3555 --- /dev/null +++ b/bigframes/core/field.py @@ -0,0 +1,37 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses + +from bigframes import dtypes +from bigframes.core import identifiers + + +@dataclasses.dataclass(frozen=True) +class Field: + id: identifiers.ColumnId + dtype: dtypes.Dtype + # Best effort, nullable=True if not certain + nullable: bool = True + + def with_nullable(self) -> Field: + return Field(self.id, self.dtype, nullable=True) + + def with_nonnull(self) -> Field: + return Field(self.id, self.dtype, nullable=False) + + def with_id(self, id: identifiers.ColumnId) -> Field: + return Field(id, self.dtype, nullable=self.nullable) diff --git a/bigframes/core/global_session.py b/bigframes/core/global_session.py index 8b32fee5b4..b055bdb854 100644 --- a/bigframes/core/global_session.py +++ b/bigframes/core/global_session.py @@ -14,16 +14,19 @@ """Utilities for managing a default, globally available Session object.""" +from __future__ import annotations + import threading import traceback -from typing import Callable, Optional, TypeVar +from typing import Callable, Optional, TYPE_CHECKING, TypeVar import warnings import google.auth.exceptions -import bigframes._config import bigframes.exceptions as bfe -import bigframes.session + +if TYPE_CHECKING: + import bigframes.session _global_session: Optional[bigframes.session.Session] = None _global_session_lock = threading.Lock() @@ -39,7 +42,7 @@ def _try_close_session(session: bigframes.session.Session): session_id = session.session_id location = session._location project_id = session._project - msg = ( + msg = bfe.format_message( f"Session cleanup failed for session with id: {session_id}, " f"location: {location}, project: {project_id}" ) @@ -56,6 +59,9 @@ def close_session() -> None: Returns: None """ + # Avoid troubles with circular imports. + import bigframes._config + global _global_session, _global_session_lock, _global_session_state if bigframes._config.options.is_bigquery_thread_local: @@ -88,6 +94,10 @@ def get_global_session(): Creates the global session if it does not exist. """ + # Avoid troubles with circular imports. + import bigframes._config + import bigframes.session + global _global_session, _global_session_lock, _global_session_state if bigframes._config.options.is_bigquery_thread_local: @@ -110,5 +120,25 @@ def get_global_session(): _T = TypeVar("_T") -def with_default_session(func: Callable[..., _T], *args, **kwargs) -> _T: - return func(get_global_session(), *args, **kwargs) +def with_default_session(func_: Callable[..., _T], *args, **kwargs) -> _T: + return func_(get_global_session(), *args, **kwargs) + + +class _GlobalSessionContext: + """ + Context manager for testing that sets global session. + """ + + def __init__(self, session: bigframes.session.Session): + self._session = session + + def __enter__(self): + global _global_session, _global_session_lock + with _global_session_lock: + self._previous_session = _global_session + _global_session = self._session + + def __exit__(self, *exc_details): + global _global_session, _global_session_lock + with _global_session_lock: + _global_session = self._previous_session diff --git a/bigframes/core/graphs.py b/bigframes/core/graphs.py new file mode 100644 index 0000000000..b7ce80e3cf --- /dev/null +++ b/bigframes/core/graphs.py @@ -0,0 +1,76 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import collections +from typing import Dict, Generic, Hashable, Iterable, Iterator, Tuple, TypeVar + +import bigframes.core.ordered_sets as sets + +T = TypeVar("T", bound=Hashable) + + +class DiGraph(Generic[T]): + def __init__(self, nodes: Iterable[T], edges: Iterable[Tuple[T, T]]): + self._parents: Dict[T, sets.InsertionOrderedSet[T]] = collections.defaultdict( + sets.InsertionOrderedSet + ) + self._children: Dict[T, sets.InsertionOrderedSet[T]] = collections.defaultdict( + sets.InsertionOrderedSet + ) + self._sinks: sets.InsertionOrderedSet[T] = sets.InsertionOrderedSet() + for node in nodes: + self._children[node] + self._parents[node] + self._sinks.add(node) + for src, dst in edges: + assert src in self.nodes + assert dst in self.nodes + self._children[src].add(dst) + self._parents[dst].add(src) + # sinks have no children + if src in self._sinks: + self._sinks.remove(src) + + @property + def nodes(self): + # should be the same set of ids as self._parents + return self._children.keys() + + @property + def sinks(self) -> Iterable[T]: + return self._sinks + + @property + def empty(self): + return len(self.nodes) == 0 + + def parents(self, node: T) -> Iterator[T]: + assert node in self._parents + yield from self._parents[node] + + def children(self, node: T) -> Iterator[T]: + assert node in self._children + yield from self._children[node] + + def remove_node(self, node: T) -> None: + for child in self._children[node]: + self._parents[child].remove(node) + for parent in self._parents[node]: + self._children[parent].remove(node) + if len(self._children[parent]) == 0: + self._sinks.add(parent) + del self._children[node] + del self._parents[node] + if node in self._sinks: + self._sinks.remove(node) diff --git a/bigframes/core/groupby/__init__.py b/bigframes/core/groupby/__init__.py index f619cd72c9..fe44911858 100644 --- a/bigframes/core/groupby/__init__.py +++ b/bigframes/core/groupby/__init__.py @@ -12,777 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations +from bigframes.core.groupby.dataframe_group_by import DataFrameGroupBy +from bigframes.core.groupby.series_group_by import SeriesGroupBy -import typing -from typing import Sequence, Tuple, Union - -import bigframes_vendored.constants as constants -import bigframes_vendored.pandas.core.groupby as vendored_pandas_groupby -import jellyfish -import pandas as pd - -from bigframes import session -from bigframes.core import log_adapter -import bigframes.core.block_transforms as block_ops -import bigframes.core.blocks as blocks -import bigframes.core.expression -import bigframes.core.ordering as order -import bigframes.core.utils as utils -import bigframes.core.validations as validations -import bigframes.core.window as windows -import bigframes.core.window_spec as window_specs -import bigframes.dataframe as df -import bigframes.dtypes as dtypes -import bigframes.operations.aggregations as agg_ops -import bigframes.series as series - - -@log_adapter.class_logger -class DataFrameGroupBy(vendored_pandas_groupby.DataFrameGroupBy): - __doc__ = vendored_pandas_groupby.GroupBy.__doc__ - - def __init__( - self, - block: blocks.Block, - by_col_ids: typing.Sequence[str], - *, - selected_cols: typing.Optional[typing.Sequence[str]] = None, - dropna: bool = True, - as_index: bool = True, - ): - # TODO(tbergeron): Support more group-by expression types - self._block = block - self._col_id_labels = { - value_column: column_label - for value_column, column_label in zip( - block.value_columns, block.column_labels - ) - } - self._by_col_ids = by_col_ids - - self._dropna = dropna - self._as_index = as_index - if selected_cols: - for col in selected_cols: - if col not in self._block.value_columns: - raise ValueError(f"Invalid column selection: {col}") - self._selected_cols = selected_cols - else: - self._selected_cols = [ - col_id - for col_id in self._block.value_columns - if col_id not in self._by_col_ids - ] - - @property - def _session(self) -> session.Session: - return self._block.session - - def __getitem__( - self, - key: typing.Union[ - blocks.Label, - typing.Sequence[blocks.Label], - ], - ): - if utils.is_list_like(key): - keys = list(key) - else: - keys = [key] - - bad_keys = [key for key in keys if key not in self._block.column_labels] - - # Raise a KeyError message with the possible correct key(s) - if len(bad_keys) > 0: - possible_key = [] - for bad_key in bad_keys: - possible_key.append( - min( - self._block.column_labels, - key=lambda item: jellyfish.damerau_levenshtein_distance( - bad_key, item - ), - ) - ) - raise KeyError( - f"Columns not found: {str(bad_keys)[1:-1]}. Did you mean {str(possible_key)[1:-1]}?" - ) - - columns = [ - col_id for col_id, label in self._col_id_labels.items() if label in keys - ] - - if len(columns) > 1 or (not self._as_index): - return DataFrameGroupBy( - self._block, - self._by_col_ids, - selected_cols=columns, - dropna=self._dropna, - as_index=self._as_index, - ) - else: - return SeriesGroupBy( - self._block, - columns[0], - self._by_col_ids, - value_name=self._col_id_labels[columns[0]], - dropna=self._dropna, - ) - - @validations.requires_ordering() - def head(self, n: int = 5) -> df.DataFrame: - block = self._block - if self._dropna: - block = block_ops.dropna(self._block, self._by_col_ids, how="any") - return df.DataFrame( - block.grouped_head( - by_column_ids=self._by_col_ids, - value_columns=self._block.value_columns, - n=n, - ) - ) - - def size(self) -> typing.Union[df.DataFrame, series.Series]: - agg_block, _ = self._block.aggregate_size( - by_column_ids=self._by_col_ids, - dropna=self._dropna, - ) - agg_block = agg_block.with_column_labels(pd.Index(["size"])) - dataframe = df.DataFrame(agg_block) - - if self._as_index: - series = dataframe["size"] - return series.rename(None) - else: - return self._convert_index(dataframe) - - def sum(self, numeric_only: bool = False, *args) -> df.DataFrame: - if not numeric_only: - self._raise_on_non_numeric("sum") - return self._aggregate_all(agg_ops.sum_op, numeric_only=True) - - def mean(self, numeric_only: bool = False, *args) -> df.DataFrame: - if not numeric_only: - self._raise_on_non_numeric("mean") - return self._aggregate_all(agg_ops.mean_op, numeric_only=True) - - def median(self, numeric_only: bool = False, *, exact: bool = True) -> df.DataFrame: - if not numeric_only: - self._raise_on_non_numeric("median") - if exact: - return self.quantile(0.5) - return self._aggregate_all(agg_ops.median_op, numeric_only=True) - - def quantile( - self, q: Union[float, Sequence[float]] = 0.5, *, numeric_only: bool = False - ) -> df.DataFrame: - if not numeric_only: - self._raise_on_non_numeric("quantile") - q_cols = tuple( - col - for col in self._selected_cols - if self._column_type(col) in dtypes.NUMERIC_BIGFRAMES_TYPES_PERMISSIVE - ) - multi_q = utils.is_list_like(q) - result = block_ops.quantile( - self._block, - q_cols, - qs=tuple(q) if multi_q else (q,), # type: ignore - grouping_column_ids=self._by_col_ids, - dropna=self._dropna, - ) - result_df = df.DataFrame(result) - if multi_q: - return result_df.stack() - else: - return result_df.droplevel(-1, 1) - - def min(self, numeric_only: bool = False, *args) -> df.DataFrame: - return self._aggregate_all(agg_ops.min_op, numeric_only=numeric_only) - - def max(self, numeric_only: bool = False, *args) -> df.DataFrame: - return self._aggregate_all(agg_ops.max_op, numeric_only=numeric_only) - - def std( - self, - *, - numeric_only: bool = False, - ) -> df.DataFrame: - if not numeric_only: - self._raise_on_non_numeric("std") - return self._aggregate_all(agg_ops.std_op, numeric_only=True) - - def var( - self, - *, - numeric_only: bool = False, - ) -> df.DataFrame: - if not numeric_only: - self._raise_on_non_numeric("var") - return self._aggregate_all(agg_ops.var_op, numeric_only=True) - - def skew( - self, - *, - numeric_only: bool = False, - ) -> df.DataFrame: - if not numeric_only: - self._raise_on_non_numeric("skew") - block = block_ops.skew(self._block, self._selected_cols, self._by_col_ids) - return df.DataFrame(block) - - def kurt( - self, - *, - numeric_only: bool = False, - ) -> df.DataFrame: - if not numeric_only: - self._raise_on_non_numeric("kurt") - block = block_ops.kurt(self._block, self._selected_cols, self._by_col_ids) - return df.DataFrame(block) - - kurtosis = kurt - - def all(self) -> df.DataFrame: - return self._aggregate_all(agg_ops.all_op) - - def any(self) -> df.DataFrame: - return self._aggregate_all(agg_ops.any_op) - - def count(self) -> df.DataFrame: - return self._aggregate_all(agg_ops.count_op) - - def nunique(self) -> df.DataFrame: - return self._aggregate_all(agg_ops.nunique_op) - - @validations.requires_ordering() - def cumsum(self, *args, numeric_only: bool = False, **kwargs) -> df.DataFrame: - if not numeric_only: - self._raise_on_non_numeric("cumsum") - return self._apply_window_op(agg_ops.sum_op, numeric_only=True) - - @validations.requires_ordering() - def cummin(self, *args, numeric_only: bool = False, **kwargs) -> df.DataFrame: - return self._apply_window_op(agg_ops.min_op, numeric_only=numeric_only) - - @validations.requires_ordering() - def cummax(self, *args, numeric_only: bool = False, **kwargs) -> df.DataFrame: - return self._apply_window_op(agg_ops.max_op, numeric_only=numeric_only) - - @validations.requires_ordering() - def cumprod(self, *args, **kwargs) -> df.DataFrame: - return self._apply_window_op(agg_ops.product_op, numeric_only=True) - - @validations.requires_ordering() - def shift(self, periods=1) -> series.Series: - # Window framing clause is not allowed for analytic function lag. - window = window_specs.unbound( - grouping_keys=tuple(self._by_col_ids), - ) - return self._apply_window_op(agg_ops.ShiftOp(periods), window=window) - - @validations.requires_ordering() - def diff(self, periods=1) -> series.Series: - # Window framing clause is not allowed for analytic function lag. - window = window_specs.rows( - grouping_keys=tuple(self._by_col_ids), - ) - return self._apply_window_op(agg_ops.DiffOp(periods), window=window) - - @validations.requires_ordering() - def rolling(self, window: int, min_periods=None) -> windows.Window: - # To get n size window, need current row and n-1 preceding rows. - window_spec = window_specs.rows( - grouping_keys=tuple(self._by_col_ids), - preceding=window - 1, - following=0, - min_periods=min_periods or window, - ) - block = self._block.order_by( - [order.ascending_over(col) for col in self._by_col_ids], - ) - return windows.Window( - block, window_spec, self._selected_cols, drop_null_groups=self._dropna - ) - - @validations.requires_ordering() - def expanding(self, min_periods: int = 1) -> windows.Window: - window_spec = window_specs.cumulative_rows( - grouping_keys=tuple(self._by_col_ids), - min_periods=min_periods, - ) - block = self._block.order_by( - [order.ascending_over(col) for col in self._by_col_ids], - ) - return windows.Window( - block, window_spec, self._selected_cols, drop_null_groups=self._dropna - ) - - def agg(self, func=None, **kwargs) -> typing.Union[df.DataFrame, series.Series]: - if func: - if isinstance(func, str): - return self.size() if func == "size" else self._agg_string(func) - elif utils.is_dict_like(func): - return self._agg_dict(func) - elif utils.is_list_like(func): - return self._agg_list(func) - else: - raise NotImplementedError( - f"Aggregate with {func} not supported. {constants.FEEDBACK_LINK}" - ) - else: - return self._agg_named(**kwargs) - - def _agg_string(self, func: str) -> df.DataFrame: - ids, labels = self._aggregated_columns() - aggregations = [agg(col_id, agg_ops.lookup_agg_func(func)) for col_id in ids] - agg_block, _ = self._block.aggregate( - by_column_ids=self._by_col_ids, - aggregations=aggregations, - dropna=self._dropna, - column_labels=labels, - ) - dataframe = df.DataFrame(agg_block) - return dataframe if self._as_index else self._convert_index(dataframe) - - def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: - aggregations: typing.List[bigframes.core.expression.Aggregation] = [] - column_labels = [] - - want_aggfunc_level = any(utils.is_list_like(aggs) for aggs in func.values()) - - for label, funcs_for_id in func.items(): - col_id = self._resolve_label(label) - func_list = ( - funcs_for_id if utils.is_list_like(funcs_for_id) else [funcs_for_id] - ) - for f in func_list: - aggregations.append(agg(col_id, agg_ops.lookup_agg_func(f))) - column_labels.append(label) - agg_block, _ = self._block.aggregate( - by_column_ids=self._by_col_ids, - aggregations=aggregations, - dropna=self._dropna, - ) - if want_aggfunc_level: - agg_block = agg_block.with_column_labels( - utils.combine_indices( - pd.Index(column_labels), - pd.Index( - typing.cast(agg_ops.AggregateOp, agg.op).name - for agg in aggregations - ), - ) - ) - else: - agg_block = agg_block.with_column_labels(pd.Index(column_labels)) - dataframe = df.DataFrame(agg_block) - return dataframe if self._as_index else self._convert_index(dataframe) - - def _agg_list(self, func: typing.Sequence) -> df.DataFrame: - ids, labels = self._aggregated_columns() - aggregations = [ - agg(col_id, agg_ops.lookup_agg_func(f)) for col_id in ids for f in func - ] - - if self._block.column_labels.nlevels > 1: - # Restructure MultiIndex for proper format: (idx1, idx2, func) - # rather than ((idx1, idx2), func). - column_labels = [ - tuple(label) + (f,) - for label in labels.to_frame(index=False).to_numpy() - for f in func - ] - else: # Single-level index - column_labels = [(label, f) for label in labels for f in func] - - agg_block, _ = self._block.aggregate( - by_column_ids=self._by_col_ids, - aggregations=aggregations, - dropna=self._dropna, - ) - agg_block = agg_block.with_column_labels( - pd.MultiIndex.from_tuples( - column_labels, names=[*self._block.column_labels.names, None] - ) - ) - dataframe = df.DataFrame(agg_block) - return dataframe if self._as_index else self._convert_index(dataframe) - - def _agg_named(self, **kwargs) -> df.DataFrame: - aggregations = [] - column_labels = [] - for k, v in kwargs.items(): - if not isinstance(k, str): - raise NotImplementedError( - f"Only string aggregate names supported. {constants.FEEDBACK_LINK}" - ) - if not isinstance(v, tuple) or (len(v) != 2): - raise TypeError("kwargs values must be 2-tuples of column, aggfunc") - col_id = self._resolve_label(v[0]) - aggregations.append(agg(col_id, agg_ops.lookup_agg_func(v[1]))) - column_labels.append(k) - agg_block, _ = self._block.aggregate( - by_column_ids=self._by_col_ids, - aggregations=aggregations, - dropna=self._dropna, - ) - agg_block = agg_block.with_column_labels(column_labels) - dataframe = df.DataFrame(agg_block) - return dataframe if self._as_index else self._convert_index(dataframe) - - def _convert_index(self, dataframe: df.DataFrame): - """Convert index levels to columns except where names conflict.""" - levels_to_drop = [ - level for level in dataframe.index.names if level in dataframe.columns - ] - - if len(levels_to_drop) == dataframe.index.nlevels: - return dataframe.reset_index(drop=True) - return dataframe.droplevel(levels_to_drop).reset_index(drop=False) - - aggregate = agg - - def _raise_on_non_numeric(self, op: str): - if not all( - self._column_type(col) in dtypes.NUMERIC_BIGFRAMES_TYPES_PERMISSIVE - for col in self._selected_cols - ): - raise NotImplementedError( - f"'{op}' does not support non-numeric columns. " - "Set 'numeric_only'=True to ignore non-numeric columns. " - f"{constants.FEEDBACK_LINK}" - ) - return self - - def _aggregated_columns( - self, numeric_only: bool = False - ) -> Tuple[typing.Sequence[str], pd.Index]: - valid_agg_cols: list[str] = [] - offsets: list[int] = [] - for i, col_id in enumerate(self._block.value_columns): - is_numeric = ( - self._column_type(col_id) in dtypes.NUMERIC_BIGFRAMES_TYPES_PERMISSIVE - ) - if (col_id in self._selected_cols) and (is_numeric or not numeric_only): - offsets.append(i) - valid_agg_cols.append(col_id) - return valid_agg_cols, self._block.column_labels.take(offsets) - - def _column_type(self, col_id: str) -> dtypes.Dtype: - col_offset = self._block.value_columns.index(col_id) - dtype = self._block.dtypes[col_offset] - return dtype - - def _aggregate_all( - self, aggregate_op: agg_ops.UnaryAggregateOp, numeric_only: bool = False - ) -> df.DataFrame: - aggregated_col_ids, labels = self._aggregated_columns(numeric_only=numeric_only) - aggregations = [agg(col_id, aggregate_op) for col_id in aggregated_col_ids] - result_block, _ = self._block.aggregate( - by_column_ids=self._by_col_ids, - aggregations=aggregations, - column_labels=labels, - dropna=self._dropna, - ) - dataframe = df.DataFrame(result_block) - return dataframe if self._as_index else self._convert_index(dataframe) - - def _apply_window_op( - self, - op: agg_ops.WindowOp, - window: typing.Optional[window_specs.WindowSpec] = None, - numeric_only: bool = False, - ): - """Apply window op to groupby. Defaults to grouped cumulative window.""" - window_spec = window or window_specs.cumulative_rows( - grouping_keys=tuple(self._by_col_ids) - ) - columns, _ = self._aggregated_columns(numeric_only=numeric_only) - block, result_ids = self._block.multi_apply_window_op( - columns, op, window_spec=window_spec - ) - block = block.select_columns(result_ids) - return df.DataFrame(block) - - def _resolve_label(self, label: blocks.Label) -> str: - """Resolve label to column id.""" - col_ids = self._block.label_to_col_id.get(label, ()) - if len(col_ids) > 1: - raise ValueError(f"Label {label} is ambiguous") - if len(col_ids) == 0: - raise ValueError(f"Label {label} does not match any columns") - return col_ids[0] - - -@log_adapter.class_logger -class SeriesGroupBy(vendored_pandas_groupby.SeriesGroupBy): - __doc__ = vendored_pandas_groupby.GroupBy.__doc__ - - def __init__( - self, - block: blocks.Block, - value_column: str, - by_col_ids: typing.Sequence[str], - value_name: blocks.Label = None, - dropna=True, - ): - # TODO(tbergeron): Support more group-by expression types - self._block = block - self._value_column = value_column - self._by_col_ids = by_col_ids - self._value_name = value_name - self._dropna = dropna # Applies to aggregations but not windowing - - @property - def _session(self) -> session.Session: - return self._block.session - - @validations.requires_ordering() - def head(self, n: int = 5) -> series.Series: - block = self._block - if self._dropna: - block = block_ops.dropna(self._block, self._by_col_ids, how="any") - return series.Series( - block.grouped_head( - by_column_ids=self._by_col_ids, value_columns=[self._value_column], n=n - ) - ) - - def all(self) -> series.Series: - return self._aggregate(agg_ops.all_op) - - def any(self) -> series.Series: - return self._aggregate(agg_ops.any_op) - - def min(self, *args) -> series.Series: - return self._aggregate(agg_ops.min_op) - - def max(self, *args) -> series.Series: - return self._aggregate(agg_ops.max_op) - - def count(self) -> series.Series: - return self._aggregate(agg_ops.count_op) - - def nunique(self) -> series.Series: - return self._aggregate(agg_ops.nunique_op) - - def sum(self, *args) -> series.Series: - return self._aggregate(agg_ops.sum_op) - - def mean(self, *args) -> series.Series: - return self._aggregate(agg_ops.mean_op) - - def median( - self, - *args, - exact: bool = True, - **kwargs, - ) -> series.Series: - if exact: - return self.quantile(0.5) - else: - return self._aggregate(agg_ops.median_op) - - def quantile( - self, q: Union[float, Sequence[float]] = 0.5, *, numeric_only: bool = False - ) -> series.Series: - multi_q = utils.is_list_like(q) - result = block_ops.quantile( - self._block, - (self._value_column,), - qs=tuple(q) if multi_q else (q,), # type: ignore - grouping_column_ids=self._by_col_ids, - dropna=self._dropna, - ) - if multi_q: - return series.Series(result.stack()) - else: - return series.Series(result.stack()).droplevel(-1) - - def std(self, *args, **kwargs) -> series.Series: - return self._aggregate(agg_ops.std_op) - - def var(self, *args, **kwargs) -> series.Series: - return self._aggregate(agg_ops.var_op) - - def size(self) -> series.Series: - agg_block, _ = self._block.aggregate_size( - by_column_ids=self._by_col_ids, - dropna=self._dropna, - ) - return series.Series(agg_block.with_column_labels([self._value_name])) - - def skew(self, *args, **kwargs) -> series.Series: - block = block_ops.skew(self._block, [self._value_column], self._by_col_ids) - return series.Series(block) - - def kurt(self, *args, **kwargs) -> series.Series: - block = block_ops.kurt(self._block, [self._value_column], self._by_col_ids) - return series.Series(block) - - kurtosis = kurt - - def prod(self, *args) -> series.Series: - return self._aggregate(agg_ops.product_op) - - def agg(self, func=None) -> typing.Union[df.DataFrame, series.Series]: - column_names: list[str] = [] - if isinstance(func, str): - aggregations = [agg(self._value_column, agg_ops.lookup_agg_func(func))] - column_names = [func] - elif utils.is_list_like(func): - aggregations = [ - agg(self._value_column, agg_ops.lookup_agg_func(f)) for f in func - ] - column_names = list(func) - else: - raise NotImplementedError( - f"Aggregate with {func} not supported. {constants.FEEDBACK_LINK}" - ) - - agg_block, _ = self._block.aggregate( - by_column_ids=self._by_col_ids, - aggregations=aggregations, - dropna=self._dropna, - ) - - if column_names: - agg_block = agg_block.with_column_labels(column_names) - - if len(aggregations) > 1: - return df.DataFrame(agg_block) - return series.Series(agg_block) - - aggregate = agg - - @validations.requires_ordering() - def cumsum(self, *args, **kwargs) -> series.Series: - return self._apply_window_op( - agg_ops.sum_op, - ) - - @validations.requires_ordering() - def cumprod(self, *args, **kwargs) -> series.Series: - return self._apply_window_op( - agg_ops.product_op, - ) - - @validations.requires_ordering() - def cummax(self, *args, **kwargs) -> series.Series: - return self._apply_window_op( - agg_ops.max_op, - ) - - @validations.requires_ordering() - def cummin(self, *args, **kwargs) -> series.Series: - return self._apply_window_op( - agg_ops.min_op, - ) - - @validations.requires_ordering() - def cumcount(self, *args, **kwargs) -> series.Series: - # TODO: Add nullary op support to implement more cleanly - return ( - self._apply_window_op( - agg_ops.SizeUnaryOp(), - discard_name=True, - never_skip_nulls=True, - ) - - 1 - ) - - @validations.requires_ordering() - def shift(self, periods=1) -> series.Series: - """Shift index by desired number of periods.""" - # Window framing clause is not allowed for analytic function lag. - window = window_specs.rows( - grouping_keys=tuple(self._by_col_ids), - ) - return self._apply_window_op(agg_ops.ShiftOp(periods), window=window) - - @validations.requires_ordering() - def diff(self, periods=1) -> series.Series: - window = window_specs.rows( - grouping_keys=tuple(self._by_col_ids), - ) - return self._apply_window_op(agg_ops.DiffOp(periods), window=window) - - @validations.requires_ordering() - def rolling(self, window: int, min_periods=None) -> windows.Window: - # To get n size window, need current row and n-1 preceding rows. - window_spec = window_specs.rows( - grouping_keys=tuple(self._by_col_ids), - preceding=window - 1, - following=0, - min_periods=min_periods or window, - ) - block = self._block.order_by( - [order.ascending_over(col) for col in self._by_col_ids], - ) - return windows.Window( - block, - window_spec, - [self._value_column], - drop_null_groups=self._dropna, - is_series=True, - ) - - @validations.requires_ordering() - def expanding(self, min_periods: int = 1) -> windows.Window: - window_spec = window_specs.cumulative_rows( - grouping_keys=tuple(self._by_col_ids), - min_periods=min_periods, - ) - block = self._block.order_by( - [order.ascending_over(col) for col in self._by_col_ids], - ) - return windows.Window( - block, - window_spec, - [self._value_column], - drop_null_groups=self._dropna, - is_series=True, - ) - - def _aggregate(self, aggregate_op: agg_ops.UnaryAggregateOp) -> series.Series: - result_block, _ = self._block.aggregate( - self._by_col_ids, - (agg(self._value_column, aggregate_op),), - dropna=self._dropna, - ) - - return series.Series(result_block.with_column_labels([self._value_name])) - - def _apply_window_op( - self, - op: agg_ops.WindowOp, - discard_name=False, - window: typing.Optional[window_specs.WindowSpec] = None, - never_skip_nulls: bool = False, - ): - """Apply window op to groupby. Defaults to grouped cumulative window.""" - window_spec = window or window_specs.cumulative_rows( - grouping_keys=tuple(self._by_col_ids) - ) - - label = self._value_name if not discard_name else None - block, result_id = self._block.apply_window_op( - self._value_column, - op, - result_label=label, - window_spec=window_spec, - never_skip_nulls=never_skip_nulls, - ) - return series.Series(block.select_column(result_id)) - - -def agg(input: str, op: agg_ops.AggregateOp) -> bigframes.core.expression.Aggregation: - if isinstance(op, agg_ops.UnaryAggregateOp): - return bigframes.core.expression.UnaryAggregation( - op, bigframes.core.expression.deref(input) - ) - else: - assert isinstance(op, agg_ops.NullaryAggregateOp) - return bigframes.core.expression.NullaryAggregation(op) +__all__ = ["DataFrameGroupBy", "SeriesGroupBy"] diff --git a/bigframes/core/groupby/aggs.py b/bigframes/core/groupby/aggs.py new file mode 100644 index 0000000000..9d8b957d54 --- /dev/null +++ b/bigframes/core/groupby/aggs.py @@ -0,0 +1,26 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from bigframes.core import agg_expressions, expression +from bigframes.operations import aggregations as agg_ops + + +def agg(input: str, op: agg_ops.AggregateOp) -> agg_expressions.Aggregation: + if isinstance(op, agg_ops.UnaryAggregateOp): + return agg_expressions.UnaryAggregation(op, expression.deref(input)) + else: + assert isinstance(op, agg_ops.NullaryAggregateOp) + return agg_expressions.NullaryAggregation(op) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py new file mode 100644 index 0000000000..e3a132d4d0 --- /dev/null +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -0,0 +1,782 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import datetime +import typing +from typing import Iterable, Literal, Optional, Sequence, Tuple, Union + +import bigframes_vendored.constants as constants +import bigframes_vendored.pandas.core.groupby as vendored_pandas_groupby +import numpy +import pandas as pd + +from bigframes import session +from bigframes.core import agg_expressions +from bigframes.core import expression as ex +from bigframes.core import log_adapter +import bigframes.core.block_transforms as block_ops +import bigframes.core.blocks as blocks +from bigframes.core.groupby import aggs, group_by, series_group_by +import bigframes.core.ordering as order +import bigframes.core.utils as utils +import bigframes.core.validations as validations +from bigframes.core.window import rolling +import bigframes.core.window as windows +import bigframes.core.window_spec as window_specs +import bigframes.dataframe as df +import bigframes.dtypes as dtypes +import bigframes.operations +import bigframes.operations.aggregations as agg_ops +import bigframes.series as series + + +@log_adapter.class_logger +class DataFrameGroupBy(vendored_pandas_groupby.DataFrameGroupBy): + __doc__ = vendored_pandas_groupby.GroupBy.__doc__ + + def __init__( + self, + block: blocks.Block, + by_col_ids: typing.Sequence[str], + *, + selected_cols: typing.Optional[typing.Sequence[str]] = None, + dropna: bool = True, + as_index: bool = True, + by_key_is_singular: bool = False, + ): + # TODO(tbergeron): Support more group-by expression types + self._block = block + self._col_id_labels = { + value_column: column_label + for value_column, column_label in zip( + block.value_columns, block.column_labels + ) + } + self._by_col_ids = by_col_ids + self._by_key_is_singular = by_key_is_singular + if by_key_is_singular: + assert len(by_col_ids) == 1, "singular key should be exactly one group key" + + self._dropna = dropna + self._as_index = as_index + if selected_cols: + for col in selected_cols: + if col not in self._block.value_columns: + raise ValueError(f"Invalid column selection: {col}") + self._selected_cols = selected_cols + else: + self._selected_cols = [ + col_id + for col_id in self._block.value_columns + if col_id not in self._by_col_ids + ] + + @property + def _session(self) -> session.Session: + return self._block.session + + def __getitem__( + self, + key: typing.Union[ + blocks.Label, + typing.Sequence[blocks.Label], + ], + ): + import bigframes._tools.strings + + if utils.is_list_like(key): + keys = list(key) + else: + keys = [key] + + bad_keys = [key for key in keys if key not in self._block.column_labels] + + # Raise a KeyError message with the possible correct key(s) + if len(bad_keys) > 0: + possible_key = [] + for bad_key in bad_keys: + possible_key.append( + min( + self._block.column_labels, + key=lambda item: bigframes._tools.strings.levenshtein_distance( + bad_key, item + ), + ) + ) + raise KeyError( + f"Columns not found: {str(bad_keys)[1:-1]}. Did you mean {str(possible_key)[1:-1]}?" + ) + + columns = [ + col_id for col_id, label in self._col_id_labels.items() if label in keys + ] + + if len(columns) > 1 or (not self._as_index): + return DataFrameGroupBy( + self._block, + self._by_col_ids, + selected_cols=columns, + dropna=self._dropna, + as_index=self._as_index, + ) + else: + return series_group_by.SeriesGroupBy( + self._block, + columns[0], + self._by_col_ids, + value_name=self._col_id_labels[columns[0]], + dropna=self._dropna, + ) + + @validations.requires_ordering() + def head(self, n: int = 5) -> df.DataFrame: + block = self._block + if self._dropna: + block = block_ops.dropna(self._block, self._by_col_ids, how="any") + return df.DataFrame( + block.grouped_head( + by_column_ids=self._by_col_ids, + value_columns=self._block.value_columns, + n=n, + ) + ) + + def describe(self, include: None | Literal["all"] = None): + from bigframes.pandas.core.methods import describe + + return df.DataFrame( + describe._describe( + self._block, + self._selected_cols, + include, + as_index=self._as_index, + by_col_ids=self._by_col_ids, + dropna=self._dropna, + ) + ) + + def __iter__(self) -> Iterable[Tuple[blocks.Label, df.DataFrame]]: + for group_keys, filtered_block in group_by.block_groupby_iter( + self._block, + by_col_ids=self._by_col_ids, + by_key_is_singular=self._by_key_is_singular, + dropna=self._dropna, + ): + filtered_df = df.DataFrame(filtered_block) + yield group_keys, filtered_df + + def __len__(self) -> int: + return len(self.agg([])) + + def size(self) -> typing.Union[df.DataFrame, series.Series]: + agg_block = self._block.aggregate( + aggregations=[agg_ops.SizeOp().as_expr()], + by_column_ids=self._by_col_ids, + dropna=self._dropna, + ) + agg_block = agg_block.with_column_labels(pd.Index(["size"])) + dataframe = df.DataFrame(agg_block) + + if self._as_index: + series = dataframe["size"] + return series.rename(None) + else: + return self._convert_index(dataframe) + + def sum(self, numeric_only: bool = False, *args) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("sum") + return self._aggregate_all(agg_ops.sum_op, numeric_only=True) + + def mean(self, numeric_only: bool = False, *args) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("mean") + return self._aggregate_all(agg_ops.mean_op, numeric_only=True) + + def median(self, numeric_only: bool = False, *, exact: bool = True) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("median") + if exact: + return self.quantile(0.5) + return self._aggregate_all(agg_ops.median_op, numeric_only=True) + + def rank( + self, + method="average", + ascending: bool = True, + na_option: str = "keep", + pct: bool = False, + ) -> df.DataFrame: + return df.DataFrame( + block_ops.rank( + self._block, + method, + na_option, + ascending, + grouping_cols=tuple(self._by_col_ids), + columns=tuple(self._selected_cols), + pct=pct, + ) + ) + + def quantile( + self, q: Union[float, Sequence[float]] = 0.5, *, numeric_only: bool = False + ) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("quantile") + q_cols = tuple( + col + for col in self._selected_cols + if self._column_type(col) in dtypes.NUMERIC_BIGFRAMES_TYPES_PERMISSIVE + ) + multi_q = utils.is_list_like(q) + result = block_ops.quantile( + self._block, + q_cols, + qs=tuple(q) if multi_q else (q,), # type: ignore + grouping_column_ids=self._by_col_ids, + dropna=self._dropna, + ) + result_df = df.DataFrame(result) + if multi_q: + return result_df.stack() + else: + return result_df.droplevel(-1, 1) + + def min(self, numeric_only: bool = False, *args) -> df.DataFrame: + return self._aggregate_all(agg_ops.min_op, numeric_only=numeric_only) + + def max(self, numeric_only: bool = False, *args) -> df.DataFrame: + return self._aggregate_all(agg_ops.max_op, numeric_only=numeric_only) + + def std( + self, + *, + numeric_only: bool = False, + ) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("std") + return self._aggregate_all(agg_ops.std_op, numeric_only=True) + + def var( + self, + *, + numeric_only: bool = False, + ) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("var") + return self._aggregate_all(agg_ops.var_op, numeric_only=True) + + def corr( + self, + *, + numeric_only: bool = False, + ) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("corr") + if len(self._selected_cols) > 30: + raise ValueError( + f"Cannot calculate corr on >30 columns, dataframe has {len(self._selected_cols)} selected columns." + ) + + labels = self._block._get_labels_for_columns(self._selected_cols) + block = self._block + aggregations = [ + agg_expressions.BinaryAggregation( + agg_ops.CorrOp(), ex.deref(left_col), ex.deref(right_col) + ) + for left_col in self._selected_cols + for right_col in self._selected_cols + ] + # unique columns stops + uniq_orig_columns = utils.combine_indices(labels, pd.Index(range(len(labels)))) + result_labels = utils.cross_indices(uniq_orig_columns, uniq_orig_columns) + + block = block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + column_labels=result_labels, + ) + + block = block.stack(levels=labels.nlevels + 1) + # Drop the last level of each index, which was created to guarantee uniqueness + return df.DataFrame(block).droplevel(-1, axis=0).droplevel(-1, axis=1) + + def cov( + self, + *, + numeric_only: bool = False, + ) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("cov") + if len(self._selected_cols) > 30: + raise ValueError( + f"Cannot calculate cov on >30 columns, dataframe has {len(self._selected_cols)} selected columns." + ) + + labels = self._block._get_labels_for_columns(self._selected_cols) + block = self._block + aggregations = [ + agg_expressions.BinaryAggregation( + agg_ops.CovOp(), ex.deref(left_col), ex.deref(right_col) + ) + for left_col in self._selected_cols + for right_col in self._selected_cols + ] + # unique columns stops + uniq_orig_columns = utils.combine_indices(labels, pd.Index(range(len(labels)))) + result_labels = utils.cross_indices(uniq_orig_columns, uniq_orig_columns) + + block = block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + column_labels=result_labels, + ) + + block = block.stack(levels=labels.nlevels + 1) + # Drop the last level of each index, which was created to guarantee uniqueness + return df.DataFrame(block).droplevel(-1, axis=0).droplevel(-1, axis=1) + + def skew( + self, + *, + numeric_only: bool = False, + ) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("skew") + block = block_ops.skew(self._block, self._selected_cols, self._by_col_ids) + return df.DataFrame(block) + + def kurt( + self, + *, + numeric_only: bool = False, + ) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("kurt") + block = block_ops.kurt(self._block, self._selected_cols, self._by_col_ids) + return df.DataFrame(block) + + kurtosis = kurt + + @validations.requires_ordering() + def first(self, numeric_only: bool = False, min_count: int = -1) -> df.DataFrame: + window_spec = window_specs.unbound( + grouping_keys=tuple(self._by_col_ids), + min_periods=min_count if min_count >= 0 else 0, + ) + target_cols, index = self._aggregated_columns(numeric_only) + block, firsts_ids = self._block.multi_apply_window_op( + target_cols, + agg_ops.FirstNonNullOp(), + window_spec=window_spec, + ) + block = block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=tuple( + aggs.agg(firsts_id, agg_ops.AnyValueOp()) for firsts_id in firsts_ids + ), + dropna=self._dropna, + column_labels=index, + ) + return df.DataFrame(block) + + @validations.requires_ordering() + def last(self, numeric_only: bool = False, min_count: int = -1) -> df.DataFrame: + window_spec = window_specs.unbound( + grouping_keys=tuple(self._by_col_ids), + min_periods=min_count if min_count >= 0 else 0, + ) + target_cols, index = self._aggregated_columns(numeric_only) + block, lasts_ids = self._block.multi_apply_window_op( + target_cols, + agg_ops.LastNonNullOp(), + window_spec=window_spec, + ) + block = block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=tuple( + aggs.agg(lasts_id, agg_ops.AnyValueOp()) for lasts_id in lasts_ids + ), + dropna=self._dropna, + column_labels=index, + ) + return df.DataFrame(block) + + def all(self) -> df.DataFrame: + return self._aggregate_all(agg_ops.all_op) + + def any(self) -> df.DataFrame: + return self._aggregate_all(agg_ops.any_op) + + def count(self) -> df.DataFrame: + return self._aggregate_all(agg_ops.count_op) + + def nunique(self) -> df.DataFrame: + return self._aggregate_all(agg_ops.nunique_op) + + @validations.requires_ordering() + def cumcount(self, ascending: bool = True) -> series.Series: + window_spec = ( + window_specs.cumulative_rows(grouping_keys=tuple(self._by_col_ids)) + if ascending + else window_specs.inverse_cumulative_rows( + grouping_keys=tuple(self._by_col_ids) + ) + ) + block, result_ids = self._block.apply_analytic( + [agg_expressions.NullaryAggregation(agg_ops.size_op)], + window=window_spec, + result_labels=[None], + ) + result = series.Series(block.select_columns(result_ids)) - 1 + if self._dropna and (len(self._by_col_ids) == 1): + result = result.mask( + series.Series(block.select_column(self._by_col_ids[0])).isna() + ) + return result + + @validations.requires_ordering() + def cumsum(self, *args, numeric_only: bool = False, **kwargs) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("cumsum") + return self._apply_window_op(agg_ops.sum_op, numeric_only=True) + + @validations.requires_ordering() + def cummin(self, *args, numeric_only: bool = False, **kwargs) -> df.DataFrame: + return self._apply_window_op(agg_ops.min_op, numeric_only=numeric_only) + + @validations.requires_ordering() + def cummax(self, *args, numeric_only: bool = False, **kwargs) -> df.DataFrame: + return self._apply_window_op(agg_ops.max_op, numeric_only=numeric_only) + + @validations.requires_ordering() + def cumprod(self, *args, **kwargs) -> df.DataFrame: + return self._apply_window_op(agg_ops.product_op, numeric_only=True) + + @validations.requires_ordering() + def shift(self, periods=1) -> series.Series: + # Window framing clause is not allowed for analytic function lag. + window = window_specs.unbound( + grouping_keys=tuple(self._by_col_ids), + ) + return self._apply_window_op(agg_ops.ShiftOp(periods), window=window) + + @validations.requires_ordering() + def diff(self, periods=1) -> series.Series: + # Window framing clause is not allowed for analytic function lag. + window = window_specs.rows( + grouping_keys=tuple(self._by_col_ids), + ) + return self._apply_window_op(agg_ops.DiffOp(periods), window=window) + + def value_counts( + self, + subset: Optional[Sequence[blocks.Label]] = None, + normalize: bool = False, + sort: bool = True, + ascending: bool = False, + dropna: bool = True, + ) -> Union[df.DataFrame, series.Series]: + if subset is None: + columns = self._selected_cols + else: + columns = [ + column + for column in self._block.value_columns + if self._block.col_id_to_label[column] in subset + ] + block = self._block + if self._dropna: # this drops null grouping columns + block = block_ops.dropna(block, self._by_col_ids) + block = block_ops.value_counts( + block, + columns, + normalize=normalize, + sort=sort, + ascending=ascending, + drop_na=dropna, # this drops null value columns + grouping_keys=self._by_col_ids, + ) + if self._as_index: + return series.Series(block) + else: + return series.Series(block).to_frame().reset_index(drop=False) + + @validations.requires_ordering() + def rolling( + self, + window: int | pd.Timedelta | numpy.timedelta64 | datetime.timedelta | str, + min_periods=None, + on: str | None = None, + closed: Literal["right", "left", "both", "neither"] = "right", + ) -> windows.Window: + if isinstance(window, int): + window_spec = window_specs.WindowSpec( + bounds=window_specs.RowsWindowBounds.from_window_size(window, closed), + min_periods=min_periods if min_periods is not None else window, + grouping_keys=tuple(ex.deref(col) for col in self._by_col_ids), + ) + block = self._block.order_by( + [order.ascending_over(col) for col in self._by_col_ids], + ) + skip_agg_col_id = ( + None if on is None else self._block.resolve_label_exact_or_error(on) + ) + return windows.Window( + block, + window_spec, + self._selected_cols, + drop_null_groups=self._dropna, + skip_agg_column_id=skip_agg_col_id, + ) + + return rolling.create_range_window( + self._block, + window, + min_periods=min_periods, + value_column_ids=self._selected_cols, + on=on, + closed=closed, + is_series=False, + grouping_keys=self._by_col_ids, + drop_null_groups=self._dropna, + ) + + @validations.requires_ordering() + def expanding(self, min_periods: int = 1) -> windows.Window: + window_spec = window_specs.cumulative_rows( + grouping_keys=tuple(self._by_col_ids), + min_periods=min_periods, + ) + block = self._block.order_by( + [order.ascending_over(col) for col in self._by_col_ids], + ) + return windows.Window( + block, window_spec, self._selected_cols, drop_null_groups=self._dropna + ) + + def agg(self, func=None, **kwargs) -> typing.Union[df.DataFrame, series.Series]: + if func: + if utils.is_dict_like(func): + return self._agg_dict(func) + elif utils.is_list_like(func): + return self._agg_list(func) + else: + return self.size() if func == "size" else self._agg_func(func) + else: + return self._agg_named(**kwargs) + + def _agg_func(self, func) -> df.DataFrame: + ids, labels = self._aggregated_columns() + aggregations = [ + aggs.agg(col_id, agg_ops.lookup_agg_func(func)[0]) for col_id in ids + ] + agg_block = self._block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + dropna=self._dropna, + column_labels=labels, + ) + dataframe = df.DataFrame(agg_block) + return dataframe if self._as_index else self._convert_index(dataframe) + + def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: + aggregations: typing.List[agg_expressions.Aggregation] = [] + column_labels = [] + function_labels = [] + + want_aggfunc_level = any(utils.is_list_like(aggs) for aggs in func.values()) + + for label, funcs_for_id in func.items(): + col_id = self._resolve_label(label) + func_list = ( + funcs_for_id if utils.is_list_like(funcs_for_id) else [funcs_for_id] + ) + for f in func_list: + f_op, f_label = agg_ops.lookup_agg_func(f) + aggregations.append(aggs.agg(col_id, f_op)) + column_labels.append(label) + function_labels.append(f_label) + agg_block = self._block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + dropna=self._dropna, + ) + if want_aggfunc_level: + agg_block = agg_block.with_column_labels( + utils.combine_indices( + pd.Index(column_labels), + pd.Index(function_labels), + ) + ) + else: + agg_block = agg_block.with_column_labels(pd.Index(column_labels)) + dataframe = df.DataFrame(agg_block) + return dataframe if self._as_index else self._convert_index(dataframe) + + def _agg_list(self, func: typing.Sequence) -> df.DataFrame: + ids, labels = self._aggregated_columns() + aggregations = [ + aggs.agg(col_id, agg_ops.lookup_agg_func(f)[0]) + for col_id in ids + for f in func + ] + + if self._block.column_labels.nlevels > 1: + # Restructure MultiIndex for proper format: (idx1, idx2, func) + # rather than ((idx1, idx2), func). + column_labels = [ + tuple(label) + (agg_ops.lookup_agg_func(f)[1],) + for label in labels.to_frame(index=False).to_numpy() + for f in func + ] + else: # Single-level index + column_labels = [ + (label, agg_ops.lookup_agg_func(f)[1]) for label in labels for f in func + ] + + agg_block = self._block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + dropna=self._dropna, + ) + agg_block = agg_block.with_column_labels( + pd.MultiIndex.from_tuples( + column_labels, names=[*self._block.column_labels.names, None] + ) + ) + dataframe = df.DataFrame(agg_block) + return dataframe if self._as_index else self._convert_index(dataframe) + + def _agg_named(self, **kwargs) -> df.DataFrame: + aggregations = [] + column_labels = [] + for k, v in kwargs.items(): + if not isinstance(k, str): + raise NotImplementedError( + f"Only string aggregate names supported. {constants.FEEDBACK_LINK}" + ) + if not isinstance(v, tuple) or (len(v) != 2): + raise TypeError("kwargs values must be 2-tuples of column, aggfunc") + col_id = self._resolve_label(v[0]) + aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(v[1])[0])) + column_labels.append(k) + agg_block = self._block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + dropna=self._dropna, + ) + agg_block = agg_block.with_column_labels(column_labels) + dataframe = df.DataFrame(agg_block) + return dataframe if self._as_index else self._convert_index(dataframe) + + def _convert_index(self, dataframe: df.DataFrame): + """Convert index levels to columns except where names conflict.""" + levels_to_drop = [ + level for level in dataframe.index.names if level in dataframe.columns + ] + + if len(levels_to_drop) == dataframe.index.nlevels: + return dataframe.reset_index(drop=True) + return dataframe.droplevel(levels_to_drop).reset_index(drop=False) + + aggregate = agg + + def _raise_on_non_numeric(self, op: str): + if not all( + self._column_type(col) in dtypes.NUMERIC_BIGFRAMES_TYPES_PERMISSIVE + for col in self._selected_cols + ): + raise NotImplementedError( + f"'{op}' does not support non-numeric columns. " + "Set 'numeric_only'=True to ignore non-numeric columns. " + f"{constants.FEEDBACK_LINK}" + ) + return self + + def _aggregated_columns( + self, numeric_only: bool = False + ) -> Tuple[typing.Sequence[str], pd.Index]: + valid_agg_cols: list[str] = [] + offsets: list[int] = [] + for i, col_id in enumerate(self._block.value_columns): + is_numeric = ( + self._column_type(col_id) in dtypes.NUMERIC_BIGFRAMES_TYPES_PERMISSIVE + ) + if (col_id in self._selected_cols) and (is_numeric or not numeric_only): + offsets.append(i) + valid_agg_cols.append(col_id) + return valid_agg_cols, self._block.column_labels.take(offsets) + + def _column_type(self, col_id: str) -> dtypes.Dtype: + col_offset = self._block.value_columns.index(col_id) + dtype = self._block.dtypes[col_offset] + return dtype + + def _aggregate_all( + self, aggregate_op: agg_ops.UnaryAggregateOp, numeric_only: bool = False + ) -> df.DataFrame: + aggregated_col_ids, labels = self._aggregated_columns(numeric_only=numeric_only) + aggregations = [aggs.agg(col_id, aggregate_op) for col_id in aggregated_col_ids] + result_block = self._block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + column_labels=labels, + dropna=self._dropna, + ) + dataframe = df.DataFrame(result_block) + return dataframe if self._as_index else self._convert_index(dataframe) + + def _apply_window_op( + self, + op: agg_ops.UnaryWindowOp, + window: typing.Optional[window_specs.WindowSpec] = None, + numeric_only: bool = False, + ): + """Apply window op to groupby. Defaults to grouped cumulative window.""" + window_spec = window or window_specs.cumulative_rows( + grouping_keys=tuple(self._by_col_ids) + ) + columns, labels = self._aggregated_columns(numeric_only=numeric_only) + block, result_ids = self._block.multi_apply_window_op( + columns, + op, + window_spec=window_spec, + ) + block = block.project_exprs( + tuple( + bigframes.operations.where_op.as_expr( + r_col, + bigframes.operations.notnull_op.as_expr(og_col), + ex.const(None), + ) + for og_col, r_col in zip(columns, result_ids) + ), + labels=labels, + drop=True, + ) + + return df.DataFrame(block) + + def _resolve_label(self, label: blocks.Label) -> str: + """Resolve label to column id.""" + col_ids = self._block.label_to_col_id.get(label, ()) + if len(col_ids) > 1: + raise ValueError(f"Label {label} is ambiguous") + if len(col_ids) == 0: + raise ValueError(f"Label {label} does not match any columns") + return col_ids[0] diff --git a/bigframes/core/groupby/group_by.py b/bigframes/core/groupby/group_by.py new file mode 100644 index 0000000000..1d24e61545 --- /dev/null +++ b/bigframes/core/groupby/group_by.py @@ -0,0 +1,91 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import functools +from typing import Sequence + +import pandas as pd + +from bigframes.core import blocks +from bigframes.core import expression as ex +import bigframes.enums +import bigframes.operations as ops + + +def block_groupby_iter( + block: blocks.Block, + *, + by_col_ids: Sequence[str], + by_key_is_singular: bool, + dropna: bool, +): + original_index_columns = block._index_columns + original_index_labels = block._index_labels + by_col_ids = by_col_ids + block = block.reset_index( + level=None, + # Keep the original index columns so they can be recovered. + drop=False, + allow_duplicates=True, + replacement=bigframes.enums.DefaultIndexKind.NULL, + ).set_index( + by_col_ids, + # Keep by_col_ids in-place so the ordering doesn't change. + drop=False, + append=False, + ) + block.cached( + force=True, + # All DataFrames will be filtered by by_col_ids, so + # force block.cached() to cluster by the new index by explicitly + # setting `session_aware=False`. This will ensure that the filters + # are more efficient. + session_aware=False, + ) + keys_block = block.aggregate(by_column_ids=by_col_ids, dropna=dropna) + for chunk in keys_block.to_pandas_batches(): + # Convert to MultiIndex to make sure we get tuples, + # even for singular keys. + by_keys_index = chunk.index + if not isinstance(by_keys_index, pd.MultiIndex): + by_keys_index = pd.MultiIndex.from_frame(by_keys_index.to_frame()) + + for by_keys in by_keys_index: + filtered_block = ( + # To ensure the cache is used, filter first, then reset the + # index before yielding the DataFrame. + block.filter( + functools.reduce( + ops.and_op.as_expr, + ( + ops.eq_op.as_expr(by_col, ex.const(by_key)) + for by_col, by_key in zip(by_col_ids, by_keys) + ), + ), + ).set_index( + original_index_columns, + # We retained by_col_ids in the set_index call above, + # so it's safe to drop the duplicates now. + drop=True, + append=False, + index_labels=original_index_labels, + ) + ) + + if by_key_is_singular: + yield by_keys[0], filtered_block + else: + yield by_keys, filtered_block diff --git a/bigframes/core/groupby/series_group_by.py b/bigframes/core/groupby/series_group_by.py new file mode 100644 index 0000000000..b1485888a8 --- /dev/null +++ b/bigframes/core/groupby/series_group_by.py @@ -0,0 +1,453 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import datetime +import typing +from typing import Iterable, Literal, Sequence, Tuple, Union + +import bigframes_vendored.constants as constants +import bigframes_vendored.pandas.core.groupby as vendored_pandas_groupby +import numpy +import pandas + +from bigframes import session +from bigframes.core import expression as ex +from bigframes.core import log_adapter +import bigframes.core.block_transforms as block_ops +import bigframes.core.blocks as blocks +from bigframes.core.groupby import aggs, group_by +import bigframes.core.ordering as order +import bigframes.core.utils as utils +import bigframes.core.validations as validations +from bigframes.core.window import rolling +import bigframes.core.window as windows +import bigframes.core.window_spec as window_specs +import bigframes.dataframe as df +import bigframes.dtypes +import bigframes.operations +import bigframes.operations.aggregations as agg_ops +import bigframes.series as series + + +@log_adapter.class_logger +class SeriesGroupBy(vendored_pandas_groupby.SeriesGroupBy): + __doc__ = vendored_pandas_groupby.GroupBy.__doc__ + + def __init__( + self, + block: blocks.Block, + value_column: str, + by_col_ids: typing.Sequence[str], + value_name: blocks.Label = None, + dropna=True, + *, + by_key_is_singular: bool = False, + ): + # TODO(tbergeron): Support more group-by expression types + self._block = block + self._value_column = value_column + self._by_col_ids = by_col_ids + self._value_name = value_name + self._dropna = dropna # Applies to aggregations but not windowing + + self._by_key_is_singular = by_key_is_singular + if by_key_is_singular: + assert len(by_col_ids) == 1, "singular key should be exactly one group key" + + @property + def _session(self) -> session.Session: + return self._block.session + + @validations.requires_ordering() + def head(self, n: int = 5) -> series.Series: + block = self._block + if self._dropna: + block = block_ops.dropna(self._block, self._by_col_ids, how="any") + return series.Series( + block.grouped_head( + by_column_ids=self._by_col_ids, value_columns=[self._value_column], n=n + ) + ) + + def describe(self, include: None | Literal["all"] = None): + from bigframes.pandas.core.methods import describe + + return df.DataFrame( + describe._describe( + self._block, + columns=[self._value_column], + include=include, + as_index=True, + by_col_ids=self._by_col_ids, + dropna=self._dropna, + ) + ).droplevel(level=0, axis=1) + + def __iter__(self) -> Iterable[Tuple[blocks.Label, series.Series]]: + for group_keys, filtered_block in group_by.block_groupby_iter( + self._block, + by_col_ids=self._by_col_ids, + by_key_is_singular=self._by_key_is_singular, + dropna=self._dropna, + ): + filtered_series = series.Series( + filtered_block.select_column(self._value_column) + ) + filtered_series.name = self._value_name + yield group_keys, filtered_series + + def __len__(self) -> int: + return len(self.agg([])) + + def all(self) -> series.Series: + return self._aggregate(agg_ops.all_op) + + def any(self) -> series.Series: + return self._aggregate(agg_ops.any_op) + + def min(self, *args) -> series.Series: + return self._aggregate(agg_ops.min_op) + + def max(self, *args) -> series.Series: + return self._aggregate(agg_ops.max_op) + + def count(self) -> series.Series: + return self._aggregate(agg_ops.count_op) + + def nunique(self) -> series.Series: + return self._aggregate(agg_ops.nunique_op) + + def sum(self, *args) -> series.Series: + return self._aggregate(agg_ops.sum_op) + + def mean(self, *args) -> series.Series: + return self._aggregate(agg_ops.mean_op) + + def rank( + self, + method="average", + ascending: bool = True, + na_option: str = "keep", + pct: bool = False, + ) -> series.Series: + return series.Series( + block_ops.rank( + self._block, + method, + na_option, + ascending, + grouping_cols=tuple(self._by_col_ids), + columns=(self._value_column,), + pct=pct, + ) + ) + + def median( + self, + *args, + exact: bool = True, + **kwargs, + ) -> series.Series: + if exact: + return self.quantile(0.5) + else: + return self._aggregate(agg_ops.median_op) + + def quantile( + self, q: Union[float, Sequence[float]] = 0.5, *, numeric_only: bool = False + ) -> series.Series: + multi_q = utils.is_list_like(q) + result = block_ops.quantile( + self._block, + (self._value_column,), + qs=tuple(q) if multi_q else (q,), # type: ignore + grouping_column_ids=self._by_col_ids, + dropna=self._dropna, + ) + if multi_q: + return series.Series(result.stack()) + else: + return series.Series(result.stack()).droplevel(-1) + + def std(self, *args, **kwargs) -> series.Series: + return self._aggregate(agg_ops.std_op) + + def var(self, *args, **kwargs) -> series.Series: + return self._aggregate(agg_ops.var_op) + + def size(self) -> series.Series: + agg_block = self._block.aggregate( + aggregations=[agg_ops.SizeOp().as_expr()], + by_column_ids=self._by_col_ids, + dropna=self._dropna, + ) + return series.Series(agg_block.with_column_labels([self._value_name])) + + def skew(self, *args, **kwargs) -> series.Series: + block = block_ops.skew(self._block, [self._value_column], self._by_col_ids) + return series.Series(block) + + def kurt(self, *args, **kwargs) -> series.Series: + block = block_ops.kurt(self._block, [self._value_column], self._by_col_ids) + return series.Series(block) + + kurtosis = kurt + + @validations.requires_ordering() + def first(self, numeric_only: bool = False, min_count: int = -1) -> series.Series: + if numeric_only and not bigframes.dtypes.is_numeric( + self._block.expr.get_column_type(self._value_column) + ): + raise TypeError( + f"Cannot use 'numeric_only' with non-numeric column {self._value_name}." + ) + window_spec = window_specs.unbound( + grouping_keys=tuple(self._by_col_ids), + min_periods=min_count if min_count >= 0 else 0, + ) + block, firsts_id = self._block.apply_window_op( + self._value_column, + agg_ops.FirstNonNullOp(), + window_spec=window_spec, + ) + block = block.aggregate( + (aggs.agg(firsts_id, agg_ops.AnyValueOp()),), + self._by_col_ids, + dropna=self._dropna, + ) + return series.Series(block.with_column_labels([self._value_name])) + + @validations.requires_ordering() + def last(self, numeric_only: bool = False, min_count: int = -1) -> series.Series: + if numeric_only and not bigframes.dtypes.is_numeric( + self._block.expr.get_column_type(self._value_column) + ): + raise TypeError( + f"Cannot use 'numeric_only' with non-numeric column {self._value_name}." + ) + window_spec = window_specs.unbound( + grouping_keys=tuple(self._by_col_ids), + min_periods=min_count if min_count >= 0 else 0, + ) + block, firsts_id = self._block.apply_window_op( + self._value_column, + agg_ops.LastNonNullOp(), + window_spec=window_spec, + ) + block = block.aggregate( + (aggs.agg(firsts_id, agg_ops.AnyValueOp()),), + self._by_col_ids, + dropna=self._dropna, + ) + return series.Series(block.with_column_labels([self._value_name])) + + def prod(self, *args) -> series.Series: + return self._aggregate(agg_ops.product_op) + + def agg(self, func=None) -> typing.Union[df.DataFrame, series.Series]: + column_names: list[str] = [] + if utils.is_dict_like(func): + raise NotImplementedError( + f"Aggregate with {func} not supported. {constants.FEEDBACK_LINK}" + ) + if not utils.is_list_like(func): + func = [func] + + aggregations = [ + aggs.agg(self._value_column, agg_ops.lookup_agg_func(f)[0]) for f in func + ] + column_names = [agg_ops.lookup_agg_func(f)[1] for f in func] + + agg_block = self._block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + dropna=self._dropna, + ) + + if column_names: + agg_block = agg_block.with_column_labels(column_names) + + if len(aggregations) == 1: + return series.Series(agg_block) + return df.DataFrame(agg_block) + + aggregate = agg + + def value_counts( + self, + normalize: bool = False, + sort: bool = True, + ascending: bool = False, + dropna: bool = True, + ) -> Union[df.DataFrame, series.Series]: + columns = [self._value_column] + block = self._block + if self._dropna: # this drops null grouping columns + block = block_ops.dropna(block, self._by_col_ids) + block = block_ops.value_counts( + block, + columns, + normalize=normalize, + sort=sort, + ascending=ascending, + drop_na=dropna, # this drops null value columns + grouping_keys=self._by_col_ids, + ) + # TODO: once as_index=Fales supported, return DataFrame instead by resetting index + # with .to_frame().reset_index(drop=False) + return series.Series(block) + + @validations.requires_ordering() + def cumsum(self, *args, **kwargs) -> series.Series: + return self._apply_window_op( + agg_ops.sum_op, + ) + + @validations.requires_ordering() + def cumprod(self, *args, **kwargs) -> series.Series: + return self._apply_window_op( + agg_ops.product_op, + ) + + @validations.requires_ordering() + def cummax(self, *args, **kwargs) -> series.Series: + return self._apply_window_op( + agg_ops.max_op, + ) + + @validations.requires_ordering() + def cummin(self, *args, **kwargs) -> series.Series: + return self._apply_window_op( + agg_ops.min_op, + ) + + @validations.requires_ordering() + def cumcount(self, *args, **kwargs) -> series.Series: + # TODO: Add nullary op support to implement more cleanly + return ( + self._apply_window_op( + agg_ops.SizeUnaryOp(), + discard_name=True, + ) + - 1 + ) + + @validations.requires_ordering() + def shift(self, periods=1) -> series.Series: + """Shift index by desired number of periods.""" + # Window framing clause is not allowed for analytic function lag. + window = window_specs.rows( + grouping_keys=tuple(self._by_col_ids), + ) + return self._apply_window_op(agg_ops.ShiftOp(periods), window=window) + + @validations.requires_ordering() + def diff(self, periods=1) -> series.Series: + window = window_specs.rows( + grouping_keys=tuple(self._by_col_ids), + ) + return self._apply_window_op(agg_ops.DiffOp(periods), window=window) + + @validations.requires_ordering() + def rolling( + self, + window: int | pandas.Timedelta | numpy.timedelta64 | datetime.timedelta | str, + min_periods=None, + closed: Literal["right", "left", "both", "neither"] = "right", + ) -> windows.Window: + if isinstance(window, int): + window_spec = window_specs.WindowSpec( + bounds=window_specs.RowsWindowBounds.from_window_size(window, closed), + min_periods=min_periods if min_periods is not None else window, + grouping_keys=tuple(ex.deref(col) for col in self._by_col_ids), + ) + block = self._block.order_by( + [order.ascending_over(col) for col in self._by_col_ids], + ) + return windows.Window( + block, + window_spec, + [self._value_column], + drop_null_groups=self._dropna, + is_series=True, + ) + + return rolling.create_range_window( + self._block, + window, + min_periods=min_periods, + value_column_ids=[self._value_column], + closed=closed, + is_series=True, + grouping_keys=self._by_col_ids, + drop_null_groups=self._dropna, + ) + + @validations.requires_ordering() + def expanding(self, min_periods: int = 1) -> windows.Window: + window_spec = window_specs.cumulative_rows( + grouping_keys=tuple(self._by_col_ids), + min_periods=min_periods, + ) + block = self._block.order_by( + [order.ascending_over(col) for col in self._by_col_ids], + ) + return windows.Window( + block, + window_spec, + [self._value_column], + drop_null_groups=self._dropna, + is_series=True, + ) + + def _aggregate(self, aggregate_op: agg_ops.UnaryAggregateOp) -> series.Series: + result_block = self._block.aggregate( + (aggs.agg(self._value_column, aggregate_op),), + self._by_col_ids, + dropna=self._dropna, + ) + + return series.Series(result_block.with_column_labels([self._value_name])) + + def _apply_window_op( + self, + op: agg_ops.UnaryWindowOp, + discard_name=False, + window: typing.Optional[window_specs.WindowSpec] = None, + ) -> series.Series: + """Apply window op to groupby. Defaults to grouped cumulative window.""" + window_spec = window or window_specs.cumulative_rows( + grouping_keys=tuple(self._by_col_ids) + ) + + label = self._value_name if not discard_name else None + block, result_id = self._block.apply_window_op( + self._value_column, + op, + result_label=label, + window_spec=window_spec, + ) + if op.skips_nulls: + block, result_id = block.project_expr( + bigframes.operations.where_op.as_expr( + result_id, + bigframes.operations.notnull_op.as_expr(self._value_column), + ex.const(None), + ), + label, + ) + + return series.Series(block.select_column(result_id)) diff --git a/bigframes/core/guid.py b/bigframes/core/guid.py index 8930d0760a..f9b666d32b 100644 --- a/bigframes/core/guid.py +++ b/bigframes/core/guid.py @@ -11,11 +11,36 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import threading +import typing +_GUID_LOCK = threading.Lock() _GUID_COUNTER = 0 def generate_guid(prefix="col_"): - global _GUID_COUNTER - _GUID_COUNTER += 1 - return f"bfuid_{prefix}{_GUID_COUNTER}" + global _GUID_LOCK + with _GUID_LOCK: + global _GUID_COUNTER + _GUID_COUNTER += 1 + return f"bfuid_{prefix}{_GUID_COUNTER}" + + +class SequentialUIDGenerator: + """Produces a sequence of UIDs, such as {"t0", "t1", "c0", "t2", ...}, by + cycling through provided prefixes (e.g., "t" and "c"). + Note: this function is not thread-safe. + """ + + def __init__(self): + self.prefix_counters: typing.Dict[str, int] = {} + + def get_uid_stream(self, prefix: str) -> typing.Generator[str, None, None]: + """Yields a continuous stream of raw UID strings for the given prefix.""" + if prefix not in self.prefix_counters: + self.prefix_counters[prefix] = 0 + + while True: + uid = f"{prefix}{self.prefix_counters[prefix]}" + self.prefix_counters[prefix] += 1 + yield uid diff --git a/bigframes/core/indexers.py b/bigframes/core/indexers.py index 9c7fba8ec1..c60e40880b 100644 --- a/bigframes/core/indexers.py +++ b/bigframes/core/indexers.py @@ -27,6 +27,7 @@ import bigframes.core.guid as guid import bigframes.core.indexes as indexes import bigframes.core.scalar +import bigframes.core.window_spec as windows import bigframes.dataframe import bigframes.dtypes import bigframes.exceptions as bfe @@ -154,8 +155,8 @@ def __getitem__(self, key): # row key. We must choose one, so bias towards treating as multi-part row label if isinstance(key, tuple) and len(key) == 2: is_row_multi_index = self._dataframe.index.nlevels > 1 - is_first_item_tuple = isinstance(key[0], tuple) - if not is_row_multi_index or is_first_item_tuple: + is_first_item_list_or_tuple = isinstance(key[0], (tuple, list)) + if not is_row_multi_index or is_first_item_list_or_tuple: df = typing.cast( bigframes.dataframe.DataFrame, _loc_getitem_series_or_dataframe(self._dataframe, key[0]), @@ -379,12 +380,14 @@ def _perform_loc_list_join( result = typing.cast( bigframes.series.Series, series_or_dataframe.to_frame()._perform_join_by_index( - keys_index, how="right" + keys_index, how="right", always_order=True )[name], ) result = result.rename(original_name) else: - result = series_or_dataframe._perform_join_by_index(keys_index, how="right") + result = series_or_dataframe._perform_join_by_index( + keys_index, how="right", always_order=True + ) if drop_levels and series_or_dataframe.index.nlevels > keys_index.nlevels: # drop common levels @@ -407,7 +410,7 @@ def _struct_accessor_check_and_warn( return if not bigframes.dtypes.is_string_like(series.index.dtype): - msg = ( + msg = bfe.format_message( "Are you trying to access struct fields? If so, please use Series.struct.field(...) " "method instead." ) @@ -425,7 +428,7 @@ def _iloc_getitem_series_or_dataframe( @typing.overload def _iloc_getitem_series_or_dataframe( series_or_dataframe: bigframes.dataframe.DataFrame, key -) -> Union[bigframes.dataframe.DataFrame, pd.Series]: +) -> Union[bigframes.dataframe.DataFrame, pd.Series, bigframes.core.scalar.Scalar]: ... @@ -447,31 +450,55 @@ def _iloc_getitem_series_or_dataframe( return result_pd_df.iloc[0] elif isinstance(key, slice): return series_or_dataframe._slice(key.start, key.stop, key.step) - elif isinstance(key, tuple) and len(key) == 0: - return series_or_dataframe - elif isinstance(key, tuple) and len(key) == 1: - return _iloc_getitem_series_or_dataframe(series_or_dataframe, key[0]) - elif ( - isinstance(key, tuple) - and isinstance(series_or_dataframe, bigframes.dataframe.DataFrame) - and len(key) == 2 - ): - return series_or_dataframe.iat[key] elif isinstance(key, tuple): - raise pd.errors.IndexingError("Too many indexers") + if len(key) > 2 or ( + len(key) == 2 and isinstance(series_or_dataframe, bigframes.series.Series) + ): + raise pd.errors.IndexingError("Too many indexers") + + if len(key) == 0: + return series_or_dataframe + + if len(key) == 1: + return _iloc_getitem_series_or_dataframe(series_or_dataframe, key[0]) + + # len(key) == 2 + df = typing.cast(bigframes.dataframe.DataFrame, series_or_dataframe) + if isinstance(key[1], int): + return df.iat[key] + elif isinstance(key[1], list): + columns = df.columns[key[1]] + return _iloc_getitem_series_or_dataframe(df[columns], key[0]) + raise NotImplementedError( + f"iloc does not yet support indexing with {key}. {constants.FEEDBACK_LINK}" + ) elif pd.api.types.is_list_like(key): if len(key) == 0: return typing.cast( Union[bigframes.dataframe.DataFrame, bigframes.series.Series], series_or_dataframe.iloc[0:0], ) - df = series_or_dataframe + + # Check if both positive index and negative index are necessary + if isinstance(key, (bigframes.series.Series, indexes.Index)): + # Avoid data download + is_key_unisigned = False + else: + first_sign = key[0] >= 0 + is_key_unisigned = True + for k in key: + if (k >= 0) != first_sign: + is_key_unisigned = False + break + if isinstance(series_or_dataframe, bigframes.series.Series): original_series_name = series_or_dataframe.name series_name = ( original_series_name if original_series_name is not None else 0 ) df = series_or_dataframe.to_frame() + else: + df = series_or_dataframe original_index_names = df.index.names temporary_index_names = [ guid.generate_guid(prefix="temp_iloc_index_") @@ -481,6 +508,32 @@ def _iloc_getitem_series_or_dataframe( # set to offset index and use regular loc, then restore index df = df.reset_index(drop=False) + block = df._block + # explicitly set index to offsets, reset_index may not generate offsets in some modes + block, offsets_id = block.promote_offsets("temp_iloc_offsets_") + pos_block = block.set_index([offsets_id]) + + if not is_key_unisigned or key[0] < 0: + neg_block, size_col_id = block.apply_window_op( + offsets_id, + ops.aggregations.SizeUnaryOp(), + window_spec=windows.rows(), + ) + neg_block, neg_index_id = neg_block.apply_binary_op( + offsets_id, size_col_id, ops.SubOp() + ) + + neg_block = neg_block.set_index([neg_index_id]).drop_columns( + [size_col_id, offsets_id] + ) + + if is_key_unisigned: + block = pos_block if key[0] >= 0 else neg_block + else: + block = pos_block.concat([neg_block], how="inner") + + df = bigframes.dataframe.DataFrame(block) + result = df.loc[key] result = result.set_index(temporary_index_names) result = result.rename_axis(original_index_names) @@ -491,11 +544,6 @@ def _iloc_getitem_series_or_dataframe( result = result.rename(original_series_name) return result - - elif isinstance(key, tuple): - raise NotImplementedError( - f"iloc does not yet support indexing with a (row, column) tuple. {constants.FEEDBACK_LINK}" - ) elif callable(key): raise NotImplementedError( f"iloc does not yet support indexing with a callable. {constants.FEEDBACK_LINK}" diff --git a/bigframes/core/indexes/__init__.py b/bigframes/core/indexes/__init__.py index 0a95adcd83..dfe361aa76 100644 --- a/bigframes/core/indexes/__init__.py +++ b/bigframes/core/indexes/__init__.py @@ -13,9 +13,11 @@ # limitations under the License. from bigframes.core.indexes.base import Index +from bigframes.core.indexes.datetimes import DatetimeIndex from bigframes.core.indexes.multi import MultiIndex __all__ = [ "Index", "MultiIndex", + "DatetimeIndex", ] diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index b3a07d33bc..383534fa4d 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -16,8 +16,9 @@ from __future__ import annotations +import functools import typing -from typing import Hashable, Literal, Optional, Sequence, Union +from typing import cast, Hashable, Literal, Optional, overload, Sequence, Union import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.indexes.base as vendored_pandas_index @@ -25,6 +26,8 @@ import numpy as np import pandas +from bigframes import dtypes +import bigframes.core.agg_expressions as ex_types import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks import bigframes.core.expression as ex @@ -35,9 +38,12 @@ import bigframes.formatting_helpers as formatter import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops +import bigframes.series +import bigframes.session.execution_spec as ex_spec if typing.TYPE_CHECKING: import bigframes.dataframe + import bigframes.operations.strings import bigframes.series @@ -70,9 +76,7 @@ def __new__( elif isinstance(data, series.Series) or isinstance(data, Index): if isinstance(data, series.Series): block = data._block - block = block.set_index( - col_ids=[data._value_column], - ) + block = block.set_index(col_ids=[data._value_column]) elif isinstance(data, Index): block = data._block index = Index(data=block) @@ -87,17 +91,24 @@ def __new__( pd_df = pandas.DataFrame(index=data) block = df.DataFrame(pd_df, session=session)._block else: + if isinstance(dtype, str) and dtype.lower() == "json": + dtype = bigframes.dtypes.JSON_DTYPE pd_index = pandas.Index(data=data, dtype=dtype, name=name) pd_df = pandas.DataFrame(index=pd_index) block = df.DataFrame(pd_df, session=session)._block # TODO: Support more index subtypes - from bigframes.core.indexes.multi import MultiIndex - if len(block._index_columns) <= 1: - klass = cls + if len(block._index_columns) > 1: + from bigframes.core.indexes.multi import MultiIndex + + klass: type[Index] = MultiIndex # type hint to make mypy happy + elif _should_create_datetime_index(block): + from bigframes.core.indexes.datetimes import DatetimeIndex + + klass = DatetimeIndex else: - klass = MultiIndex + klass = cls result = typing.cast(Index, object.__new__(klass)) result._query_job = None @@ -141,12 +152,7 @@ def names(self) -> typing.Sequence[blocks.Label]: @names.setter def names(self, values: typing.Sequence[blocks.Label]): - new_block = self._block.with_index_labels(values) - if self._linked_frame is not None: - self._linked_frame._set_block( - self._linked_frame._block.with_index_labels(values) - ) - self._block = new_block + self.rename(values, inplace=True) @property def nlevels(self) -> int: @@ -166,15 +172,24 @@ def shape(self) -> typing.Tuple[int]: @property def dtype(self): - return self._block.index.dtypes[0] if self.nlevels == 1 else np.dtype("O") + dtype = self._block.index.dtypes[0] if self.nlevels == 1 else np.dtype("O") + bigframes.dtypes.warn_on_db_dtypes_json_dtype([dtype]) + return dtype @property def dtypes(self) -> pandas.Series: + dtypes = self._block.index.dtypes + bigframes.dtypes.warn_on_db_dtypes_json_dtype(dtypes) return pandas.Series( - data=self._block.index.dtypes, + data=dtypes, index=typing.cast(typing.Tuple, self._block.index.names), ) + def __setitem__(self, key, value) -> None: + """Index objects are immutable. Use Index constructor to create + modified Index.""" + raise TypeError("Index does not support mutable operations") + @property def size(self) -> int: return self.shape[0] @@ -228,7 +243,7 @@ def T(self) -> Index: return self.transpose() @property - def query_job(self) -> Optional[bigquery.QueryJob]: + def query_job(self) -> bigquery.QueryJob: """BigQuery job metadata for the most recent query. Returns: @@ -236,10 +251,120 @@ def query_job(self) -> Optional[bigquery.QueryJob]: `_. """ if self._query_job is None: - self._query_job = self._block._compute_dry_run() + _, query_job = self._block._compute_dry_run() + self._query_job = query_job return self._query_job - def __repr__(self) -> str: + @property + def str(self) -> bigframes.operations.strings.StringMethods: + import bigframes.operations.strings + + return bigframes.operations.strings.StringMethods(self) + + def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: + """Get integer location, slice or boolean mask for requested label. + + Args: + key: + The label to search for in the index. + + Returns: + An integer, slice, or boolean mask representing the location(s) of the key. + + Raises: + NotImplementedError: If the index has more than one level. + KeyError: If the key is not found in the index. + """ + if self.nlevels != 1: + raise NotImplementedError("get_loc only supports single-level indexes") + + # Get the index column from the block + index_column = self._block.index_columns[0] + + # Use promote_offsets to get row numbers (similar to argmax/argmin implementation) + block_with_offsets, offsets_id = self._block.promote_offsets( + "temp_get_loc_offsets_" + ) + + # Create expression to find matching positions + match_expr = ops.eq_op.as_expr(ex.deref(index_column), ex.const(key)) + block_with_offsets, match_col_id = block_with_offsets.project_expr(match_expr) + + # Filter to only rows where the key matches + filtered_block = block_with_offsets.filter_by_id(match_col_id) + + # Check if key exists at all by counting + count_agg = ex_types.UnaryAggregation(agg_ops.count_op, ex.deref(offsets_id)) + count_result = filtered_block._expr.aggregate([(count_agg, "count")]) + + count_scalar = ( + self._block.session._executor.execute( + count_result, ex_spec.ExecutionSpec(promise_under_10gb=True) + ) + .batches() + .to_py_scalar() + ) + + if count_scalar == 0: + raise KeyError(f"'{key}' is not in index") + + # If only one match, return integer position + if count_scalar == 1: + min_agg = ex_types.UnaryAggregation(agg_ops.min_op, ex.deref(offsets_id)) + position_result = filtered_block._expr.aggregate([(min_agg, "position")]) + position_scalar = ( + self._block.session._executor.execute( + position_result, ex_spec.ExecutionSpec(promise_under_10gb=True) + ) + .batches() + .to_py_scalar() + ) + return int(position_scalar) + + # Handle multiple matches based on index monotonicity + is_monotonic = self.is_monotonic_increasing or self.is_monotonic_decreasing + if is_monotonic: + return self._get_monotonic_slice(filtered_block, offsets_id) + else: + # Return boolean mask for non-monotonic duplicates + mask_block = block_with_offsets.select_columns([match_col_id]) + mask_block = mask_block.reset_index(drop=True) + result_series = bigframes.series.Series(mask_block) + return result_series.astype("boolean") + + def _get_monotonic_slice( + self, filtered_block, offsets_id: __builtins__.str + ) -> slice: + """Helper method to get a slice for monotonic duplicates with an optimized query.""" + # Combine min and max aggregations into a single query for efficiency + min_max_aggs = [ + ( + ex_types.UnaryAggregation(agg_ops.min_op, ex.deref(offsets_id)), + "min_pos", + ), + ( + ex_types.UnaryAggregation(agg_ops.max_op, ex.deref(offsets_id)), + "max_pos", + ), + ] + combined_result = filtered_block._expr.aggregate(min_max_aggs) + + # Execute query and extract positions + result_df = ( + self._block.session._executor.execute( + combined_result, + execution_spec=ex_spec.ExecutionSpec(promise_under_10gb=True), + ) + .batches() + .to_pandas() + ) + min_pos = int(result_df["min_pos"].iloc[0]) + max_pos = int(result_df["max_pos"].iloc[0]) + + # Create slice (stop is exclusive) + return slice(min_pos, max_pos + 1) + + def __repr__(self) -> __builtins__.str: # Protect against errors with uninitialized Series. See: # https://github.com/googleapis/python-bigquery-dataframes/issues/728 if not hasattr(self, "_block"): @@ -252,7 +377,8 @@ def __repr__(self) -> str: opts = bigframes.options.display max_results = opts.max_rows if opts.repr_mode == "deferred": - return formatter.repr_query_job(self._block._compute_dry_run()) + _, dry_run_query_job = self._block._compute_dry_run() + return formatter.repr_query_job(dry_run_query_job) pandas_df, _, query_job = self._block.retrieve_repr_request_results(max_results) self._query_job = query_job @@ -276,9 +402,16 @@ def to_series( name = self.name if name is None else name if index is None: - return bigframes.series.Series(data=self, index=self, name=name) + return bigframes.series.Series( + data=self, index=self, name=str(name), session=self._session + ) else: - return bigframes.series.Series(data=self, index=Index(index), name=name) + return bigframes.series.Series( + data=self, + index=Index(index, session=self._session), + name=str(name), + session=self._session, + ) def get_level_values(self, level) -> Index: level_n = level if isinstance(level, int) else self.names.index(level) @@ -298,7 +431,13 @@ def _memory_usage(self) -> int: def transpose(self) -> Index: return self - def sort_values(self, *, ascending: bool = True, na_position: str = "last"): + def sort_values( + self, + *, + inplace: bool = False, + ascending: bool = True, + na_position: __builtins__.str = "last", + ) -> Index: if na_position not in ["first", "last"]: raise ValueError("Param na_position must be one of 'first' or 'last'") na_last = na_position == "last" @@ -386,7 +525,7 @@ def value_counts( self._block.index_columns, normalize=normalize, ascending=ascending, - dropna=dropna, + drop_na=dropna, ) import bigframes.series as series @@ -399,11 +538,62 @@ def fillna(self, value=None) -> Index: ops.fillna_op.as_expr(ex.free_var("arg"), ex.const(value)) ) - def rename(self, name: Union[str, Sequence[str]]) -> Index: - names = [name] if isinstance(name, str) else list(name) + @overload + def rename( + self, + name: Union[blocks.Label, Sequence[blocks.Label]], + ) -> Index: + ... + + @overload + def rename( + self, + name: Union[blocks.Label, Sequence[blocks.Label]], + *, + inplace: Literal[False], + ) -> Index: + ... + + @overload + def rename( + self, + name: Union[blocks.Label, Sequence[blocks.Label]], + *, + inplace: Literal[True], + ) -> None: + ... + + def rename( + self, + name: Union[blocks.Label, Sequence[blocks.Label]], + *, + inplace: bool = False, + ) -> Optional[Index]: + # Tuples are allowed as a label, but we specifically exclude them here. + # This is because tuples are hashable, but we want to treat them as a + # sequence. If name is iterable, we want to assume we're working with a + # MultiIndex. Unfortunately, strings are iterable and we don't want a + # list of all the characters, so specifically exclude the non-tuple + # hashables. + if isinstance(name, blocks.Label) and not isinstance(name, tuple): + names = [name] + else: + names = list(name) + if len(names) != self.nlevels: raise ValueError("'name' must be same length as levels") - return Index(self._block.with_index_labels(names)) + + new_block = self._block.with_index_labels(names) + + if inplace: + if self._linked_frame is not None: + self._linked_frame._set_block( + self._linked_frame._block.with_index_labels(names) + ) + self._block = new_block + return None + else: + return Index(new_block) def drop( self, @@ -433,13 +623,21 @@ def dropna(self, how: typing.Literal["all", "any"] = "any") -> Index: result = block_ops.dropna(self._block, self._block.index_columns, how=how) return Index(result) - def drop_duplicates(self, *, keep: str = "first") -> Index: - if keep is not False: - validations.enforce_ordered(self, "drop_duplicates") + def drop_duplicates(self, *, keep: __builtins__.str = "first") -> Index: block = block_ops.drop_duplicates(self._block, self._block.index_columns, keep) return Index(block) + def unique(self, level: Hashable | int | None = None) -> Index: + if level is None: + return self.drop_duplicates() + + return self.get_level_values(level).drop_duplicates() + def isin(self, values) -> Index: + import bigframes.series as series + + if isinstance(values, (series.Series, Index)): + return Index(self.to_series().isin(values)) if not utils.is_list_like(values): raise TypeError( "only list-like objects are allowed to be passed to " @@ -452,6 +650,32 @@ def isin(self, values) -> Index: ) ).fillna(value=False) + def __contains__(self, key) -> bool: + hash(key) # to throw for unhashable values + if self.nlevels == 0: + return False + + if (not isinstance(key, tuple)) or (self.nlevels == 1): + key = (key,) + + match_exprs = [] + for key_part, index_col, dtype in zip( + key, self._block.index_columns, self._block.index.dtypes + ): + key_type = bigframes.dtypes.is_compatible(key_part, dtype) + if key_type is None: + return False + key_expr = ex.const(key_part, key_type) + match_expr = ops.eq_null_match_op.as_expr(ex.deref(index_col), key_expr) + match_exprs.append(match_expr) + + match_expr_final = functools.reduce(ops.and_op.as_expr, match_exprs) + block, match_col = self._block.project_expr(match_expr_final) + return cast(bool, block.get_stat(match_col, agg_ops.AnyOp())) + + def _apply_unary_op(self, op: ops.UnaryOp) -> Index: + return self._apply_unary_expr(op.as_expr(ex.free_var("input"))) + def _apply_unary_expr( self, op: ex.Expression, @@ -490,19 +714,135 @@ def __getitem__(self, key: int) -> typing.Any: else: raise NotImplementedError(f"Index key not supported {key}") - def to_pandas(self) -> pandas.Index: + @overload + def to_pandas( # type: ignore[overload-overlap] + self, + *, + allow_large_results: Optional[bool] = ..., + dry_run: Literal[False] = ..., + ) -> pandas.Index: + ... + + @overload + def to_pandas( + self, *, allow_large_results: Optional[bool] = ..., dry_run: Literal[True] = ... + ) -> pandas.Series: + ... + + def to_pandas( + self, + *, + allow_large_results: Optional[bool] = None, + dry_run: bool = False, + ) -> pandas.Index | pandas.Series: """Gets the Index as a pandas Index. + Args: + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large query results + over the default size limit of 10 GB. + dry_run (bool, default False): + If this argument is true, this method will not process the data. Instead, it returns + a Pandas series containing dtype and the amount of bytes to be processed. + Returns: - pandas.Index: - A pandas Index with all of the labels from this Index. + pandas.Index | pandas.Series: + A pandas Index with all of the labels from this Index. If dry run is set to True, + returns a Series containing dry run statistics. """ - return self._block.index.to_pandas(ordered=True) + if dry_run: + dry_run_stats, dry_run_job = self._block.index._compute_dry_run( + ordered=True + ) + self._query_job = dry_run_job + return dry_run_stats + + df, query_job = self._block.index.to_pandas( + ordered=True, allow_large_results=allow_large_results + ) + if query_job: + self._query_job = query_job + return df - def to_numpy(self, dtype=None, **kwargs) -> np.ndarray: - return self.to_pandas().to_numpy(dtype, **kwargs) + def to_numpy(self, dtype=None, *, allow_large_results=None, **kwargs) -> np.ndarray: + return self.to_pandas(allow_large_results=allow_large_results).to_numpy( + dtype, **kwargs + ) __array__ = to_numpy + def to_list(self, *, allow_large_results: Optional[bool] = None) -> list: + return self.to_pandas(allow_large_results=allow_large_results).to_list() + def __len__(self): return self.shape[0] + + def item(self): + # Docstring is in third_party/bigframes_vendored/pandas/core/indexes/base.py + return self.to_series().peek(2).item() + + def __eq__(self, other) -> Index: # type: ignore + return self._apply_binary_op(other, ops.eq_op) + + def _apply_binary_op( + self, + other, + op: ops.BinaryOp, + alignment: typing.Literal["outer", "left"] = "outer", + ) -> Index: + # Note: alignment arg is for compatibility with accessors, is ignored as irrelevant for implicit joins. + # TODO: Handle local objects, or objects not implicitly alignable? Gets ambiguous with partial ordering though + if isinstance(other, (bigframes.series.Series, Index)): + other = Index(other) + if other.nlevels != self.nlevels: + raise ValueError("Dimensions do not match") + + lexpr = self._block.expr + rexpr = other._block.expr + join_result = lexpr.try_row_join(rexpr) + if join_result is None: + raise ValueError("Cannot align objects") + + expr, (lmap, rmap) = join_result + + expr, res_ids = expr.compute_values( + [ + op.as_expr(lmap[lid], rmap[rid]) + for lid, rid in zip(lexpr.column_ids, rexpr.column_ids) + ] + ) + labels = self.names if self.names == other.names else [None] * len(res_ids) + return Index( + blocks.Block( + expr.select_columns(res_ids), + index_columns=res_ids, + column_labels=[], + index_labels=labels, + ) + ) + elif ( + isinstance(other, bigframes.dtypes.LOCAL_SCALAR_TYPES) and self.nlevels == 1 + ): + block, id = self._block.project_expr( + op.as_expr(self._block.index_columns[0], ex.const(other)) + ) + return Index(block.set_index([id], index_labels=self.names)) + elif isinstance(other, tuple) and len(other) == self.nlevels: + block = self._block.project_exprs( + [ + op.as_expr(self._block.index_columns[i], ex.const(other[i])) + for i in range(self.nlevels) + ], + labels=[None] * self.nlevels, + drop=True, + ) + return Index(block.set_index(block.value_columns, index_labels=self.names)) + else: + return NotImplemented + + +def _should_create_datetime_index(block: blocks.Block) -> bool: + if len(block.index.dtypes) != 1: + return False + + return dtypes.is_datetime_like(block.index.dtypes[0]) diff --git a/bigframes/core/indexes/datetimes.py b/bigframes/core/indexes/datetimes.py new file mode 100644 index 0000000000..23ad8b03b4 --- /dev/null +++ b/bigframes/core/indexes/datetimes.py @@ -0,0 +1,56 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""An index based on a single column with a datetime-like data type.""" + +from __future__ import annotations + +from bigframes_vendored.pandas.core.indexes import ( + datetimes as vendored_pandas_datetime_index, +) + +from bigframes.core import expression as ex +from bigframes.core.indexes.base import Index +from bigframes.operations import date_ops + + +class DatetimeIndex(Index, vendored_pandas_datetime_index.DatetimeIndex): + __doc__ = vendored_pandas_datetime_index.DatetimeIndex.__doc__ + + # Must be above 5000 for pandas to delegate to bigframes for binops + __pandas_priority__ = 12000 + + @property + def year(self) -> Index: + return self._apply_unary_expr(date_ops.year_op.as_expr(ex.free_var("arg"))) + + @property + def month(self) -> Index: + return self._apply_unary_expr(date_ops.month_op.as_expr(ex.free_var("arg"))) + + @property + def day(self) -> Index: + return self._apply_unary_expr(date_ops.day_op.as_expr(ex.free_var("arg"))) + + @property + def dayofweek(self) -> Index: + return self._apply_unary_expr(date_ops.dayofweek_op.as_expr(ex.free_var("arg"))) + + @property + def day_of_week(self) -> Index: + return self.dayofweek + + @property + def weekday(self) -> Index: + return self.dayofweek diff --git a/bigframes/core/indexes/multi.py b/bigframes/core/indexes/multi.py index 182d1f101c..cfabd9e70d 100644 --- a/bigframes/core/indexes/multi.py +++ b/bigframes/core/indexes/multi.py @@ -14,13 +14,18 @@ from __future__ import annotations -from typing import cast, Hashable, Iterable, Sequence +from typing import cast, Hashable, Iterable, Optional, Sequence, TYPE_CHECKING import bigframes_vendored.pandas.core.indexes.multi as vendored_pandas_multindex import pandas +from bigframes.core import blocks +from bigframes.core import expression as ex from bigframes.core.indexes.base import Index +if TYPE_CHECKING: + import bigframes.session + class MultiIndex(Index, vendored_pandas_multindex.MultiIndex): __doc__ = vendored_pandas_multindex.MultiIndex.__doc__ @@ -31,10 +36,12 @@ def from_tuples( tuples: Iterable[tuple[Hashable, ...]], sortorder: int | None = None, names: Sequence[Hashable] | Hashable | None = None, + *, + session: Optional[bigframes.session.Session] = None, ) -> MultiIndex: pd_index = pandas.MultiIndex.from_tuples(tuples, sortorder, names) # Index.__new__ should detect multiple levels and properly create a multiindex - return cast(MultiIndex, Index(pd_index)) + return cast(MultiIndex, Index(pd_index, session=session)) @classmethod def from_arrays( @@ -42,7 +49,67 @@ def from_arrays( arrays, sortorder: int | None = None, names=None, + *, + session: Optional[bigframes.session.Session] = None, ) -> MultiIndex: pd_index = pandas.MultiIndex.from_arrays(arrays, sortorder, names) # Index.__new__ should detect multiple levels and properly create a multiindex - return cast(MultiIndex, Index(pd_index)) + return cast(MultiIndex, Index(pd_index, session=session)) + + def __eq__(self, other) -> Index: # type: ignore + import bigframes.operations as ops + import bigframes.operations.aggregations as agg_ops + + eq_result = self._apply_binary_op(other, ops.eq_op)._block.expr + + as_array = ops.ToArrayOp().as_expr( + *( + ops.fillna_op.as_expr(col, ex.const(False)) + for col in eq_result.column_ids + ) + ) + reduced = ops.ArrayReduceOp(agg_ops.all_op).as_expr(as_array) + result_expr, result_ids = eq_result.compute_values([reduced]) + return Index( + blocks.Block( + result_expr.select_columns(result_ids), + index_columns=result_ids, + column_labels=(), + index_labels=[None], + ) + ) + + +class MultiIndexAccessor: + """Proxy to MultiIndex constructors to allow a session to be passed in.""" + + def __init__(self, session: bigframes.session.Session): + self._session = session + + def __call__(self, *args, **kwargs) -> MultiIndex: + """Construct a MultiIndex using the associated Session. + + See :class:`bigframes.pandas.MultiIndex`. + """ + return MultiIndex(*args, session=self._session, **kwargs) + + def from_arrays(self, *args, **kwargs) -> MultiIndex: + """Construct a MultiIndex using the associated Session. + + See :func:`bigframes.pandas.MultiIndex.from_arrays`. + """ + return MultiIndex.from_arrays(*args, session=self._session, **kwargs) + + def from_frame(self, *args, **kwargs) -> MultiIndex: + """Construct a MultiIndex using the associated Session. + + See :func:`bigframes.pandas.MultiIndex.from_frame`. + """ + return cast(MultiIndex, MultiIndex.from_frame(*args, **kwargs)) + + def from_tuples(self, *args, **kwargs) -> MultiIndex: + """Construct a MultiIndex using the associated Session. + + See :func:`bigframes.pandas.MultiIndex.from_tuples`. + """ + return MultiIndex.from_tuples(*args, session=self._session, **kwargs) diff --git a/bigframes/core/interchange.py b/bigframes/core/interchange.py new file mode 100644 index 0000000000..f6f0bdd103 --- /dev/null +++ b/bigframes/core/interchange.py @@ -0,0 +1,155 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import dataclasses +import functools +from typing import Any, Dict, Iterable, Optional, Sequence, TYPE_CHECKING + +from bigframes.core import blocks +import bigframes.enums + +if TYPE_CHECKING: + import bigframes.dataframe + + +@dataclasses.dataclass(frozen=True) +class InterchangeColumn: + _dataframe: InterchangeDataFrame + _pos: int + + @functools.cache + def _arrow_column(self): + # Conservatively downloads the whole underlying dataframe + # This is much better if multiple columns end up being used, + # but does incur a lot of overhead otherwise. + return self._dataframe._arrow_dataframe().get_column(self._pos) + + def size(self) -> int: + return self._arrow_column().size() + + @property + def offset(self) -> int: + return self._arrow_column().offset + + @property + def dtype(self): + return self._arrow_column().dtype + + @property + def describe_categorical(self): + raise TypeError(f"Column type {self.dtype} is not categorical") + + @property + def describe_null(self): + return self._arrow_column().describe_null + + @property + def null_count(self): + return self._arrow_column().null_count + + @property + def metadata(self) -> Dict[str, Any]: + return self._arrow_column().metadata + + def num_chunks(self) -> int: + return self._arrow_column().num_chunks() + + def get_chunks(self, n_chunks: Optional[int] = None) -> Iterable: + return self._arrow_column().get_chunks(n_chunks=n_chunks) + + def get_buffers(self): + return self._arrow_column().get_buffers() + + +@dataclasses.dataclass(frozen=True) +class InterchangeDataFrame: + """ + Implements the dataframe interchange format. + + Mostly implemented by downloading result to pyarrow, and using pyarrow interchange implementation. + """ + + _value: blocks.Block + + version: int = 0 # version of the protocol + + def __dataframe__( + self, nan_as_null: bool = False, allow_copy: bool = True + ) -> InterchangeDataFrame: + return self + + @classmethod + def _from_bigframes(cls, df: bigframes.dataframe.DataFrame): + block = df._block.with_column_labels( + [str(label) for label in df._block.column_labels] + ) + return cls(block) + + # In future, could potentially rely on executor to refetch batches efficiently with caching, + # but safest for now to just request a single execution and save the whole table. + @functools.cache + def _arrow_dataframe(self): + arrow_table, _ = self._value.reset_index( + replacement=bigframes.enums.DefaultIndexKind.NULL + ).to_arrow(allow_large_results=False) + return arrow_table.__dataframe__() + + @property + def metadata(self): + # Allows round-trip without materialization + return {"bigframes.block": self._value} + + def num_columns(self) -> int: + """ + Return the number of columns in the DataFrame. + """ + return len(self._value.value_columns) + + def num_rows(self) -> Optional[int]: + return self._value.shape[0] + + def num_chunks(self) -> int: + return self._arrow_dataframe().num_chunks() + + def column_names(self) -> Iterable[str]: + return [col for col in self._value.column_labels] + + def get_column(self, i: int) -> InterchangeColumn: + return InterchangeColumn(self, i) + + # For single column getters, we download the whole dataframe still + # This is inefficient in some cases, but more efficient in other + def get_column_by_name(self, name: str) -> InterchangeColumn: + col_id = self._value.resolve_label_exact(name) + assert col_id is not None + pos = self._value.value_columns.index(col_id) + return InterchangeColumn(self, pos) + + def get_columns(self) -> Iterable[InterchangeColumn]: + return [InterchangeColumn(self, i) for i in range(self.num_columns())] + + def select_columns(self, indices: Sequence[int]) -> InterchangeDataFrame: + col_ids = [self._value.value_columns[i] for i in indices] + new_value = self._value.select_columns(col_ids) + return InterchangeDataFrame(new_value) + + def select_columns_by_name(self, names: Sequence[str]) -> InterchangeDataFrame: + col_ids = [self._value.resolve_label_exact(name) for name in names] + assert all(id is not None for id in col_ids) + new_value = self._value.select_columns(col_ids) # type: ignore + return InterchangeDataFrame(new_value) + + def get_chunks(self, n_chunks: Optional[int] = None) -> Iterable: + return self._arrow_dataframe().get_chunks(n_chunks) diff --git a/bigframes/core/local_data.py b/bigframes/core/local_data.py index d891e385d5..ef7374a5a4 100644 --- a/bigframes/core/local_data.py +++ b/bigframes/core/local_data.py @@ -16,42 +16,428 @@ from __future__ import annotations +import dataclasses +import functools +import io +import itertools +import json +from typing import Any, Callable, cast, Generator, Iterable, Literal, Optional, Union +import uuid + +import geopandas # type: ignore +import numpy as np +import pandas as pd import pyarrow as pa +import pyarrow.parquet # type: ignore +from bigframes.core import pyarrow_utils import bigframes.core.schema as schemata import bigframes.dtypes -def arrow_schema_to_bigframes(arrow_schema: pa.Schema) -> schemata.ArraySchema: - """Infer the corresponding bigframes schema given a pyarrow schema.""" - schema_items = tuple( - schemata.SchemaItem( - field.name, - bigframes_type_for_arrow_type(field.type), +@dataclasses.dataclass(frozen=True) +class LocalTableMetadata: + total_bytes: int + row_count: int + + @classmethod + def from_arrow(cls, table: pa.Table) -> LocalTableMetadata: + return cls(total_bytes=table.nbytes, row_count=table.num_rows) + + +_MANAGED_STORAGE_TYPES_OVERRIDES: dict[bigframes.dtypes.Dtype, pa.DataType] = { + # wkt to be precise + bigframes.dtypes.GEO_DTYPE: pa.string(), + # Just json as string + bigframes.dtypes.JSON_DTYPE: pa.string(), +} + + +@dataclasses.dataclass(frozen=True) +class ManagedArrowTable: + data: pa.Table = dataclasses.field(hash=False, compare=False) + schema: schemata.ArraySchema = dataclasses.field(hash=False, compare=False) + id: uuid.UUID = dataclasses.field(default_factory=uuid.uuid4) + + @functools.cached_property + def metadata(self) -> LocalTableMetadata: + return LocalTableMetadata.from_arrow(self.data) + + @classmethod + def from_pandas(cls, dataframe: pd.DataFrame) -> ManagedArrowTable: + """Creates managed table from pandas. Ignores index, col names must be unique strings""" + columns: list[pa.ChunkedArray] = [] + fields: list[schemata.SchemaItem] = [] + column_names = list(dataframe.columns) + assert len(column_names) == len(set(column_names)) + + for name, col in dataframe.items(): + new_arr, bf_type = _adapt_pandas_series(col) + columns.append(new_arr) + fields.append(schemata.SchemaItem(str(name), bf_type)) + + mat = ManagedArrowTable( + pa.table(columns, names=column_names), schemata.ArraySchema(tuple(fields)) + ) + mat.validate() + return mat + + @classmethod + def from_pyarrow( + cls, table: pa.Table, schema: Optional[schemata.ArraySchema] = None + ) -> ManagedArrowTable: + if schema is not None: + pa_fields = [] + for item in schema.items: + pa_type = _get_managed_storage_type(item.dtype) + pa_fields.append( + pyarrow.field( + item.column, + pa_type, + nullable=not pyarrow.types.is_list(pa_type), + ) + ) + pa_schema = pyarrow.schema(pa_fields) + # assumption: needed transformations can be handled by simple cast. + mat = ManagedArrowTable(table.cast(pa_schema), schema) + mat.validate() + return mat + else: # infer bigframes schema + columns: list[pa.ChunkedArray] = [] + fields: list[schemata.SchemaItem] = [] + for name, arr in zip(table.column_names, table.columns): + new_arr, bf_type = _adapt_chunked_array(arr) + columns.append(new_arr) + fields.append(schemata.SchemaItem(name, bf_type)) + + mat = ManagedArrowTable( + pa.table(columns, names=table.column_names), + schemata.ArraySchema(tuple(fields)), + ) + mat.validate() + return mat + + def to_arrow( + self, + *, + offsets_col: Optional[str] = None, + geo_format: Literal["wkb", "wkt"] = "wkt", + duration_type: Literal["int", "duration"] = "duration", + json_type: Literal["string"] = "string", + max_chunksize: Optional[int] = None, + ) -> tuple[pa.Schema, Iterable[pa.RecordBatch]]: + if geo_format != "wkt": + raise NotImplementedError(f"geo format {geo_format} not yet implemented") + assert json_type == "string" + + batches = self.data.to_batches(max_chunksize=max_chunksize) + schema = self.data.schema + if duration_type == "int": + schema = _schema_durations_to_ints(schema) + batches = map( + functools.partial(pyarrow_utils.cast_batch, schema=schema), batches + ) + + if offsets_col is not None: + return schema.append(pa.field(offsets_col, pa.int64())), _append_offsets( + batches, offsets_col + ) + else: + return schema, batches + + def to_pyarrow_table( + self, + *, + offsets_col: Optional[str] = None, + geo_format: Literal["wkb", "wkt"] = "wkt", + duration_type: Literal["int", "duration"] = "duration", + json_type: Literal["string"] = "string", + ) -> pa.Table: + schema, batches = self.to_arrow( + offsets_col=offsets_col, + geo_format=geo_format, + duration_type=duration_type, + json_type=json_type, + ) + return pa.Table.from_batches(batches, schema) + + def to_parquet( + self, + dst: Union[str, io.IOBase], + *, + offsets_col: Optional[str] = None, + geo_format: Literal["wkb", "wkt"] = "wkt", + duration_type: Literal["int", "duration"] = "duration", + json_type: Literal["string"] = "string", + ): + pa_table = self.to_pyarrow_table( + offsets_col=offsets_col, + geo_format=geo_format, + duration_type=duration_type, + json_type=json_type, + ) + pyarrow.parquet.write_table(pa_table, where=dst) + + def itertuples( + self, + *, + geo_format: Literal["wkb", "wkt"] = "wkt", + duration_type: Literal["int", "timedelta"] = "timedelta", + json_type: Literal["string", "object"] = "string", + ) -> Iterable[tuple]: + """ + Yield each row as an unlabeled tuple. + + Row-wise iteration of columnar data is slow, avoid if possible. + """ + for row_dict in _iter_table( + self.data, + self.schema, + geo_format=geo_format, + duration_type=duration_type, + json_type=json_type, + ): + yield tuple(row_dict.values()) + + def validate(self): + for bf_field, arrow_field in zip(self.schema.items, self.data.schema): + expected_arrow_type = _get_managed_storage_type(bf_field.dtype) + arrow_type = arrow_field.type + if expected_arrow_type != arrow_type: + raise TypeError( + f"Field {bf_field} has arrow array type: {arrow_type}, expected type: {expected_arrow_type}" + ) + + +# Sequential iterator, but could split into batches and leverage parallelism for speed +def _iter_table( + table: pa.Table, + schema: schemata.ArraySchema, + *, + geo_format: Literal["wkb", "wkt"] = "wkt", + duration_type: Literal["int", "timedelta"] = "timedelta", + json_type: Literal["string", "object"] = "string", +) -> Generator[dict[str, Any], None, None]: + """For when you feel like iterating row-wise over a column store. Don't expect speed.""" + + if geo_format != "wkt": + raise NotImplementedError(f"geo format {geo_format} not yet implemented") + + @functools.singledispatch + def iter_array( + array: pa.Array, dtype: bigframes.dtypes.Dtype + ) -> Generator[Any, None, None]: + values = array.to_pylist() + if dtype == bigframes.dtypes.JSON_DTYPE: + if json_type == "object": + yield from map(lambda x: json.loads(x) if x is not None else x, values) + else: + yield from values + elif dtype == bigframes.dtypes.TIMEDELTA_DTYPE: + if duration_type == "int": + yield from map( + lambda x: ((x.days * 3600 * 24) + x.seconds) * 1_000_000 + + x.microseconds + if x is not None + else x, + values, + ) + else: + yield from values + else: + yield from values + + @iter_array.register + def _( + array: pa.ListArray, dtype: bigframes.dtypes.Dtype + ) -> Generator[Any, None, None]: + value_generator = iter_array( + array.flatten(), bigframes.dtypes.get_array_inner_type(dtype) + ) + offset_generator = iter_array(array.offsets, bigframes.dtypes.INT_DTYPE) + + start_offset = None + end_offset = None + for offset in offset_generator: + start_offset = end_offset + end_offset = offset + if start_offset is not None: + arr_size = end_offset - start_offset + yield list(itertools.islice(value_generator, arr_size)) + + @iter_array.register + def _( + array: pa.StructArray, dtype: bigframes.dtypes.Dtype + ) -> Generator[Any, None, None]: + # yield from each subarray + sub_generators: dict[str, Generator[Any, None, None]] = {} + for field_name, dtype in bigframes.dtypes.get_struct_fields(dtype).items(): + sub_generators[field_name] = iter_array(array.field(field_name), dtype) + + keys = list(sub_generators.keys()) + is_null_generator = iter_array(array.is_null(), bigframes.dtypes.BOOL_DTYPE) + + for values in zip(is_null_generator, *sub_generators.values()): + is_row_null = values[0] + row_values = values[1:] + if not is_row_null: + yield {key: value for key, value in zip(keys, row_values)} + else: + yield None + + for batch in table.to_batches(): + sub_generators: dict[str, Generator[Any, None, None]] = {} + for field in schema.items: + sub_generators[field.column] = iter_array( + batch.column(field.column), field.dtype + ) + + keys = list(sub_generators.keys()) + for row_values in zip(*sub_generators.values()): + yield {key: value for key, value in zip(keys, row_values)} + + +def _adapt_pandas_series( + series: pd.Series, +) -> tuple[Union[pa.ChunkedArray, pa.Array], bigframes.dtypes.Dtype]: + # Mostly rely on pyarrow conversions, but have to convert geo without its help. + if series.dtype == bigframes.dtypes.GEO_DTYPE: + # geoseries produces eg "POINT (1, 1)", while bq uses style "POINT(1, 1)" + # we normalize to bq style for consistency + series = ( + geopandas.GeoSeries(series) + .to_wkt(rounding_precision=-1) + .str.replace(r"(\w+) \(", repl=r"\1(", regex=True) + ) + return pa.array(series, type=pa.string()), bigframes.dtypes.GEO_DTYPE + try: + pa_arr = pa.array(series) + if isinstance(pa_arr, pa.ChunkedArray): + return _adapt_chunked_array(pa_arr) + return _adapt_arrow_array(pa_arr) + except pa.ArrowInvalid as e: + if series.dtype == np.dtype("O"): + try: + return _adapt_pandas_series(series.astype(bigframes.dtypes.GEO_DTYPE)) + except TypeError: + # Prefer original error + pass + raise e + + +def _adapt_chunked_array( + chunked_array: pa.ChunkedArray, +) -> tuple[pa.ChunkedArray, bigframes.dtypes.Dtype]: + if len(chunked_array.chunks) == 0: + return _adapt_arrow_array(chunked_array.combine_chunks()) + dtype = None + arrays = [] + for chunk in chunked_array.chunks: + array, arr_dtype = _adapt_arrow_array(chunk) + arrays.append(array) + dtype = dtype or arr_dtype + assert dtype is not None + return pa.chunked_array(arrays), dtype + + +def _adapt_arrow_array(array: pa.Array) -> tuple[pa.Array, bigframes.dtypes.Dtype]: + """Normalize the array to managed storage types. Preserve shapes, only transforms values.""" + if array.offset != 0: # Offset arrays don't have all operations implemented + return _adapt_arrow_array(pa.concat_arrays([array])) + + if pa.types.is_struct(array.type): + assert isinstance(array, pa.StructArray) + assert isinstance(array.type, pa.StructType) + arrays = [] + dtypes = [] + pa_fields = [] + for i in range(array.type.num_fields): + field_array, field_type = _adapt_arrow_array(array.field(i)) + arrays.append(field_array) + dtypes.append(field_type) + pa_fields.append(pa.field(array.type.field(i).name, field_array.type)) + struct_array = pa.StructArray.from_arrays( + arrays=arrays, fields=pa_fields, mask=array.is_null() ) - for field in arrow_schema + dtype = bigframes.dtypes.struct_type( + [(field.name, dtype) for field, dtype in zip(pa_fields, dtypes)] + ) + return struct_array, dtype + if pa.types.is_list(array.type): + assert isinstance(array, pa.ListArray) + values, values_type = _adapt_arrow_array(array.values) + new_value = pa.ListArray.from_arrays( + array.offsets, values, mask=array.is_null() + ) + return new_value.fill_null([]), bigframes.dtypes.list_type(values_type) + if array.type == bigframes.dtypes.JSON_ARROW_TYPE: + return _canonicalize_json(array), bigframes.dtypes.JSON_DTYPE + target_type = logical_type_replacements(array.type) + if target_type != array.type: + # TODO: Maybe warn if lossy conversion? + array = array.cast(target_type) + bf_type = bigframes.dtypes.arrow_dtype_to_bigframes_dtype( + target_type, allow_lossless_cast=True ) - return schemata.ArraySchema(schema_items) + storage_type = _get_managed_storage_type(bf_type) + if storage_type != array.type: + array = array.cast(storage_type) + return array, bf_type + + +def _canonicalize_json(array: pa.Array) -> pa.Array: + def _canonicalize_scalar(json_string): + if json_string is None: + return None + # This is the canonical form that bq uses when emitting json + # The sorted keys and unambiguous whitespace ensures a 1:1 mapping + # between syntax and semantics. + return json.dumps( + json.loads(json_string), sort_keys=True, separators=(",", ":") + ) -def adapt_pa_table(arrow_table: pa.Table) -> pa.Table: - """Adapt a pyarrow table to one that can be handled by bigframes. Converts tz to UTC and unit to us for temporal types.""" - new_schema = pa.schema( - [ - pa.field(field.name, arrow_type_replacements(field.type)) - for field in arrow_table.schema - ] + return pa.array( + [_canonicalize_scalar(value) for value in array.to_pylist()], type=pa.string() ) - return arrow_table.cast(new_schema) -def bigframes_type_for_arrow_type(pa_type: pa.DataType) -> bigframes.dtypes.Dtype: - return bigframes.dtypes.arrow_dtype_to_bigframes_dtype( - arrow_type_replacements(pa_type) +def _get_managed_storage_type(dtype: bigframes.dtypes.Dtype) -> pa.DataType: + if dtype in _MANAGED_STORAGE_TYPES_OVERRIDES.keys(): + return _MANAGED_STORAGE_TYPES_OVERRIDES[dtype] + return _physical_type_replacements( + bigframes.dtypes.bigframes_dtype_to_arrow_dtype(dtype) ) -def arrow_type_replacements(type: pa.DataType) -> pa.DataType: +def _recursive_map_types( + f: Callable[[pa.DataType], pa.DataType] +) -> Callable[[pa.DataType], pa.DataType]: + @functools.wraps(f) + def recursive_f(type: pa.DataType) -> pa.DataType: + if pa.types.is_list(type): + new_field_t = recursive_f(type.value_type) + if new_field_t != type.value_type: + return pa.list_(new_field_t) + return type + # polars can produce large lists, and we want to map these down to regular lists + if pa.types.is_large_list(type): + new_field_t = recursive_f(type.value_type) + return pa.list_(new_field_t) + if pa.types.is_struct(type): + struct_type = cast(pa.StructType, type) + new_fields: list[pa.Field] = [] + for i in range(struct_type.num_fields): + field = struct_type.field(i) + new_fields.append(field.with_type(recursive_f(field.type))) + return pa.struct(new_fields) + return f(type) + + return recursive_f + + +@_recursive_map_types +def logical_type_replacements(type: pa.DataType) -> pa.DataType: if pa.types.is_timestamp(type): # This is potentially lossy, but BigFrames doesn't support ns new_tz = "UTC" if (type.tz is not None) else None @@ -66,10 +452,59 @@ def arrow_type_replacements(type: pa.DataType) -> pa.DataType: return pa.decimal128(38, 9) if pa.types.is_decimal256(type): return pa.decimal256(76, 38) - if pa.types.is_dictionary(type): - return arrow_type_replacements(type.value_type) if pa.types.is_large_string(type): # simple string type can handle the largest strings needed return pa.string() + if pa.types.is_large_binary(type): + # simple string type can handle the largest strings needed + return pa.binary() + if pa.types.is_dictionary(type): + return logical_type_replacements(type.value_type) + if pa.types.is_null(type): + # null as a type not allowed, default type is float64 for bigframes + return pa.float64() else: return type + + +_ARROW_MANAGED_STORAGE_OVERRIDES = { + bigframes.dtypes._BIGFRAMES_TO_ARROW[bf_dtype]: arrow_type + for bf_dtype, arrow_type in _MANAGED_STORAGE_TYPES_OVERRIDES.items() + if bf_dtype in bigframes.dtypes._BIGFRAMES_TO_ARROW +} + + +@_recursive_map_types +def _physical_type_replacements(dtype: pa.DataType) -> pa.DataType: + if dtype in _ARROW_MANAGED_STORAGE_OVERRIDES: + return _ARROW_MANAGED_STORAGE_OVERRIDES[dtype] + return dtype + + +def _append_offsets( + batches: Iterable[pa.RecordBatch], offsets_col_name: str +) -> Iterable[pa.RecordBatch]: + offset = 0 + for batch in batches: + offsets = pa.array( + range(offset, offset + batch.num_rows), size=batch.num_rows, type=pa.int64() + ) + batch_w_offsets = pa.record_batch( + [*batch.columns, offsets], + schema=batch.schema.append(pa.field(offsets_col_name, pa.int64())), + ) + offset += batch.num_rows + yield batch_w_offsets + + +@_recursive_map_types +def _durations_to_ints(type: pa.DataType) -> pa.DataType: + if pa.types.is_duration(type): + return pa.int64() + return type + + +def _schema_durations_to_ints(schema: pa.Schema) -> pa.Schema: + return pa.schema( + pa.field(field.name, _durations_to_ints(field.type)) for field in schema + ) diff --git a/bigframes/core/log_adapter.py b/bigframes/core/log_adapter.py index 714a522183..77c09437c0 100644 --- a/bigframes/core/log_adapter.py +++ b/bigframes/core/log_adapter.py @@ -15,7 +15,7 @@ import functools import inspect import threading -from typing import List +from typing import List, Optional from google.cloud import bigquery import pandas @@ -28,6 +28,7 @@ MAX_LABELS_COUNT = 64 - 8 PANDAS_API_TRACKING_TASK = "pandas_api_tracking" PANDAS_PARAM_TRACKING_TASK = "pandas_param_tracking" +LOG_OVERRIDE_NAME = "__log_override_name__" _api_methods: List = [] _excluded_methods = ["__setattr__", "__getattr__"] @@ -37,8 +38,8 @@ def submit_pandas_labels( - bq_client: bigquery.Client, - class_name: str, + bq_client: Optional[bigquery.Client], + base_name: str, method_name: str, args=(), kwargs={}, @@ -54,7 +55,7 @@ def submit_pandas_labels( Args: bq_client (bigquery.Client): The client used to interact with BigQuery. - class_name (str): The name of the pandas class being used. + base_name (str): The name of the pandas class/module being used. method_name (str): The name of the method being invoked. args (tuple): The positional arguments passed to the method. kwargs (dict): The keyword arguments passed to the method. @@ -63,25 +64,29 @@ def submit_pandas_labels( - 'PANDAS_PARAM_TRACKING_TASK': Indicates that the unimplemented feature is a parameter of a method. """ - if method_name.startswith("_") and not method_name.startswith("__"): + if bq_client is None or ( + method_name.startswith("_") and not method_name.startswith("__") + ): return labels_dict = { "task": task, - "class_name": class_name.lower(), + "class_name": base_name.lower(), "method_name": method_name.lower(), "args_count": len(args), } - if hasattr(pandas, class_name): - cls = getattr(pandas, class_name) + # getattr(pandas, "pandas") returns pandas + # so we can also use this for pandas.function + if hasattr(pandas, base_name): + base = getattr(pandas, base_name) else: return # Omit __call__, because its not implemented on the actual instances of # DataFrame/Series, only as the constructor. - if method_name != "__call__" and hasattr(cls, method_name): - method = getattr(cls, method_name) + if method_name != "__call__" and hasattr(base, method_name): + method = getattr(base, method_name) else: return @@ -110,79 +115,118 @@ def submit_pandas_labels( bq_client.query(query, job_config=job_config) -def class_logger(decorated_cls): +def class_logger(decorated_cls=None): """Decorator that adds logging functionality to each method of the class.""" - for attr_name, attr_value in decorated_cls.__dict__.items(): - if callable(attr_value) and (attr_name not in _excluded_methods): - if isinstance(attr_value, staticmethod): - # TODO(b/390244171) support for staticmethod - pass - else: + + def wrap(cls): + for attr_name, attr_value in cls.__dict__.items(): + if callable(attr_value) and (attr_name not in _excluded_methods): + if isinstance(attr_value, staticmethod): + setattr( + cls, + attr_name, + staticmethod(method_logger(attr_value)), + ) + else: + setattr( + cls, + attr_name, + method_logger(attr_value), + ) + elif isinstance(attr_value, property): setattr( - decorated_cls, attr_name, method_logger(attr_value, decorated_cls) + cls, + attr_name, + property_logger(attr_value), ) - elif isinstance(attr_value, property): - setattr( - decorated_cls, attr_name, property_logger(attr_value, decorated_cls) - ) - return decorated_cls + return cls + + if decorated_cls is None: + # The logger is used with parentheses + return wrap + # The logger is used without parentheses + return wrap(decorated_cls) -def method_logger(method, decorated_cls): + +def method_logger(method=None, /, *, custom_base_name: Optional[str] = None): """Decorator that adds logging functionality to a method.""" - @functools.wraps(method) - def wrapper(self, *args, **kwargs): - class_name = decorated_cls.__name__ # Access decorated class name - api_method_name = str(method.__name__) - full_method_name = f"{class_name.lower()}-{api_method_name}" - - # Track directly called methods - if len(_call_stack) == 0: - add_api_method(full_method_name) - - _call_stack.append(full_method_name) - - try: - return method(self, *args, **kwargs) - except (NotImplementedError, TypeError) as e: - # Log method parameters that are implemented in pandas but either missing (TypeError) - # or not fully supported (NotImplementedError) in BigFrames. - # Logging is currently supported only when we can access the bqclient through - # self._block.expr.session.bqclient. Also, to avoid generating multiple queries - # because of internal calls, we log only when the method is directly invoked. - if hasattr(self, "_block") and len(_call_stack) == 1: - submit_pandas_labels( - self._block.expr.session.bqclient, - class_name, - api_method_name, - args, - kwargs, - task=PANDAS_PARAM_TRACKING_TASK, + def outer_wrapper(method): + @functools.wraps(method) + def wrapper(*args, **kwargs): + api_method_name = getattr( + method, LOG_OVERRIDE_NAME, method.__name__ + ).lower() + if custom_base_name is None: + qualname_parts = getattr(method, "__qualname__", method.__name__).split( + "." + ) + class_name = qualname_parts[-2] if len(qualname_parts) > 1 else "" + base_name = ( + class_name + if class_name + else "_".join(method.__module__.split(".")[1:]) ) - raise e - finally: - _call_stack.pop() + else: + base_name = custom_base_name - return wrapper + full_method_name = f"{base_name.lower()}-{api_method_name}" + # Track directly called methods + if len(_call_stack) == 0: + session = _find_session(*args, **kwargs) + add_api_method(full_method_name, session=session) + _call_stack.append(full_method_name) -def property_logger(prop, decorated_cls): + try: + return method(*args, **kwargs) + except (NotImplementedError, TypeError) as e: + # Log method parameters that are implemented in pandas but either missing (TypeError) + # or not fully supported (NotImplementedError) in BigFrames. + # Logging is currently supported only when we can access the bqclient through + # _block.session.bqclient. + if len(_call_stack) == 1: + submit_pandas_labels( + _get_bq_client(*args, **kwargs), + base_name, + api_method_name, + args, + kwargs, + task=PANDAS_PARAM_TRACKING_TASK, + ) + raise e + finally: + _call_stack.pop() + + return wrapper + + if method is None: + # Called with parentheses + return outer_wrapper + + # Called without parentheses + return outer_wrapper(method) + + +def property_logger(prop): """Decorator that adds logging functionality to a property.""" - def shared_wrapper(f): - @functools.wraps(f) + def shared_wrapper(prop): + @functools.wraps(prop) def wrapped(*args, **kwargs): - class_name = decorated_cls.__name__ - property_name = f.__name__ + qualname_parts = getattr(prop, "__qualname__", prop.__name__).split(".") + class_name = qualname_parts[-2] if len(qualname_parts) > 1 else "" + property_name = prop.__name__ full_property_name = f"{class_name.lower()}-{property_name.lower()}" if len(_call_stack) == 0: - add_api_method(full_property_name) + session = _find_session(*args, **kwargs) + add_api_method(full_property_name, session=session) _call_stack.append(full_property_name) try: - return f(*args, **kwargs) + return prop(*args, **kwargs) finally: _call_stack.pop() @@ -196,22 +240,97 @@ def wrapped(*args, **kwargs): ) -def add_api_method(api_method_name): +def log_name_override(name: str): + """ + Attaches a custom name to be used by logger. + """ + + def wrapper(func): + setattr(func, LOG_OVERRIDE_NAME, name) + return func + + return wrapper + + +def add_api_method(api_method_name, session=None): global _lock global _api_methods - with _lock: - # Push the method to the front of the _api_methods list - _api_methods.insert(0, api_method_name) - # Keep the list length within the maximum limit (adjust MAX_LABELS_COUNT as needed) - _api_methods = _api_methods[:MAX_LABELS_COUNT] + clean_method_name = api_method_name.replace("<", "").replace(">", "") -def get_and_reset_api_methods(dry_run: bool = False): + if session is not None and _is_session_initialized(session): + with session._api_methods_lock: + session._api_methods.insert(0, clean_method_name) + session._api_methods = session._api_methods[:MAX_LABELS_COUNT] + else: + with _lock: + # Push the method to the front of the _api_methods list + _api_methods.insert(0, clean_method_name) + # Keep the list length within the maximum limit (adjust MAX_LABELS_COUNT as needed) + _api_methods = _api_methods[:MAX_LABELS_COUNT] + + +def get_and_reset_api_methods(dry_run: bool = False, session=None): global _lock + methods = [] + + if session is not None and _is_session_initialized(session): + with session._api_methods_lock: + methods.extend(session._api_methods) + if not dry_run: + session._api_methods.clear() + with _lock: - previous_api_methods = list(_api_methods) + methods.extend(_api_methods) # dry_run might not make a job resource, so only reset the log on real queries. if not dry_run: _api_methods.clear() - return previous_api_methods + return methods + + +def _get_bq_client(*args, **kwargs): + # Assumes that on BigFrames API errors (TypeError/NotImplementedError), + # an input arg (likely the first, e.g., 'self') has `_block.session.bqclient` + for argv in args: + if hasattr(argv, "_block"): + return argv._block.session.bqclient + + for kwargv in kwargs.values(): + if hasattr(kwargv, "_block"): + return kwargv._block.session.bqclient + + return None + + +def _is_session_initialized(session): + """Return True if fully initialized. + + Because the method logger could get called before Session.__init__ has a + chance to run, we use the globals in that case. + """ + return hasattr(session, "_api_methods_lock") and hasattr(session, "_api_methods") + + +def _find_session(*args, **kwargs): + # This function cannot import Session at the top level because Session + # imports log_adapter. + from bigframes.session import Session + + session = args[0] if args else None + if ( + session is not None + and isinstance(session, Session) + and _is_session_initialized(session) + ): + return session + + session = kwargs.get("session") + if ( + session is not None + and isinstance(session, Session) + and _is_session_initialized(session) + ): + return session + + return None diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index e2093e57d9..ddccb39ef9 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -16,20 +16,25 @@ import abc import dataclasses -import datetime import functools import itertools import typing -from typing import Callable, cast, Iterable, Mapping, Optional, Sequence, Tuple - -import google.cloud.bigquery as bq - -from bigframes.core import identifiers -from bigframes.core.bigframe_node import BigFrameNode, COLUMN_SET, Field +from typing import ( + AbstractSet, + Callable, + cast, + Iterable, + Mapping, + Optional, + Sequence, + Tuple, +) + +from bigframes.core import agg_expressions, bq_data, identifiers, local_data, sequences +from bigframes.core.bigframe_node import BigFrameNode, COLUMN_SET import bigframes.core.expression as ex -import bigframes.core.guid -from bigframes.core.ordering import OrderingExpression -import bigframes.core.schema as schemata +from bigframes.core.field import Field +from bigframes.core.ordering import OrderingExpression, RowOrdering import bigframes.core.slices as slices import bigframes.core.window_spec as window import bigframes.dtypes @@ -43,6 +48,12 @@ OVERHEAD_VARIABLES = 5 +@dataclasses.dataclass(frozen=True, eq=True) +class ColumnDef: + expression: ex.Expression + id: identifiers.ColumnId + + class AdditiveNode: """Definition of additive - if you drop added_fields, you end up with the descendent. @@ -67,7 +78,7 @@ def additive_base(self) -> BigFrameNode: ... @abc.abstractmethod - def replace_additive_base(self, BigFrameNode): + def replace_additive_base(self, BigFrameNode) -> BigFrameNode: ... @@ -80,7 +91,7 @@ def child_nodes(self) -> typing.Sequence[BigFrameNode]: return (self.child,) @property - def fields(self) -> Iterable[Field]: + def fields(self) -> Sequence[Field]: return self.child.fields @property @@ -146,6 +157,16 @@ def is_limit(self) -> bool: and (self.stop > 0) ) + @property + def is_noop(self) -> bool: + """Returns whether this node doesn't actually change the results.""" + # TODO: Handle tail case. + return ( + ((not self.start) or (self.start == 0)) + and (self.step == 1) + and ((self.stop is None) or (self.stop == self.child.row_count)) + ) + @property def row_count(self) -> typing.Optional[int]: child_length = self.child.row_count @@ -185,13 +206,10 @@ class InNode(BigFrameNode, AdditiveNode): left_child: BigFrameNode right_child: BigFrameNode left_col: ex.DerefOp - right_col: ex.DerefOp indicator_col: identifiers.ColumnId def _validate(self): - assert not ( - set(self.left_child.ids) & set(self.right_child.ids) - ), "Join ids collide" + assert len(self.right_child.fields) == 1 @property def row_preserving(self) -> bool: @@ -219,8 +237,8 @@ def added_fields(self) -> Tuple[Field, ...]: return (Field(self.indicator_col, bigframes.dtypes.BOOL_DTYPE, nullable=False),) @property - def fields(self) -> Iterable[Field]: - return itertools.chain( + def fields(self) -> Sequence[Field]: + return sequences.ChainedSequence( self.left_child.fields, self.added_fields, ) @@ -244,7 +262,11 @@ def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: @property def referenced_ids(self) -> COLUMN_SET: - return frozenset({self.left_col.id, self.right_col.id}) + return frozenset( + { + self.left_col.id, + } + ) @property def additive_base(self) -> BigFrameNode: @@ -253,9 +275,14 @@ def additive_base(self) -> BigFrameNode: @property def joins_nulls(self) -> bool: left_nullable = self.left_child.field_by_id[self.left_col.id].nullable - right_nullable = self.right_child.field_by_id[self.right_col.id].nullable + # assumption: right side has one column + right_nullable = self.right_child.fields[0].nullable return left_nullable or right_nullable + @property + def _node_expressions(self): + return (self.left_col,) + def replace_additive_base(self, node: BigFrameNode): return dataclasses.replace(self, left_child=node) @@ -278,7 +305,12 @@ def remap_vars( def remap_refs( self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] ) -> InNode: - return dataclasses.replace(self, left_col=self.left_col.remap_column_refs(mappings, allow_partial_bindings=True), right_col=self.right_col.remap_column_refs(mappings, allow_partial_bindings=True)) # type: ignore + return dataclasses.replace( + self, + left_col=self.left_col.remap_column_refs( + mappings, allow_partial_bindings=True + ), + ) # type: ignore @dataclasses.dataclass(frozen=True, eq=False) @@ -287,6 +319,7 @@ class JoinNode(BigFrameNode): right_child: BigFrameNode conditions: typing.Tuple[typing.Tuple[ex.DerefOp, ex.DerefOp], ...] type: typing.Literal["inner", "outer", "left", "right", "cross"] + propogate_order: bool def _validate(self): assert not ( @@ -311,18 +344,17 @@ def order_ambiguous(self) -> bool: @property def explicitly_ordered(self) -> bool: - # Do not consider user pre-join ordering intent - they need to re-order post-join in unordered mode. - return False + return self.propogate_order - @property - def fields(self) -> Iterable[Field]: - left_fields = self.left_child.fields + @functools.cached_property + def fields(self) -> Sequence[Field]: + left_fields: Iterable[Field] = self.left_child.fields if self.type in ("right", "outer"): left_fields = map(lambda x: x.with_nullable(), left_fields) - right_fields = self.right_child.fields + right_fields: Iterable[Field] = self.right_child.fields if self.type in ("left", "outer"): right_fields = map(lambda x: x.with_nullable(), right_fields) - return itertools.chain(left_fields, right_fields) + return (*left_fields, *right_fields) @property def joins_nulls(self) -> bool: @@ -369,6 +401,10 @@ def referenced_ids(self) -> COLUMN_SET: def consumed_ids(self) -> COLUMN_SET: return frozenset(*self.ids, *self.referenced_ids) + @property + def _node_expressions(self): + return tuple(itertools.chain.from_iterable(self.conditions)) + def transform_children(self, t: Callable[[BigFrameNode], BigFrameNode]) -> JoinNode: transformed = dataclasses.replace( self, left_child=t(self.left_child), right_child=t(self.right_child) @@ -398,7 +434,7 @@ def remap_refs( @dataclasses.dataclass(frozen=True, eq=False) class ConcatNode(BigFrameNode): - # TODO: Explcitly map column ids from each child + # TODO: Explcitly map column ids from each child? children: Tuple[BigFrameNode, ...] output_ids: Tuple[identifiers.ColumnId, ...] @@ -423,10 +459,10 @@ def explicitly_ordered(self) -> bool: return True @property - def fields(self) -> Iterable[Field]: + def fields(self) -> Sequence[Field]: # TODO: Output names should probably be aligned beforehand or be part of concat definition # TODO: Handle nullability - return ( + return tuple( Field(id, field.dtype) for id, field in zip(self.output_ids, self.children[0].fields) ) @@ -498,7 +534,7 @@ def explicitly_ordered(self) -> bool: return True @functools.cached_property - def fields(self) -> Iterable[Field]: + def fields(self) -> Sequence[Field]: return ( Field(self.output_id, next(iter(self.start.fields)).dtype, nullable=False), ) @@ -565,38 +601,97 @@ def transform_children(self, t: Callable[[BigFrameNode], BigFrameNode]) -> LeafN class ScanItem(typing.NamedTuple): id: identifiers.ColumnId - dtype: bigframes.dtypes.Dtype # Might be multiple logical types for a given physical source type source_id: str # Flexible enough for both local data and bq data def with_id(self, id: identifiers.ColumnId) -> ScanItem: - return ScanItem(id, self.dtype, self.source_id) + return ScanItem(id, self.source_id) + + def with_source_id(self, source_id: str) -> ScanItem: + return ScanItem(self.id, source_id) @dataclasses.dataclass(frozen=True) class ScanList: + """ + Defines the set of columns to scan from a source, along with the variable to bind the columns to. + """ + items: typing.Tuple[ScanItem, ...] + @classmethod + def from_items(cls, items: Iterable[ScanItem]) -> ScanList: + return cls(tuple(items)) + + def filter_cols( + self, + ids: AbstractSet[identifiers.ColumnId], + ) -> ScanList: + """Drop columns from the scan that except those in the 'ids' arg.""" + result = ScanList(tuple(item for item in self.items if item.id in ids)) + if len(result.items) == 0: + # We need to select something, or sql syntax breaks + result = ScanList(self.items[:1]) + return result + + def project( + self, + selections: Mapping[identifiers.ColumnId, identifiers.ColumnId], + ) -> ScanList: + """Project given ids from the scanlist, dropping previous bindings.""" + by_id = {item.id: item for item in self.items} + result = ScanList( + tuple( + by_id[old_id].with_id(new_id) for old_id, new_id in selections.items() + ) + ) + if len(result.items) == 0: + # We need to select something, or sql syntax breaks + result = ScanList((self.items[:1])) + return result + + def remap_source_ids( + self, + mapping: Mapping[str, str], + ) -> ScanList: + items = tuple( + item.with_source_id(mapping.get(item.source_id, item.source_id)) + for item in self.items + ) + return ScanList(items) + + def append( + self, source_id: str, dtype: bigframes.dtypes.Dtype, id: identifiers.ColumnId + ) -> ScanList: + return ScanList((*self.items, ScanItem(id, source_id))) + @dataclasses.dataclass(frozen=True, eq=False) class ReadLocalNode(LeafNode): - # TODO: Combine feather_bytes, data_schema, n_rows into a LocalDataDef struct # TODO: Track nullability for local data - feather_bytes: bytes - data_schema: schemata.ArraySchema - n_rows: int + local_data_source: local_data.ManagedArrowTable # Mapping of local ids to bfet id. scan_list: ScanList + session: bigframes.session.Session # Offsets are generated only if this is non-null offsets_col: Optional[identifiers.ColumnId] = None - session: typing.Optional[bigframes.session.Session] = None @property - def fields(self) -> Iterable[Field]: - fields = (Field(col_id, dtype) for col_id, dtype, _ in self.scan_list.items) + def fields(self) -> Sequence[Field]: + fields = tuple( + Field(col_id, self.local_data_source.schema.get_type(source_id)) + for col_id, source_id in self.scan_list.items + ) + if self.offsets_col is not None: - return itertools.chain( - fields, - (Field(self.offsets_col, bigframes.dtypes.INT_DTYPE, nullable=False),), + return tuple( + itertools.chain( + fields, + ( + Field( + self.offsets_col, bigframes.dtypes.INT_DTYPE, nullable=False + ), + ), + ) ) return fields @@ -623,7 +718,7 @@ def explicitly_ordered(self) -> bool: @property def row_count(self) -> typing.Optional[int]: - return self.n_rows + return self.local_data_source.metadata.row_count @property def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: @@ -634,7 +729,7 @@ def remap_vars( ) -> ReadLocalNode: new_scan_list = ScanList( tuple( - ScanItem(mappings.get(item.id, item.id), item.dtype, item.source_id) + ScanItem(mappings.get(item.id, item.id), item.source_id) for item in self.scan_list.items ) ) @@ -653,60 +748,9 @@ def remap_refs( return self -@dataclasses.dataclass(frozen=True) -class GbqTable: - project_id: str = dataclasses.field() - dataset_id: str = dataclasses.field() - table_id: str = dataclasses.field() - physical_schema: Tuple[bq.SchemaField, ...] = dataclasses.field() - n_rows: int = dataclasses.field() - is_physically_stored: bool = dataclasses.field() - cluster_cols: typing.Optional[Tuple[str, ...]] - - @staticmethod - def from_table(table: bq.Table, columns: Sequence[str] = ()) -> GbqTable: - # Subsetting fields with columns can reduce cost of row-hash default ordering - if columns: - schema = tuple(item for item in table.schema if item.name in columns) - else: - schema = tuple(table.schema) - return GbqTable( - project_id=table.project, - dataset_id=table.dataset_id, - table_id=table.table_id, - physical_schema=schema, - n_rows=table.num_rows, - is_physically_stored=(table.table_type in ["TABLE", "MATERIALIZED_VIEW"]), - cluster_cols=None - if table.clustering_fields is None - else tuple(table.clustering_fields), - ) - - @property - @functools.cache - def schema_by_id(self): - return {col.name: col for col in self.physical_schema} - - -@dataclasses.dataclass(frozen=True) -class BigqueryDataSource: - """ - Google BigQuery Data source. - - This should not be modified once defined, as all attributes contribute to the default ordering. - """ - - table: GbqTable - at_time: typing.Optional[datetime.datetime] = None - # Added for backwards compatibility, not validated - sql_predicate: typing.Optional[str] = None - ordering: typing.Optional[orderings.RowOrdering] = None - - -## Put ordering in here or just add order_by node above? @dataclasses.dataclass(frozen=True, eq=False) class ReadTableNode(LeafNode): - source: BigqueryDataSource + source: bq_data.BigqueryDataSource # Subset of physical schema column # Mapping of table schema ids to bfet id. scan_list: ScanList @@ -728,10 +772,14 @@ def session(self): return self.table_session @property - def fields(self) -> Iterable[Field]: - return ( - Field(col_id, dtype, self.source.table.schema_by_id[source_id].is_nullable) - for col_id, dtype, source_id in self.scan_list.items + def fields(self) -> Sequence[Field]: + return tuple( + Field( + col_id, + self.source.schema.get_type(source_id), + self.source.table.schema_by_id[source_id].is_nullable, + ) + for col_id, source_id in self.scan_list.items ) @property @@ -778,7 +826,7 @@ def variables_introduced(self) -> int: @property def row_count(self) -> typing.Optional[int]: if self.source.sql_predicate is None and self.source.table.is_physically_stored: - return self.source.table.n_rows + return self.source.n_rows return None @property @@ -790,7 +838,7 @@ def remap_vars( ) -> ReadTableNode: new_scan_list = ScanList( tuple( - ScanItem(mappings.get(item.id, item.id), item.dtype, item.source_id) + ScanItem(mappings.get(item.id, item.id), item.source_id) for item in self.scan_list.items ) ) @@ -811,7 +859,6 @@ def with_order_cols(self): new_scan_cols = [ ScanItem( identifiers.ColumnId.unique(), - dtype=bigframes.dtypes.convert_schema_field(field)[1], source_id=field.name, ) for field in self.source.table.physical_schema @@ -842,8 +889,8 @@ def non_local(self) -> bool: return True @property - def fields(self) -> Iterable[Field]: - return itertools.chain(self.child.fields, self.added_fields) + def fields(self) -> Sequence[Field]: + return sequences.ChainedSequence(self.child.fields, self.added_fields) @property def relation_ops_created(self) -> int: @@ -916,6 +963,18 @@ def consumed_ids(self) -> COLUMN_SET: def referenced_ids(self) -> COLUMN_SET: return frozenset(self.predicate.column_references) + @property + def _node_expressions(self): + return (self.predicate,) + + def transform_exprs( + self, fn: Callable[[ex.Expression], ex.Expression] + ) -> FilterNode: + return dataclasses.replace( + self, + predicate=fn(self.predicate), + ) + def remap_vars( self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] ) -> FilterNode: @@ -970,6 +1029,24 @@ def referenced_ids(self) -> COLUMN_SET: itertools.chain.from_iterable(map(lambda x: x.referenced_columns, self.by)) ) + @property + def _node_expressions(self): + return tuple(map(lambda x: x.scalar_expression, self.by)) + + def transform_exprs( + self, fn: Callable[[ex.Expression], ex.Expression] + ) -> OrderByNode: + new_by = cast( + tuple[OrderingExpression, ...], + tuple( + dataclasses.replace( + by_expr, scalar_expression=fn(by_expr.scalar_expression) + ) + for by_expr in self.by + ), + ) + return dataclasses.replace(self, by=new_by) + def remap_vars( self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] ) -> OrderByNode: @@ -982,14 +1059,9 @@ def remap_refs( itertools.chain.from_iterable(map(lambda x: x.referenced_columns, self.by)) ) ref_mapping = {id: ex.DerefOp(mappings[id]) for id in all_refs} - new_by = cast( - tuple[OrderingExpression, ...], - tuple( - by_expr.bind_refs(ref_mapping, allow_partial_bindings=True) - for by_expr in self.by - ), + return self.transform_exprs( + lambda ex: ex.bind_refs(ref_mapping, allow_partial_bindings=True) ) - return dataclasses.replace(self, by=new_by) @dataclasses.dataclass(frozen=True, eq=False) @@ -1058,7 +1130,7 @@ def _validate(self): raise ValueError(f"Reference to column not in child: {ref.id}") @functools.cached_property - def fields(self) -> Iterable[Field]: + def fields(self) -> Sequence[Field]: input_fields_by_id = {field.id: field for field in self.child.fields} return tuple( Field( @@ -1074,6 +1146,11 @@ def variables_introduced(self) -> int: # This operation only renames variables, doesn't actually create new ones return 0 + @property + def has_multi_referenced_ids(self) -> bool: + referenced = tuple(ref.ref.id for ref in self.input_output_pairs) + return len(referenced) != len(set(referenced)) + # TODO: Reuse parent namespace # Currently, Selection node allows renaming an reusing existing names, so it must establish a # new namespace. @@ -1093,6 +1170,10 @@ def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: def consumed_ids(self) -> COLUMN_SET: return frozenset(ref.id for ref, id in self.input_output_pairs) + @property + def _node_expressions(self): + return tuple(ref for ref, id in self.input_output_pairs) + def get_id_mapping(self) -> dict[identifiers.ColumnId, identifiers.ColumnId]: return {ref.id: id for ref, id in self.input_output_pairs} @@ -1120,26 +1201,26 @@ class ProjectionNode(UnaryNode, AdditiveNode): assignments: typing.Tuple[typing.Tuple[ex.Expression, identifiers.ColumnId], ...] def _validate(self): - input_types = self.child._dtype_lookup - for expression, id in self.assignments: + for expression, _ in self.assignments: # throws TypeError if invalid - _ = expression.output_type(input_types) + _ = ex.bind_schema_fields(expression, self.child.field_by_id).output_type + assert expression.is_scalar_expr # Cannot assign to existing variables - append only! assert all(name not in self.child.schema.names for _, name in self.assignments) @functools.cached_property def added_fields(self) -> Tuple[Field, ...]: - input_types = self.child._dtype_lookup - fields = [] for expr, id in self.assignments: + bound_expr = ex.bind_schema_fields(expr, self.child.field_by_id) field = Field( id, - bigframes.dtypes.dtype_for_etype(expr.output_type(input_types)), - nullable=expr.nullable, + bigframes.dtypes.dtype_for_etype(bound_expr.output_type), + nullable=bound_expr.nullable, ) + # Special case until we get better nullability inference in expression objects themselves - if expr.is_identity and not any( + if bound_expr.is_identity and not any( self.child.field_by_id[id].nullable for id in expr.column_references ): field = field.with_nonnull() @@ -1148,8 +1229,8 @@ def added_fields(self) -> Tuple[Field, ...]: return tuple(fields) @property - def fields(self) -> Iterable[Field]: - return itertools.chain(self.child.fields, self.added_fields) + def fields(self) -> Sequence[Field]: + return sequences.ChainedSequence(self.child.fields, self.added_fields) @property def variables_introduced(self) -> int: @@ -1181,10 +1262,20 @@ def referenced_ids(self) -> COLUMN_SET: ) ) + @property + def _node_expressions(self): + return tuple(ex for ex, id in self.assignments) + @property def additive_base(self) -> BigFrameNode: return self.child + def transform_exprs( + self, fn: Callable[[ex.Expression], ex.Expression] + ) -> ProjectionNode: + new_fields = tuple((fn(ex), id) for ex, id in self.assignments) + return dataclasses.replace(self, assignments=new_fields) + def replace_additive_base(self, node: BigFrameNode) -> ProjectionNode: return dataclasses.replace(self, child=node) @@ -1204,58 +1295,11 @@ def remap_refs( return dataclasses.replace(self, assignments=new_fields) -# TODO: Merge RowCount into Aggregate Node? -# Row count can be compute from table metadata sometimes, so it is a bit special. -@dataclasses.dataclass(frozen=True, eq=False) -class RowCountNode(UnaryNode): - col_id: identifiers.ColumnId = identifiers.ColumnId("count") - - @property - def row_preserving(self) -> bool: - return False - - @property - def non_local(self) -> bool: - return True - - @property - def fields(self) -> Iterable[Field]: - return (Field(self.col_id, bigframes.dtypes.INT_DTYPE, nullable=False),) - - @property - def variables_introduced(self) -> int: - return 1 - - @property - def defines_namespace(self) -> bool: - return True - - @property - def row_count(self) -> Optional[int]: - return 1 - - @property - def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: - return (self.col_id,) - - @property - def consumed_ids(self) -> COLUMN_SET: - return frozenset() - - def remap_vars( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> RowCountNode: - return dataclasses.replace(self, col_id=mappings.get(self.col_id, self.col_id)) - - def remap_refs( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> RowCountNode: - return self - - @dataclasses.dataclass(frozen=True, eq=False) class AggregateNode(UnaryNode): - aggregations: typing.Tuple[typing.Tuple[ex.Aggregation, identifiers.ColumnId], ...] + aggregations: typing.Tuple[ + typing.Tuple[agg_expressions.Aggregation, identifiers.ColumnId], ... + ] by_column_ids: typing.Tuple[ex.DerefOp, ...] = tuple([]) order_by: Tuple[OrderingExpression, ...] = () dropna: bool = True @@ -1269,7 +1313,7 @@ def non_local(self) -> bool: return True @functools.cached_property - def fields(self) -> Iterable[Field]: + def fields(self) -> Sequence[Field]: # TODO: Use child nullability to infer grouping key nullability by_fields = (self.child.field_by_id[ref.id] for ref in self.by_column_ids) if self.dropna: @@ -1278,9 +1322,7 @@ def fields(self) -> Iterable[Field]: agg_items = ( Field( id, - bigframes.dtypes.dtype_for_etype( - agg.output_type(self.child._dtype_lookup) - ), + ex.bind_schema_fields(agg, self.child.field_by_id).output_type, nullable=True, ) for agg, id in self.aggregations @@ -1326,6 +1368,13 @@ def has_ordered_ops(self) -> bool: aggregate.op.order_independent for aggregate, _ in self.aggregations ) + @property + def _node_expressions(self): + by_ids = (ref for ref in self.by_column_ids) + aggs = tuple(agg for agg, _ in self.aggregations) + order_ids = tuple(part.scalar_expression for part in self.order_by) + return (*by_ids, *aggs, *order_ids) + def remap_vars( self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] ) -> AggregateNode: @@ -1348,27 +1397,33 @@ def remap_refs( @dataclasses.dataclass(frozen=True, eq=False) class WindowOpNode(UnaryNode, AdditiveNode): - expression: ex.Aggregation + agg_exprs: tuple[ColumnDef, ...] # must be analytic/aggregation op window_spec: window.WindowSpec - output_name: identifiers.ColumnId - never_skip_nulls: bool = False - skip_reproject_unsafe: bool = False def _validate(self): """Validate the local data in the node.""" # Since inner order and row bounds are coupled, rank ops can't be row bounded - assert ( - not self.window_spec.row_bounded - ) or self.expression.op.implicitly_inherits_order - assert all(ref in self.child.ids for ref in self.expression.column_references) + for cdef in self.agg_exprs: + assert isinstance(cdef.expression, agg_expressions.Aggregation) + if self.window_spec.is_row_bounded: + assert cdef.expression.op.implicitly_inherits_order + for agg_child in cdef.expression.children: + assert agg_child.is_scalar_expr + for ref in cdef.expression.column_references: + assert ref in self.child.ids + + assert not any(field.dtype is None for field in self.added_fields) + + for window_expr in self.window_spec.expressions: + assert window_expr.is_scalar_expr @property def non_local(self) -> bool: return True @property - def fields(self) -> Iterable[Field]: - return itertools.chain(self.child.fields, [self.added_field]) + def fields(self) -> Sequence[Field]: + return sequences.ChainedSequence(self.child.fields, self.added_fields) @property def variables_introduced(self) -> int: @@ -1376,56 +1431,69 @@ def variables_introduced(self) -> int: @property def added_fields(self) -> Tuple[Field, ...]: - return (self.added_field,) + return tuple( + Field( + cdef.id, + ex.bind_schema_fields( + cdef.expression, self.child.field_by_id + ).output_type, + ) + for cdef in self.agg_exprs + ) @property def relation_ops_created(self) -> int: - # Assume that if not reprojecting, that there is a sequence of window operations sharing the same window - return 0 if self.skip_reproject_unsafe else 4 + return 2 @property def row_count(self) -> Optional[int]: return self.child.row_count - @functools.cached_property - def added_field(self) -> Field: - input_types = self.child._dtype_lookup - # TODO: Determine if output could be non-null - return Field( - self.output_name, - bigframes.dtypes.dtype_for_etype(self.expression.output_type(input_types)), - ) - @property def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: - return (self.output_name,) + return tuple(field.id for field in self.added_fields) @property def consumed_ids(self) -> COLUMN_SET: - return frozenset( - set(self.ids).difference([self.output_name]).union(self.referenced_ids) - ) + return frozenset(self.ids) @property def referenced_ids(self) -> COLUMN_SET: + ids_for_aggs = itertools.chain.from_iterable( + cdef.expression.column_references for cdef in self.agg_exprs + ) return ( frozenset() - .union(self.expression.column_references) + .union(ids_for_aggs) .union(self.window_spec.all_referenced_columns) ) @property def inherits_order(self) -> bool: # does the op both use ordering at all? and if so, can it inherit order? - op_inherits_order = ( - not self.expression.op.order_independent - ) and self.expression.op.implicitly_inherits_order - return op_inherits_order or self.window_spec.row_bounded + aggs = ( + typing.cast(agg_expressions.Aggregation, cdef.expression) + for cdef in self.agg_exprs + ) + op_inherits_order = any( + not agg.op.order_independent and agg.op.implicitly_inherits_order + for agg in aggs + ) + # range-bounded windows do not inherit orders because their ordering are + # already defined before rewrite time. + return op_inherits_order or self.window_spec.is_row_bounded @property def additive_base(self) -> BigFrameNode: return self.child + @property + def _node_expressions(self): + return ( + *(cdef.expression for cdef in self.agg_exprs), + *self.window_spec.expressions, + ) + def replace_additive_base(self, node: BigFrameNode) -> WindowOpNode: return dataclasses.replace(self, child=node) @@ -1433,7 +1501,11 @@ def remap_vars( self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] ) -> WindowOpNode: return dataclasses.replace( - self, output_name=mappings.get(self.output_name, self.output_name) + self, + agg_exprs=tuple( + ColumnDef(cdef.expression, mappings.get(cdef.id, cdef.id)) + for cdef in self.agg_exprs + ), ) def remap_refs( @@ -1441,8 +1513,14 @@ def remap_refs( ) -> WindowOpNode: return dataclasses.replace( self, - expression=self.expression.remap_column_refs( - mappings, allow_partial_bindings=True + agg_exprs=tuple( + ColumnDef( + cdef.expression.remap_column_refs( + mappings, allow_partial_bindings=True + ), + cdef.id, + ) + for cdef in self.agg_exprs ), window_spec=self.window_spec.remap_column_refs( mappings, allow_partial_bindings=True @@ -1496,12 +1574,16 @@ class ExplodeNode(UnaryNode): # Offsets are generated only if this is non-null offsets_col: Optional[identifiers.ColumnId] = None + def _validate(self): + for col in self.column_ids: + assert col.id in self.child.ids + @property def row_preserving(self) -> bool: return False @property - def fields(self) -> Iterable[Field]: + def fields(self) -> Sequence[Field]: fields = ( Field( field.id, @@ -1515,11 +1597,17 @@ def fields(self) -> Iterable[Field]: for field in self.child.fields ) if self.offsets_col is not None: - return itertools.chain( - fields, - (Field(self.offsets_col, bigframes.dtypes.INT_DTYPE, nullable=False),), + return tuple( + itertools.chain( + fields, + ( + Field( + self.offsets_col, bigframes.dtypes.INT_DTYPE, nullable=False + ), + ), + ) ) - return fields + return tuple(fields) @property def relation_ops_created(self) -> int: @@ -1541,6 +1629,10 @@ def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: def referenced_ids(self) -> COLUMN_SET: return frozenset(ref.id for ref in self.column_ids) + @property + def _node_expressions(self): + return self.column_ids + def remap_vars( self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] ) -> ExplodeNode: @@ -1555,6 +1647,74 @@ def remap_refs( return dataclasses.replace(self, column_ids=new_ids) # type: ignore +# Introduced during planing/compilation +# TODO: Enforce more strictly that this should never be a child node +@dataclasses.dataclass(frozen=True, eq=False) +class ResultNode(UnaryNode): + output_cols: tuple[tuple[ex.DerefOp, str], ...] + order_by: Optional[RowOrdering] = None + limit: Optional[int] = None + # TODO: CTE definitions + + def _validate(self): + for ref, _ in self.output_cols: + assert ref.id in self.child.ids + + @property + def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: + return () + + def remap_vars( + self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] + ) -> ResultNode: + return self + + def remap_refs( + self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] + ) -> ResultNode: + output_cols = tuple( + (ref.remap_column_refs(mappings), name) for ref, name in self.output_cols + ) + order_by = self.order_by.remap_column_refs(mappings) if self.order_by else None + return dataclasses.replace(self, output_cols=output_cols, order_by=order_by) # type: ignore + + @property + def fields(self) -> Sequence[Field]: + # Fields property here is for output schema, not to be consumed by a parent node. + input_fields_by_id = {field.id: field for field in self.child.fields} + return tuple( + Field( + identifiers.ColumnId(output), + input_fields_by_id[ref.id].dtype, + input_fields_by_id[ref.id].nullable, + ) + for ref, output in self.output_cols + ) + + @property + def consumed_ids(self) -> COLUMN_SET: + out_refs = frozenset(ref.id for ref, _ in self.output_cols) + order_refs = self.order_by.referenced_columns if self.order_by else frozenset() + return out_refs | order_refs + + @property + def row_count(self) -> Optional[int]: + child_count = self.child.row_count + if child_count is None: + return None + if self.limit is None: + return child_count + return min(self.limit, child_count) + + @property + def variables_introduced(self) -> int: + return 0 + + @property + def _node_expressions(self): + return tuple(ref for ref, _ in self.output_cols) + + # Tree operators def top_down( root: BigFrameNode, diff --git a/bigframes/core/ordered_sets.py b/bigframes/core/ordered_sets.py new file mode 100644 index 0000000000..b09c0ce8e0 --- /dev/null +++ b/bigframes/core/ordered_sets.py @@ -0,0 +1,139 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from typing import ( + Any, + Dict, + Generic, + Hashable, + Iterable, + Iterator, + MutableSet, + Optional, + TypeVar, +) + +T = TypeVar("T", bound=Hashable) + + +class _ListNode(Generic[T]): + """A private class representing a node in the doubly linked list.""" + + __slots__ = ("value", "prev", "next") + + def __init__( + self, + value: Optional[T], + prev: Optional[_ListNode[T]] = None, + next_node: Optional[_ListNode[T]] = None, + ): + self.value = value + self.prev = prev + self.next = next_node + + +class InsertionOrderedSet(MutableSet[T]): + """ + An ordered set implementation that maintains the order in which elements were + first inserted. It provides O(1) average time complexity for addition, + membership testing, and deletion, similar to Python's built-in set. + """ + + def __init__(self, iterable: Optional[Iterable] = None): + # Dictionary mapping element value -> _ListNode instance for O(1) lookup + self._dict: Dict[T, _ListNode[T]] = {} + + # Sentinel nodes for the doubly linked list. They don't hold actual data. + # head.next is the first element, tail.prev is the last element. + self._head: _ListNode[T] = _ListNode(None) + self._tail: _ListNode[T] = _ListNode(None) + self._head.next = self._tail + self._tail.prev = self._head + + if iterable: + self.update(iterable) + + def __len__(self) -> int: + """Return the number of elements in the set.""" + return len(self._dict) + + def __contains__(self, item: Any) -> bool: + """Check if an item is a member of the set (O(1) average).""" + return item in self._dict + + def __iter__(self) -> Iterator[T]: + """Iterate over the elements in insertion order (O(N)).""" + current = self._head.next + while current is not self._tail: + yield current.value # type: ignore + current = current.next # type: ignore + + def _unlink_node(self, node: _ListNode[T]) -> None: + """Helper to remove a node from the linked list.""" + node.prev.next = node.next # type: ignore + node.next.prev = node.prev # type: ignore + # Clear references to aid garbage collection + node.prev = None + node.next = None + + def _append_node(self, node: _ListNode[T]) -> None: + """Helper to append a node to the end of the linked list.""" + last_node = self._tail.prev + last_node.next = node # type: ignore + node.prev = last_node + node.next = self._tail + self._tail.prev = node + + def add(self, value: T) -> None: + """Add an element to the set. If it exists, its order is unchanged (O(1) average).""" + if value not in self._dict: + new_node = _ListNode(value) + self._dict[value] = new_node + self._append_node(new_node) + + def discard(self, value: T) -> None: + """Remove an element from the set if it is a member (O(1) average).""" + if value in self._dict: + node = self._dict.pop(value) + self._unlink_node(node) + + def remove(self, value: T) -> None: + """Remove an element from the set; raises KeyError if not present (O(1) average).""" + if value not in self._dict: + raise KeyError(f"{value} not found in set") + self.discard(value) + + def update(self, *others: Iterable[T]) -> None: + """Update the set with the union of itself and all others.""" + for other in others: + for item in other: + self.add(item) + + def clear(self) -> None: + """Remove all elements from the set.""" + self._dict.clear() + self._head.next = self._tail + self._tail.prev = self._head + + def _replace_contents(self, source: InsertionOrderedSet) -> InsertionOrderedSet: + """Helper method for inplace operators to transfer content from a result set.""" + self.clear() + for item in source: + self.add(item) + return self + + def __repr__(self) -> str: + """Representation of the set.""" + return f"InsertionOrderedSet({list(self)})" diff --git a/bigframes/core/ordering.py b/bigframes/core/ordering.py index 2fc7573b21..50b3cee8aa 100644 --- a/bigframes/core/ordering.py +++ b/bigframes/core/ordering.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from enum import Enum import typing -from typing import Mapping, Optional, Sequence, Set, Union +from typing import Callable, Mapping, Optional, Sequence, Set, Union import bigframes.core.expression as expression import bigframes.core.identifiers as ids @@ -82,6 +82,15 @@ def with_reverse(self) -> OrderingExpression: self.scalar_expression, self.direction.reverse(), not self.na_last ) + def transform_exprs( + self, t: Callable[[expression.Expression], expression.Expression] + ) -> OrderingExpression: + return OrderingExpression( + t(self.scalar_expression), + self.direction, + self.na_last, + ) + # Encoding classes specify additional properties for some ordering representations @dataclass(frozen=True) diff --git a/bigframes/core/pyarrow_utils.py b/bigframes/core/pyarrow_utils.py new file mode 100644 index 0000000000..bdbb220b95 --- /dev/null +++ b/bigframes/core/pyarrow_utils.py @@ -0,0 +1,119 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import Iterable, Iterator + +import pyarrow as pa + + +class BatchBuffer: + """ + FIFO buffer of pyarrow Record batches + + Not thread-safe. + """ + + def __init__(self): + self._buffer: list[pa.RecordBatch] = [] + self._buffer_size: int = 0 + + def __len__(self): + return self._buffer_size + + def append_batch(self, batch: pa.RecordBatch) -> None: + self._buffer.append(batch) + self._buffer_size += batch.num_rows + + def take_as_batches(self, n: int) -> tuple[pa.RecordBatch, ...]: + if n > len(self): + raise ValueError(f"Cannot take {n} rows, only {len(self)} rows in buffer.") + rows_taken = 0 + sub_batches: list[pa.RecordBatch] = [] + while rows_taken < n: + batch = self._buffer.pop(0) + if batch.num_rows > (n - rows_taken): + sub_batches.append(batch.slice(length=n - rows_taken)) + self._buffer.insert(0, batch.slice(offset=n - rows_taken)) + rows_taken += n - rows_taken + else: + sub_batches.append(batch) + rows_taken += batch.num_rows + + self._buffer_size -= n + return tuple(sub_batches) + + def take_rechunked(self, n: int) -> pa.RecordBatch: + return ( + pa.Table.from_batches(self.take_as_batches(n)) + .combine_chunks() + .to_batches()[0] + ) + + +def chunk_by_row_count( + batches: Iterable[pa.RecordBatch], page_size: int +) -> Iterator[tuple[pa.RecordBatch, ...]]: + buffer = BatchBuffer() + for batch in batches: + buffer.append_batch(batch) + while len(buffer) >= page_size: + yield buffer.take_as_batches(page_size) + + # emit final page, maybe smaller + if len(buffer) > 0: + yield buffer.take_as_batches(len(buffer)) + + +def cast_batch(batch: pa.RecordBatch, schema: pa.Schema) -> pa.RecordBatch: + if batch.schema == schema: + return batch + # TODO: Use RecordBatch.cast once min pyarrow>=16.0 + return pa.record_batch( + [arr.cast(type) for arr, type in zip(batch.columns, schema.types)], + schema=schema, + ) + + +def rename_batch(batch: pa.RecordBatch, names: list[str]) -> pa.RecordBatch: + if batch.schema.names == names: + return batch + # TODO: Use RecordBatch.rename_columns once min pyarrow>=16.0 + return pa.RecordBatch.from_arrays(batch.columns, names) + + +def truncate_pyarrow_iterable( + batches: Iterable[pa.RecordBatch], max_results: int +) -> Iterator[pa.RecordBatch]: + total_yielded = 0 + for batch in batches: + if batch.num_rows >= (max_results - total_yielded): + yield batch.slice(length=max_results - total_yielded) + return + else: + yield batch + total_yielded += batch.num_rows + + +def append_offsets( + pa_table: pa.Table, + offsets_col: str, +) -> pa.Table: + return pa_table.append_column( + offsets_col, pa.array(range(pa_table.num_rows), type=pa.int64()) + ) + + +def as_nullable(pa_table: pa.Table): + """Normalizes schema to nullable for value-wise comparisons.""" + nullable_schema = pa.schema(field.with_nullable(True) for field in pa_table.schema) + return pa_table.cast(nullable_schema) diff --git a/bigframes/core/pyformat.py b/bigframes/core/pyformat.py new file mode 100644 index 0000000000..8f49556ff4 --- /dev/null +++ b/bigframes/core/pyformat.py @@ -0,0 +1,177 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Helpers for the pyformat feature.""" + +# TODO(tswast): consolidate with pandas-gbq and bigquery-magics. See: +# https://github.com/googleapis/python-bigquery-magics/blob/main/bigquery_magics/pyformat.py + +from __future__ import annotations + +import string +import typing +from typing import Any, Optional, Union + +import google.cloud.bigquery +import pandas + +from bigframes.core import utils +import bigframes.core.local_data +from bigframes.core.tools import bigquery_schema +import bigframes.session + +_BQ_TABLE_TYPES = Union[ + google.cloud.bigquery.Table, + google.cloud.bigquery.TableReference, + google.cloud.bigquery.table.TableListItem, +] + + +def _table_to_sql(table: _BQ_TABLE_TYPES) -> str: + return f"`{table.project}`.`{table.dataset_id}`.`{table.table_id}`" + + +def _pandas_df_to_sql_dry_run(pd_df: pandas.DataFrame) -> str: + # Ensure there are no duplicate column labels. + # + # Please make sure this stays in sync with the logic used to_gbq(). See + # bigframes.dataframe.DataFrame._prepare_export(). + new_col_labels, new_idx_labels = utils.get_standardized_ids( + pd_df.columns, pd_df.index.names + ) + pd_copy = pd_df.copy() + pd_copy.columns = pandas.Index(new_col_labels) + pd_copy.index.names = new_idx_labels + + managed_table = bigframes.core.local_data.ManagedArrowTable.from_pandas(pd_copy) + bqschema = managed_table.schema.to_bigquery() + return bigquery_schema.to_sql_dry_run(bqschema) + + +def _pandas_df_to_sql( + df_pd: pandas.DataFrame, + *, + name: str, + session: Optional[bigframes.session.Session] = None, + dry_run: bool = False, +) -> str: + if session is None: + if not dry_run: + message = ( + f"Can't embed pandas DataFrame {name} in a SQL " + "string without a bigframes session except if for a dry run." + ) + raise ValueError(message) + + return _pandas_df_to_sql_dry_run(df_pd) + + # Use the _deferred engine to avoid loading data too often during dry run. + df = session.read_pandas(df_pd, write_engine="_deferred") + return _table_to_sql(df._to_placeholder_table(dry_run=dry_run)) + + +def _field_to_template_value( + name: str, + value: Any, + *, + session: Optional[bigframes.session.Session] = None, + dry_run: bool = False, +) -> str: + """Convert value to something embeddable in a SQL string.""" + import bigframes.core.sql # Avoid circular imports + import bigframes.dataframe # Avoid circular imports + + _validate_type(name, value) + + table_types = typing.get_args(_BQ_TABLE_TYPES) + if isinstance(value, table_types): + return _table_to_sql(value) + + if isinstance(value, pandas.DataFrame): + return _pandas_df_to_sql(value, session=session, dry_run=dry_run, name=name) + + if isinstance(value, bigframes.dataframe.DataFrame): + return _table_to_sql(value._to_placeholder_table(dry_run=dry_run)) + + if isinstance(value, str): + return value + + return bigframes.core.sql.simple_literal(value) + + +def _validate_type(name: str, value: Any): + """Raises TypeError if value is unsupported.""" + import bigframes.core.sql # Avoid circular imports + import bigframes.dataframe # Avoid circular imports + + if value is None: + return # None can't be used in isinstance, but is a valid literal. + + supported_types = ( + typing.get_args(_BQ_TABLE_TYPES) + + typing.get_args(bigframes.core.sql.SIMPLE_LITERAL_TYPES) + + (bigframes.dataframe.DataFrame,) + + (pandas.DataFrame,) + ) + + if not isinstance(value, supported_types): + raise TypeError( + f"{name} has unsupported type: {type(value)}. " + f"Only {supported_types} are supported." + ) + + +def _parse_fields(sql_template: str) -> list[str]: + return [ + field_name + for _, field_name, _, _ in string.Formatter().parse(sql_template) + if field_name is not None + ] + + +def pyformat( + sql_template: str, + *, + pyformat_args: dict, + session: Optional[bigframes.session.Session] = None, + dry_run: bool = False, +) -> str: + """Unsafe Python-style string formatting of SQL string. + + Only some data types supported. + + Warning: strings are **not** escaped. This allows them to be used in + contexts such as table identifiers, where normal query parameters are not + supported. + + Args: + sql_template (str): + SQL string with 0+ {var_name}-style format options. + pyformat_args (dict): + Variable namespace to use for formatting. + + Raises: + TypeError: if a referenced variable is not of a supported type. + KeyError: if a referenced variable is not found. + """ + fields = _parse_fields(sql_template) + + format_kwargs = {} + for name in fields: + value = pyformat_args[name] + format_kwargs[name] = _field_to_template_value( + name, value, session=session, dry_run=dry_run + ) + + return sql_template.format(**format_kwargs) diff --git a/bigframes/core/reshape/api.py b/bigframes/core/reshape/api.py index 56dbdae77e..adb33427f9 100644 --- a/bigframes/core/reshape/api.py +++ b/bigframes/core/reshape/api.py @@ -15,6 +15,7 @@ from bigframes.core.reshape.concat import concat from bigframes.core.reshape.encoding import get_dummies from bigframes.core.reshape.merge import merge +from bigframes.core.reshape.pivot import crosstab from bigframes.core.reshape.tile import cut, qcut -__all__ = ["concat", "get_dummies", "merge", "cut", "qcut"] +__all__ = ["concat", "get_dummies", "merge", "cut", "qcut", "crosstab"] diff --git a/bigframes/core/reshape/merge.py b/bigframes/core/reshape/merge.py index e1750d5c7a..2afeb2a106 100644 --- a/bigframes/core/reshape/merge.py +++ b/bigframes/core/reshape/merge.py @@ -18,20 +18,18 @@ from __future__ import annotations -import typing -from typing import Literal, Optional +from typing import Literal, Sequence +from bigframes_vendored import constants import bigframes_vendored.pandas.core.reshape.merge as vendored_pandas_merge -# Avoid cirular imports. -if typing.TYPE_CHECKING: - import bigframes.dataframe - import bigframes.series +from bigframes import dataframe, series +from bigframes.core import blocks, utils def merge( - left: bigframes.dataframe.DataFrame, - right: bigframes.dataframe.DataFrame, + left: dataframe.DataFrame, + right: dataframe.DataFrame, how: Literal[ "inner", "left", @@ -39,33 +37,60 @@ def merge( "right", "cross", ] = "inner", - on: Optional[str] = None, + on: blocks.Label | Sequence[blocks.Label] | None = None, *, - left_on: Optional[str] = None, - right_on: Optional[str] = None, + left_on: blocks.Label | Sequence[blocks.Label] | None = None, + right_on: blocks.Label | Sequence[blocks.Label] | None = None, + left_index: bool = False, + right_index: bool = False, sort: bool = False, suffixes: tuple[str, str] = ("_x", "_y"), -) -> bigframes.dataframe.DataFrame: +) -> dataframe.DataFrame: left = _validate_operand(left) right = _validate_operand(right) - return left.merge( + if how == "cross": + if on is not None: + raise ValueError("'on' is not supported for cross join.") + result_block = left._block.merge( + right._block, + left_join_ids=[], + right_join_ids=[], + suffixes=suffixes, + how=how, + sort=True, + ) + return dataframe.DataFrame(result_block) + + left_join_ids, right_join_ids = _validate_left_right_on( + left, right, - how=how, - on=on, + on, left_on=left_on, right_on=right_on, + left_index=left_index, + right_index=right_index, + ) + + block = left._block.merge( + right._block, + how, + left_join_ids, + right_join_ids, sort=sort, suffixes=suffixes, + left_index=left_index, + right_index=right_index, ) + return dataframe.DataFrame(block) merge.__doc__ = vendored_pandas_merge.merge.__doc__ def _validate_operand( - obj: bigframes.dataframe.DataFrame | bigframes.series.Series, -) -> bigframes.dataframe.DataFrame: + obj: dataframe.DataFrame | series.Series, +) -> dataframe.DataFrame: import bigframes.dataframe import bigframes.series @@ -79,3 +104,115 @@ def _validate_operand( raise TypeError( f"Can only merge bigframes.series.Series or bigframes.dataframe.DataFrame objects, a {type(obj)} was passed" ) + + +def _validate_left_right_on( + left: dataframe.DataFrame, + right: dataframe.DataFrame, + on: blocks.Label | Sequence[blocks.Label] | None = None, + *, + left_on: blocks.Label | Sequence[blocks.Label] | None = None, + right_on: blocks.Label | Sequence[blocks.Label] | None = None, + left_index: bool = False, + right_index: bool = False, +) -> tuple[list[str], list[str]]: + # Turn left_on and right_on to lists + if left_on is not None and not isinstance(left_on, (tuple, list)): + left_on = [left_on] + if right_on is not None and not isinstance(right_on, (tuple, list)): + right_on = [right_on] + + if left_index and left.index.nlevels > 1: + raise ValueError( + f"Joining with multi-level index is not supported. {constants.FEEDBACK_LINK}" + ) + if right_index and right.index.nlevels > 1: + raise ValueError( + f"Joining with multi-level index is not supported. {constants.FEEDBACK_LINK}" + ) + + # The following checks are copied from Pandas. + if on is None and left_on is None and right_on is None: + if left_index and right_index: + return list(left._block.index_columns), list(right._block.index_columns) + elif left_index: + raise ValueError("Must pass right_on or right_index=True") + elif right_index: + raise ValueError("Must pass left_on or left_index=True") + else: + # use the common columns + common_cols = left.columns.intersection(right.columns) + if len(common_cols) == 0: + raise ValueError( + "No common columns to perform merge on. " + f"Merge options: left_on={left_on}, " + f"right_on={right_on}, " + f"left_index={left_index}, " + f"right_index={right_index}" + ) + if ( + not left.columns.join(common_cols, how="inner").is_unique + or not right.columns.join(common_cols, how="inner").is_unique + ): + raise ValueError(f"Data columns not unique: {repr(common_cols)}") + return _to_col_ids(left, common_cols.to_list()), _to_col_ids( + right, common_cols.to_list() + ) + + elif on is not None: + if left_on is not None or right_on is not None: + raise ValueError( + 'Can only pass argument "on" OR "left_on" ' + 'and "right_on", not a combination of both.' + ) + if left_index or right_index: + raise ValueError( + 'Can only pass argument "on" OR "left_index" ' + 'and "right_index", not a combination of both.' + ) + return _to_col_ids(left, on), _to_col_ids(right, on) + + elif left_on is not None: + if left_index: + raise ValueError( + 'Can only pass argument "left_on" OR "left_index" not both.' + ) + if not right_index and right_on is None: + raise ValueError('Must pass "right_on" OR "right_index".') + if right_index: + if len(left_on) != right.index.nlevels: + raise ValueError( + "len(left_on) must equal the number " + 'of levels in the index of "right"' + ) + return _to_col_ids(left, left_on), list(right._block.index_columns) + + elif right_on is not None: + if right_index: + raise ValueError( + 'Can only pass argument "right_on" OR "right_index" not both.' + ) + if not left_index and left_on is None: + raise ValueError('Must pass "left_on" OR "left_index".') + if left_index: + if len(right_on) != left.index.nlevels: + raise ValueError( + "len(right_on) must equal the number " + 'of levels in the index of "left"' + ) + return list(left._block.index_columns), _to_col_ids(right, right_on) + + # The user correctly specified left_on and right_on + if len(right_on) != len(left_on): # type: ignore + raise ValueError("len(right_on) must equal len(left_on)") + + return _to_col_ids(left, left_on), _to_col_ids(right, right_on) + + +def _to_col_ids( + df: dataframe.DataFrame, join_cols: blocks.Label | Sequence[blocks.Label] +) -> list[str]: + if utils.is_list_like(join_cols): + return [df._block.resolve_label_exact_or_error(col) for col in join_cols] + + return [df._block.resolve_label_exact_or_error(join_cols)] diff --git a/bigframes/core/reshape/pivot.py b/bigframes/core/reshape/pivot.py new file mode 100644 index 0000000000..c69c7f11ab --- /dev/null +++ b/bigframes/core/reshape/pivot.py @@ -0,0 +1,88 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import bigframes_vendored.pandas.core.reshape.pivot as vendored_pandas_pivot +import pandas as pd + +import bigframes +from bigframes.core import convert, utils +from bigframes.core.reshape import concat +from bigframes.dataframe import DataFrame + +if TYPE_CHECKING: + import bigframes.session + + +def crosstab( + index, + columns, + values=None, + rownames=None, + colnames=None, + aggfunc=None, + *, + session: Optional[bigframes.session.Session] = None, +) -> DataFrame: + if _is_list_of_lists(index): + index = [ + convert.to_bf_series(subindex, default_index=None, session=session) + for subindex in index + ] + else: + index = [convert.to_bf_series(index, default_index=None, session=session)] + if _is_list_of_lists(columns): + columns = [ + convert.to_bf_series(subcol, default_index=None, session=session) + for subcol in columns + ] + else: + columns = [convert.to_bf_series(columns, default_index=None, session=session)] + + df = concat.concat([*index, *columns], join="inner", axis=1) + # for uniqueness + tmp_index_names = [f"_crosstab_index_{i}" for i in range(len(index))] + tmp_col_names = [f"_crosstab_columns_{i}" for i in range(len(columns))] + df.columns = pd.Index([*tmp_index_names, *tmp_col_names]) + + values = ( + convert.to_bf_series(values, default_index=df.index, session=session) + if values is not None + else 0 + ) + + df["_crosstab_values"] = values + pivot_table = df.pivot_table( + values="_crosstab_values", + index=tmp_index_names, + columns=tmp_col_names, + aggfunc=aggfunc or "count", + sort=False, + fill_value=0 if (aggfunc is None) else None, + ) + # Undo temporary unique level labels + pivot_table.index.names = rownames or [i.name for i in index] + pivot_table.columns.names = colnames or [c.name for c in columns] + return pivot_table + + +def _is_list_of_lists(item) -> bool: + if not utils.is_list_like(item): + return False + return all(convert.can_convert_to_series(subitem) for subitem in item) + + +crosstab.__doc__ = vendored_pandas_pivot.crosstab.__doc__ diff --git a/bigframes/core/reshape/tile.py b/bigframes/core/reshape/tile.py index 2a2ca9de95..a2efa8f927 100644 --- a/bigframes/core/reshape/tile.py +++ b/bigframes/core/reshape/tile.py @@ -15,12 +15,14 @@ from __future__ import annotations import typing -from typing import Iterable, Optional, Union +from typing import Optional, TYPE_CHECKING import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.reshape.tile as vendored_pandas_tile import pandas as pd +import bigframes +import bigframes.constants import bigframes.core.expression as ex import bigframes.core.ordering as order import bigframes.core.utils as utils @@ -30,36 +32,76 @@ import bigframes.operations.aggregations as agg_ops import bigframes.series +if TYPE_CHECKING: + import bigframes.session + def cut( - x: bigframes.series.Series, - bins: Union[ + x, + bins: typing.Union[ int, pd.IntervalIndex, - Iterable, + typing.Iterable, ], *, - labels: Union[Iterable[str], bool, None] = None, + right: typing.Optional[bool] = True, + labels: typing.Union[typing.Iterable[str], bool, None] = None, + session: Optional[bigframes.session.Session] = None, ) -> bigframes.series.Series: - if isinstance(bins, int) and bins <= 0: - raise ValueError("`bins` should be a positive integer.") + if ( + labels is not None + and labels is not False + and not isinstance(labels, typing.Iterable) + ): + raise ValueError( + "Bin labels must either be False, None or passed in as a list-like argument" + ) + if ( + isinstance(labels, typing.Iterable) + and len(list(labels)) > 0 + and not isinstance(list(labels)[0], str) + ): + raise NotImplementedError( + "When using an iterable for labels, only iterables of strings are supported " + f"but found {type(list(labels)[0])}. {constants.FEEDBACK_LINK}" + ) + + if len(x) == 0: + raise ValueError("Cannot cut empty array.") + + if not isinstance(x, bigframes.series.Series): + x = bigframes.series.Series(x, session=session) + + if isinstance(bins, int): + if bins <= 0: + raise ValueError("`bins` should be a positive integer.") + if isinstance(labels, typing.Iterable): + labels = tuple(labels) + if len(labels) != bins: + raise ValueError( + f"Bin labels({len(labels)}) must be same as the value of bins({bins})" + ) - if isinstance(bins, Iterable): + op = agg_ops.CutOp(bins, right=right, labels=labels) + return x._apply_window_op(op, window_spec=window_specs.unbound()) + elif isinstance(bins, typing.Iterable): if isinstance(bins, pd.IntervalIndex): as_index: pd.IntervalIndex = bins bins = tuple((bin.left.item(), bin.right.item()) for bin in bins) + # To maintain consistency with pandas' behavior + right = True + labels = None elif len(list(bins)) == 0: - raise ValueError("`bins` iterable should have at least one item") + as_index = pd.IntervalIndex.from_tuples(list(bins)) + bins = tuple() elif isinstance(list(bins)[0], tuple): as_index = pd.IntervalIndex.from_tuples(list(bins)) bins = tuple(bins) + # To maintain consistency with pandas' behavior + right = True + labels = None elif pd.api.types.is_number(list(bins)[0]): bins_list = list(bins) - if len(bins_list) < 2: - raise ValueError( - "`bins` iterable of numeric breaks should have" - " at least two items" - ) as_index = pd.IntervalIndex.from_breaks(bins_list) single_type = all([isinstance(n, type(bins_list[0])) for n in bins_list]) numeric_type = type(bins_list[0]) if single_type else float @@ -70,20 +112,33 @@ def cut( ] ) else: - raise ValueError("`bins` iterable should contain tuples or numerics") + raise ValueError("`bins` iterable should contain tuples or numerics.") if as_index.is_overlapping: - raise ValueError("Overlapping IntervalIndex is not accepted.") + raise ValueError("Overlapping IntervalIndex is not accepted.") # TODO: test - if labels is not None and labels is not False: - raise NotImplementedError( - "The 'labels' parameter must be either False or None. " - "Please provide a valid value for 'labels'." - ) + if isinstance(labels, typing.Iterable): + labels = tuple(labels) + if len(labels) != len(as_index): + raise ValueError( + f"Bin labels({len(labels)}) must be same as the number of bin edges" + f"({len(as_index)})" + ) - return x._apply_window_op( - agg_ops.CutOp(bins, labels=labels), window_spec=window_specs.unbound() - ) + if len(as_index) == 0: + dtype = agg_ops.CutOp(bins, right=right, labels=labels).output_type() + return bigframes.series.Series( + [pd.NA] * len(x), + dtype=dtype, + name=x.name, + index=x.index, + session=x._session, + ) + else: + op = agg_ops.CutOp(bins, right=right, labels=labels) + return x._apply_window_op(op, window_spec=window_specs.unbound()) + else: + raise ValueError("`bins` must be an integer or interable.") cut.__doc__ = vendored_pandas_tile.cut.__doc__ @@ -93,7 +148,7 @@ def qcut( x: bigframes.series.Series, q: typing.Union[int, typing.Sequence[float]], *, - labels: Optional[bool] = None, + labels: typing.Optional[bool] = None, duplicates: typing.Literal["drop", "error"] = "error", ) -> bigframes.series.Series: if isinstance(q, int) and q <= 0: diff --git a/bigframes/core/rewrite/__init__.py b/bigframes/core/rewrite/__init__.py index e5f7578911..4e5295ae9d 100644 --- a/bigframes/core/rewrite/__init__.py +++ b/bigframes/core/rewrite/__init__.py @@ -12,21 +12,36 @@ # See the License for the specific language governing permissions and # limitations under the License. +from bigframes.core.rewrite.fold_row_count import fold_row_counts from bigframes.core.rewrite.identifiers import remap_variables from bigframes.core.rewrite.implicit_align import try_row_join from bigframes.core.rewrite.legacy_align import legacy_join_as_projection -from bigframes.core.rewrite.order import pull_up_order +from bigframes.core.rewrite.order import bake_order, defer_order from bigframes.core.rewrite.pruning import column_pruning -from bigframes.core.rewrite.slices import pullup_limit_from_slice, rewrite_slice +from bigframes.core.rewrite.scan_reduction import ( + try_reduce_to_local_scan, + try_reduce_to_table_scan, +) +from bigframes.core.rewrite.select_pullup import defer_selection +from bigframes.core.rewrite.slices import pull_out_limit, pull_up_limits, rewrite_slice from bigframes.core.rewrite.timedeltas import rewrite_timedelta_expressions +from bigframes.core.rewrite.windows import pull_out_window_order, rewrite_range_rolling __all__ = [ "legacy_join_as_projection", "try_row_join", "rewrite_slice", "rewrite_timedelta_expressions", - "pullup_limit_from_slice", + "pull_up_limits", + "pull_out_limit", "remap_variables", - "pull_up_order", + "defer_order", "column_pruning", + "rewrite_range_rolling", + "try_reduce_to_table_scan", + "bake_order", + "try_reduce_to_local_scan", + "fold_row_counts", + "pull_out_window_order", + "defer_selection", ] diff --git a/bigframes/core/rewrite/fold_row_count.py b/bigframes/core/rewrite/fold_row_count.py new file mode 100644 index 0000000000..cc0b818fb9 --- /dev/null +++ b/bigframes/core/rewrite/fold_row_count.py @@ -0,0 +1,40 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import pyarrow as pa + +from bigframes.core import local_data, nodes +from bigframes.operations import aggregations + + +def fold_row_counts(node: nodes.BigFrameNode) -> nodes.BigFrameNode: + if not isinstance(node, nodes.AggregateNode): + return node + if len(node.by_column_ids) > 0: + return node + if node.child.row_count is None: + return node + for agg, _ in node.aggregations: + if agg.op != aggregations.size_op: + return node + local_data_source = local_data.ManagedArrowTable.from_pyarrow( + pa.table({"count": pa.array([node.child.row_count], type=pa.int64())}) + ) + scan_list = nodes.ScanList( + tuple(nodes.ScanItem(out_id, "count") for _, out_id in node.aggregations) + ) + return nodes.ReadLocalNode( + local_data_source=local_data_source, scan_list=scan_list, session=node.session + ) diff --git a/bigframes/core/rewrite/identifiers.py b/bigframes/core/rewrite/identifiers.py index d49e5c1b42..da43fdf8b9 100644 --- a/bigframes/core/rewrite/identifiers.py +++ b/bigframes/core/rewrite/identifiers.py @@ -13,47 +13,81 @@ # limitations under the License. from __future__ import annotations -from typing import Generator, Tuple +import dataclasses +import typing -import bigframes.core.identifiers -import bigframes.core.nodes +from bigframes.core import identifiers, nodes # TODO: May as well just outright remove selection nodes in this process. def remap_variables( - root: bigframes.core.nodes.BigFrameNode, - id_generator: Generator[bigframes.core.identifiers.ColumnId, None, None], -) -> Tuple[ - bigframes.core.nodes.BigFrameNode, - dict[bigframes.core.identifiers.ColumnId, bigframes.core.identifiers.ColumnId], + root: nodes.BigFrameNode, + id_generator: typing.Iterator[identifiers.ColumnId], +) -> typing.Tuple[ + nodes.BigFrameNode, + dict[identifiers.ColumnId, identifiers.ColumnId], ]: - """ - Remap all variables in the BFET using the id_generator. + """Remaps `ColumnId`s in the expression tree to be deterministic and sequential. + + This function performs a post-order traversal. It recursively remaps children + nodes first, then remaps the current node's references and definitions. + + Note: this will convert a DAG to a tree by duplicating shared nodes. - Note: this will convert a DAG to a tree. + Args: + root: The root node of the expression tree. + id_generator: An iterator that yields new column IDs. + + Returns: + A tuple of the new root node and a mapping from old to new column IDs + visible to the parent node. """ - child_replacement_map = dict() - ref_mapping = dict() - # Sequential ids are assigned bottom-up left-to-right + # Step 1: Recursively remap children to get their new nodes and ID mappings. + new_child_nodes: list[nodes.BigFrameNode] = [] + new_child_mappings: list[dict[identifiers.ColumnId, identifiers.ColumnId]] = [] for child in root.child_nodes: - new_child, child_var_mapping = remap_variables(child, id_generator=id_generator) - child_replacement_map[child] = new_child - ref_mapping.update(child_var_mapping) - - # This is actually invalid until we've replaced all of children, refs and var defs - with_new_children = root.transform_children( - lambda node: child_replacement_map[node] - ) - - with_new_refs = with_new_children.remap_refs(ref_mapping) - - node_var_mapping = {old_id: next(id_generator) for old_id in root.node_defined_ids} - with_new_vars = with_new_refs.remap_vars(node_var_mapping) - with_new_vars._validate() - - return ( - with_new_vars, - node_var_mapping - if root.defines_namespace - else (ref_mapping | node_var_mapping), - ) + new_child, child_mappings = remap_variables(child, id_generator=id_generator) + new_child_nodes.append(new_child) + new_child_mappings.append(child_mappings) + + # Step 2: Transform children to use their new nodes. + remapped_children: dict[nodes.BigFrameNode, nodes.BigFrameNode] = { + child: new_child for child, new_child in zip(root.child_nodes, new_child_nodes) + } + new_root = root.transform_children(lambda node: remapped_children[node]) + + # Step 3: Transform the current node using the mappings from its children. + # "reversed" is required for InNode so that in case of a duplicate column ID, + # the left child's mapping is the one that's kept. + downstream_mappings: dict[identifiers.ColumnId, identifiers.ColumnId] = { + k: v for mapping in reversed(new_child_mappings) for k, v in mapping.items() + } + if isinstance(new_root, nodes.InNode): + new_root = typing.cast(nodes.InNode, new_root) + new_root = dataclasses.replace( + new_root, + left_col=new_root.left_col.remap_column_refs( + new_child_mappings[0], allow_partial_bindings=True + ), + ) + else: + new_root = new_root.remap_refs(downstream_mappings) + + # Step 4: Create new IDs for columns defined by the current node. + node_defined_mappings = { + old_id: next(id_generator) for old_id in root.node_defined_ids + } + new_root = new_root.remap_vars(node_defined_mappings) + + new_root._validate() + + # Step 5: Determine which mappings to propagate up to the parent. + if root.defines_namespace: + # If a node defines a new namespace (e.g., a join), mappings from its + # children are not visible to its parents. + mappings_for_parent = node_defined_mappings + else: + # Otherwise, pass up the combined mappings from children and the current node. + mappings_for_parent = downstream_mappings | node_defined_mappings + + return new_root, mappings_for_parent diff --git a/bigframes/core/rewrite/implicit_align.py b/bigframes/core/rewrite/implicit_align.py index 1989b1a543..ebd48d8236 100644 --- a/bigframes/core/rewrite/implicit_align.py +++ b/bigframes/core/rewrite/implicit_align.py @@ -15,15 +15,11 @@ import dataclasses import itertools -from typing import cast, Optional, Sequence, Set, Tuple +from typing import Optional, Sequence, Set, Tuple import bigframes.core.expression -import bigframes.core.guid import bigframes.core.identifiers -import bigframes.core.join_def import bigframes.core.nodes -import bigframes.core.window_spec -import bigframes.operations.aggregations # Combination of selects and additive nodes can be merged as an explicit keyless "row join" ALIGNABLE_NODES = ( @@ -156,35 +152,6 @@ def pull_up_selection( return node, tuple( bigframes.core.nodes.AliasedRef.identity(field.id) for field in node.fields ) - # InNode needs special handling, as its a binary node, but row identity is from left side only. - # TODO: Merge code with unary op paths - if isinstance(node, bigframes.core.nodes.InNode): - child_node, child_selections = pull_up_selection( - node.left_child, stop=stop, rename_vars=rename_vars - ) - mapping = {out: ref.id for ref, out in child_selections} - - new_in_node: bigframes.core.nodes.InNode = dataclasses.replace( - node, left_child=child_node - ) - new_in_node = new_in_node.remap_refs(mapping) - if rename_vars: - new_in_node = cast( - bigframes.core.nodes.InNode, - new_in_node.remap_vars( - {node.indicator_col: bigframes.core.identifiers.ColumnId.unique()} - ), - ) - added_selection = tuple( - ( - bigframes.core.nodes.AliasedRef( - bigframes.core.expression.DerefOp(new_in_node.indicator_col), - node.indicator_col, - ), - ) - ) - new_selection = child_selections + added_selection - return new_in_node, new_selection if isinstance(node, bigframes.core.nodes.AdditiveNode): child_node, child_selections = pull_up_selection( diff --git a/bigframes/core/rewrite/op_lowering.py b/bigframes/core/rewrite/op_lowering.py new file mode 100644 index 0000000000..6473c3bf8a --- /dev/null +++ b/bigframes/core/rewrite/op_lowering.py @@ -0,0 +1,57 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import abc +from typing import Sequence + +from bigframes.core import bigframe_node, expression, nodes +import bigframes.operations as ops + + +class OpLoweringRule(abc.ABC): + @property + @abc.abstractmethod + def op(self) -> type[ops.ScalarOp]: + ... + + @abc.abstractmethod + def lower(self, expr: expression.OpExpression) -> expression.Expression: + ... + + +def lower_ops( + root: bigframe_node.BigFrameNode, rules: Sequence[OpLoweringRule] +) -> bigframe_node.BigFrameNode: + rules_by_op = {rule.op: rule for rule in rules} + + def lower_expr(expr: expression.Expression): + def lower_expr_step(expr: expression.Expression) -> expression.Expression: + if isinstance(expr, expression.OpExpression): + maybe_rule = rules_by_op.get(expr.op.__class__) + if maybe_rule: + return maybe_rule.lower(expr) + return expr + + return expr.bottom_up(lower_expr_step) + + def lower_node(node: bigframe_node.BigFrameNode) -> bigframe_node.BigFrameNode: + if isinstance( + node, (nodes.ProjectionNode, nodes.FilterNode, nodes.OrderByNode) + ): + return node.transform_exprs(lower_expr) + else: + return node + + return root.bottom_up(lower_node) diff --git a/bigframes/core/rewrite/order.py b/bigframes/core/rewrite/order.py index bdb30fbc34..6741dfddad 100644 --- a/bigframes/core/rewrite/order.py +++ b/bigframes/core/rewrite/order.py @@ -15,21 +15,43 @@ import functools from typing import Mapping, Tuple -from bigframes.core import identifiers -import bigframes.core.expression +from bigframes.core import agg_expressions, expression, identifiers import bigframes.core.nodes import bigframes.core.ordering import bigframes.core.window_spec -import bigframes.operations from bigframes.operations import aggregations as agg_ops +def defer_order( + root: bigframes.core.nodes.ResultNode, output_hidden_row_keys: bool +) -> bigframes.core.nodes.ResultNode: + new_child, order = _pull_up_order(root.child, order_root=True) + order_by = ( + order.with_ordering_columns(root.order_by.all_ordering_columns) + if root.order_by + else order + ) + if output_hidden_row_keys: + output_names = tuple((expression.DerefOp(id), id.sql) for id in new_child.ids) + else: + output_names = root.output_cols + return dataclasses.replace( + root, output_cols=output_names, child=new_child, order_by=order_by + ) + + +def bake_order( + node: bigframes.core.nodes.BigFrameNode, +) -> bigframes.core.nodes.BigFrameNode: + node, _ = _pull_up_order(node, order_root=False) + return node + + # Makes ordering explicit in window definitions -def pull_up_order( +def _pull_up_order( root: bigframes.core.nodes.BigFrameNode, *, order_root: bool = True, - ordered_joins: bool = True, ) -> Tuple[bigframes.core.nodes.BigFrameNode, bigframes.core.ordering.RowOrdering]: """ Pull the ordering up, putting full order definition into window ops. @@ -92,7 +114,7 @@ def pull_up_order_inner( child_result, child_order = pull_up_order_inner(node.child) return node.replace_child(child_result), child_order elif isinstance(node, bigframes.core.nodes.JoinNode): - if ordered_joins: + if node.propogate_order: return pull_order_join(node) else: return ( @@ -145,14 +167,13 @@ def pull_up_order_inner( ) else: # Otherwise we need to generate offsets - agg = bigframes.core.expression.NullaryAggregation( - agg_ops.RowNumberOp() - ) + agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) + col_def = bigframes.core.nodes.ColumnDef(agg, node.col_id) window_spec = bigframes.core.window_spec.unbound( ordering=tuple(child_order.all_ordering_columns) ) new_offsets_node = bigframes.core.nodes.WindowOpNode( - child_result, agg, window_spec, node.col_id + child_result, (col_def,), window_spec ) return ( new_offsets_node, @@ -189,11 +210,6 @@ def pull_up_order_inner( ) new_order = child_order.remap_column_refs(new_select_node.get_id_mapping()) return new_select_node, new_order - elif isinstance(node, bigframes.core.nodes.RowCountNode): - child_result = remove_order(node.child) - return node.replace_child( - child_result - ), bigframes.core.ordering.TotalOrdering.from_primary_key([node.col_id]) elif isinstance(node, bigframes.core.nodes.AggregateNode): if node.has_ordered_ops: child_result, child_order = pull_up_order_inner(node.child) @@ -270,14 +286,13 @@ def pull_order_concat( new_source, ((order_expression.scalar_expression, offsets_id),) ) else: - agg = bigframes.core.expression.NullaryAggregation( - agg_ops.RowNumberOp() - ) + agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) window_spec = bigframes.core.window_spec.unbound( ordering=tuple(order.all_ordering_columns) ) + col_def = bigframes.core.nodes.ColumnDef(agg, offsets_id) new_source = bigframes.core.nodes.WindowOpNode( - new_source, agg, window_spec, offsets_id + new_source, (col_def,), window_spec ) new_source = bigframes.core.nodes.ProjectionNode( new_source, ((bigframes.core.expression.const(i), table_id),) @@ -406,9 +421,11 @@ def remove_order_strict( def rewrite_promote_offsets( node: bigframes.core.nodes.PromoteOffsetsNode, ) -> bigframes.core.nodes.WindowOpNode: - agg = bigframes.core.expression.NullaryAggregation(agg_ops.RowNumberOp()) + agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) window_spec = bigframes.core.window_spec.unbound() - return bigframes.core.nodes.WindowOpNode(node.child, agg, window_spec, node.col_id) + return bigframes.core.nodes.WindowOpNode( + node.child, (bigframes.core.nodes.ColumnDef(agg, node.col_id),), window_spec + ) def rename_cols( diff --git a/bigframes/core/rewrite/pruning.py b/bigframes/core/rewrite/pruning.py index 5a94f2aa40..7695ace3b3 100644 --- a/bigframes/core/rewrite/pruning.py +++ b/bigframes/core/rewrite/pruning.py @@ -13,16 +13,16 @@ # limitations under the License. import dataclasses import functools -from typing import AbstractSet +import itertools +import typing -from bigframes.core import identifiers -import bigframes.core.nodes +from bigframes.core import identifiers, nodes def column_pruning( - root: bigframes.core.nodes.BigFrameNode, -) -> bigframes.core.nodes.BigFrameNode: - return bigframes.core.nodes.top_down(root, prune_columns) + root: nodes.BigFrameNode, +) -> nodes.BigFrameNode: + return nodes.top_down(root, prune_columns) def to_fixed(max_iterations: int = 100): @@ -48,39 +48,38 @@ def wrapper(*args, **kwargs): @to_fixed(max_iterations=100) -def prune_columns(node: bigframes.core.nodes.BigFrameNode): - if isinstance(node, bigframes.core.nodes.SelectionNode): +def prune_columns(node: nodes.BigFrameNode): + if isinstance(node, nodes.SelectionNode): result = prune_selection_child(node) - elif isinstance(node, bigframes.core.nodes.AggregateNode): + elif isinstance(node, nodes.ResultNode): + result = node.replace_child(prune_node(node.child, node.consumed_ids)) + elif isinstance(node, nodes.AggregateNode): result = node.replace_child(prune_node(node.child, node.consumed_ids)) - elif isinstance(node, bigframes.core.nodes.InNode): - result = dataclasses.replace( - node, - right_child=prune_node(node.right_child, frozenset([node.right_col.id])), - ) else: result = node return result def prune_selection_child( - selection: bigframes.core.nodes.SelectionNode, -) -> bigframes.core.nodes.BigFrameNode: + selection: nodes.SelectionNode, +) -> nodes.BigFrameNode: child = selection.child # Important to check this first if list(selection.ids) == list(child.ids): - return child + if (ref.ref.id == ref.id for ref in selection.input_output_pairs): + # selection is no-op so just remove it entirely + return child - if isinstance(child, bigframes.core.nodes.SelectionNode): + if isinstance(child, nodes.SelectionNode): return selection.remap_refs( {id: ref.id for ref, id in child.input_output_pairs} ).replace_child(child.child) - elif isinstance(child, bigframes.core.nodes.AdditiveNode): + elif isinstance(child, nodes.AdditiveNode): if not set(field.id for field in child.added_fields) & selection.consumed_ids: return selection.replace_child(child.additive_base) needed_ids = selection.consumed_ids | child.referenced_ids - if isinstance(child, bigframes.core.nodes.ProjectionNode): + if isinstance(child, nodes.ProjectionNode): # Projection expressions are independent, so can be individually removed from the node child = dataclasses.replace( child, @@ -91,86 +90,88 @@ def prune_selection_child( return selection.replace_child( child.replace_additive_base(prune_node(child.additive_base, needed_ids)) ) - elif isinstance(child, bigframes.core.nodes.ConcatNode): + elif isinstance(child, nodes.ConcatNode): indices = [ list(child.ids).index(ref.id) for ref, _ in selection.input_output_pairs ] + if len(indices) == 0: + # pushing zero-column selection into concat messes up emitter for now, which doesn't like zero columns + return selection new_children = [] for concat_node in child.child_nodes: cc_ids = tuple(concat_node.ids) - sub_selection = tuple( - bigframes.core.nodes.AliasedRef.identity(cc_ids[i]) for i in indices - ) - new_children.append( - bigframes.core.nodes.SelectionNode(concat_node, sub_selection) - ) - return bigframes.core.nodes.ConcatNode( + sub_selection = tuple(nodes.AliasedRef.identity(cc_ids[i]) for i in indices) + new_children.append(nodes.SelectionNode(concat_node, sub_selection)) + return nodes.ConcatNode( children=tuple(new_children), output_ids=tuple(selection.ids) ) # Nodes that pass through input columns elif isinstance( child, ( - bigframes.core.nodes.RandomSampleNode, - bigframes.core.nodes.ReversedNode, - bigframes.core.nodes.OrderByNode, - bigframes.core.nodes.FilterNode, - bigframes.core.nodes.SliceNode, - bigframes.core.nodes.JoinNode, - bigframes.core.nodes.ExplodeNode, + nodes.RandomSampleNode, + nodes.ReversedNode, + nodes.OrderByNode, + nodes.FilterNode, + nodes.SliceNode, + nodes.JoinNode, + nodes.ExplodeNode, ), ): ids = selection.consumed_ids | child.referenced_ids return selection.replace_child( child.transform_children(lambda x: prune_node(x, ids)) ) - elif isinstance(child, bigframes.core.nodes.AggregateNode): + elif isinstance(child, nodes.AggregateNode): return selection.replace_child(prune_aggregate(child, selection.consumed_ids)) - elif isinstance(child, bigframes.core.nodes.LeafNode): + elif isinstance(child, nodes.LeafNode): return selection.replace_child(prune_leaf(child, selection.consumed_ids)) return selection def prune_node( - node: bigframes.core.nodes.BigFrameNode, - ids: AbstractSet[identifiers.ColumnId], + node: nodes.BigFrameNode, + ids: typing.AbstractSet[identifiers.ColumnId], ): # This clause is important, ensures idempotency, so can reach fixed point if not (set(node.ids) - ids): return node else: - return bigframes.core.nodes.SelectionNode( + # If no child ids are needed, probably a size op or numbering op above, keep a single column always + ids_to_keep = tuple(id for id in node.ids if id in ids) or tuple( + itertools.islice(node.ids, 0, 1) + ) + return nodes.SelectionNode( node, - tuple( - bigframes.core.nodes.AliasedRef.identity(id) - for id in node.ids - if id in ids - ), + tuple(nodes.AliasedRef.identity(id) for id in ids_to_keep), ) def prune_aggregate( - node: bigframes.core.nodes.AggregateNode, - used_cols: AbstractSet[identifiers.ColumnId], -) -> bigframes.core.nodes.AggregateNode: - pruned_aggs = tuple(agg for agg in node.aggregations if agg[1] in used_cols) + node: nodes.AggregateNode, + used_cols: typing.AbstractSet[identifiers.ColumnId], +) -> nodes.AggregateNode: + pruned_aggs = ( + tuple(agg for agg in node.aggregations if agg[1] in used_cols) + or node.aggregations[0:1] + ) return dataclasses.replace(node, aggregations=pruned_aggs) @functools.singledispatch def prune_leaf( - node: bigframes.core.nodes.BigFrameNode, - used_cols: AbstractSet[identifiers.ColumnId], + node: nodes.BigFrameNode, + used_cols: typing.AbstractSet[identifiers.ColumnId], ): ... @prune_leaf.register def prune_readlocal( - node: bigframes.core.nodes.ReadLocalNode, - selection: AbstractSet[identifiers.ColumnId], -) -> bigframes.core.nodes.ReadLocalNode: - new_scan_list = filter_scanlist(node.scan_list, selection) + node: nodes.ReadLocalNode, + selection: typing.AbstractSet[identifiers.ColumnId], +) -> nodes.ReadLocalNode: + new_scan_list = node.scan_list.filter_cols(selection) return dataclasses.replace( node, scan_list=new_scan_list, @@ -180,21 +181,8 @@ def prune_readlocal( @prune_leaf.register def prune_readtable( - node: bigframes.core.nodes.ReadTableNode, - selection: AbstractSet[identifiers.ColumnId], -) -> bigframes.core.nodes.ReadTableNode: - new_scan_list = filter_scanlist(node.scan_list, selection) + node: nodes.ReadTableNode, + selection: typing.AbstractSet[identifiers.ColumnId], +) -> nodes.ReadTableNode: + new_scan_list = node.scan_list.filter_cols(selection) return dataclasses.replace(node, scan_list=new_scan_list) - - -def filter_scanlist( - scanlist: bigframes.core.nodes.ScanList, - ids: AbstractSet[identifiers.ColumnId], -): - result = bigframes.core.nodes.ScanList( - tuple(item for item in scanlist.items if item.id in ids) - ) - if len(result.items) == 0: - # We need to select something, or stuff breaks - result = bigframes.core.nodes.ScanList(scanlist.items[:1]) - return result diff --git a/bigframes/core/rewrite/scan_reduction.py b/bigframes/core/rewrite/scan_reduction.py new file mode 100644 index 0000000000..b0729337e7 --- /dev/null +++ b/bigframes/core/rewrite/scan_reduction.py @@ -0,0 +1,71 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import dataclasses +import functools +from typing import Optional + +from bigframes.core import nodes +import bigframes.core.rewrite.slices + + +def try_reduce_to_table_scan(root: nodes.BigFrameNode) -> Optional[nodes.ReadTableNode]: + for node in root.unique_nodes(): + if not isinstance(node, (nodes.ReadTableNode, nodes.SelectionNode)): + return None + result = root.bottom_up(merge_scan) + if isinstance(result, nodes.ReadTableNode): + return result + return None + + +def try_reduce_to_local_scan( + node: nodes.BigFrameNode, +) -> Optional[tuple[nodes.ReadLocalNode, Optional[int]]]: + """Create a ReadLocalNode with optional limit, if possible. + + Similar to ReadApiSemiExecutor._try_adapt_plan. + """ + node, limit = bigframes.core.rewrite.slices.pull_out_limit(node) + + if not all( + map( + lambda x: isinstance(x, (nodes.ReadLocalNode, nodes.SelectionNode)), + node.unique_nodes(), + ) + ): + return None + result = node.bottom_up(merge_scan) + if isinstance(result, nodes.ReadLocalNode): + return result, limit + return None + + +@functools.singledispatch +def merge_scan(node: nodes.BigFrameNode) -> nodes.BigFrameNode: + return node + + +@merge_scan.register +def _(node: nodes.SelectionNode) -> nodes.BigFrameNode: + if not isinstance(node.child, (nodes.ReadTableNode, nodes.ReadLocalNode)): + return node + if node.has_multi_referenced_ids: + return node + if isinstance(node, nodes.ReadLocalNode) and node.offsets_col is not None: + return node + selection = { + aliased_ref.ref.id: aliased_ref.id for aliased_ref in node.input_output_pairs + } + new_scan_list = node.child.scan_list.project(selection) + return dataclasses.replace(node.child, scan_list=new_scan_list) diff --git a/bigframes/core/rewrite/schema_binding.py b/bigframes/core/rewrite/schema_binding.py new file mode 100644 index 0000000000..d874c7c598 --- /dev/null +++ b/bigframes/core/rewrite/schema_binding.py @@ -0,0 +1,158 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import dataclasses +import typing + +from bigframes.core import agg_expressions, bigframe_node +from bigframes.core import expression as ex +from bigframes.core import nodes, ordering + + +def bind_schema_to_tree( + node: bigframe_node.BigFrameNode, +) -> bigframe_node.BigFrameNode: + return nodes.bottom_up(node, bind_schema_to_node) + + +def bind_schema_to_node( + node: bigframe_node.BigFrameNode, +) -> bigframe_node.BigFrameNode: + if isinstance(node, nodes.ProjectionNode): + bound_assignments = tuple( + (ex.bind_schema_fields(expr, node.child.field_by_id), id) + for expr, id in node.assignments + ) + return dataclasses.replace(node, assignments=bound_assignments) + + if isinstance(node, nodes.FilterNode): + bound_predicate = ex.bind_schema_fields(node.predicate, node.child.field_by_id) + return dataclasses.replace(node, predicate=bound_predicate) + + if isinstance(node, nodes.OrderByNode): + bound_bys = [] + for by in node.by: + bound_by = dataclasses.replace( + by, + scalar_expression=ex.bind_schema_fields( + by.scalar_expression, node.child.field_by_id + ), + ) + bound_bys.append(bound_by) + + return dataclasses.replace(node, by=tuple(bound_bys)) + + if isinstance(node, nodes.JoinNode): + conditions = tuple( + ( + ex.ResolvedDerefOp.from_field(node.left_child.field_by_id[left.id]), + ex.ResolvedDerefOp.from_field(node.right_child.field_by_id[right.id]), + ) + for left, right in node.conditions + ) + return dataclasses.replace( + node, + conditions=conditions, + ) + if isinstance(node, nodes.InNode): + return dataclasses.replace( + node, + left_col=ex.ResolvedDerefOp.from_field( + node.left_child.field_by_id[node.left_col.id] + ), + ) + + if isinstance(node, nodes.AggregateNode): + aggregations = [] + for aggregation, id in node.aggregations: + aggregations.append( + (_bind_schema_to_aggregation_expr(aggregation, node.child), id) + ) + + return dataclasses.replace( + node, + aggregations=tuple(aggregations), + ) + + if isinstance(node, nodes.WindowOpNode): + window_spec = dataclasses.replace( + node.window_spec, + grouping_keys=tuple( + typing.cast( + ex.DerefOp, ex.bind_schema_fields(expr, node.child.field_by_id) + ) + for expr in node.window_spec.grouping_keys + ), + ordering=tuple( + ordering.OrderingExpression( + scalar_expression=ex.bind_schema_fields( + expr.scalar_expression, node.child.field_by_id + ), + direction=expr.direction, + na_last=expr.na_last, + ) + for expr in node.window_spec.ordering + ), + ) + return dataclasses.replace( + node, + agg_exprs=tuple( + nodes.ColumnDef( + _bind_schema_to_aggregation_expr(cdef.expression, node.child), # type: ignore + cdef.id, + ) + for cdef in node.agg_exprs + ), + window_spec=window_spec, + ) + + return node + + +def _bind_schema_to_aggregation_expr( + aggregation: agg_expressions.Aggregation, + child: bigframe_node.BigFrameNode, +) -> agg_expressions.Aggregation: + assert isinstance( + aggregation, agg_expressions.Aggregation + ), f"Expected Aggregation, got {type(aggregation)}" + + if isinstance(aggregation, agg_expressions.UnaryAggregation): + return typing.cast( + agg_expressions.Aggregation, + dataclasses.replace( + aggregation, + arg=typing.cast( + ex.RefOrConstant, + ex.bind_schema_fields(aggregation.arg, child.field_by_id), + ), + ), + ) + elif isinstance(aggregation, agg_expressions.BinaryAggregation): + return typing.cast( + agg_expressions.Aggregation, + dataclasses.replace( + aggregation, + left=typing.cast( + ex.RefOrConstant, + ex.bind_schema_fields(aggregation.left, child.field_by_id), + ), + right=typing.cast( + ex.RefOrConstant, + ex.bind_schema_fields(aggregation.right, child.field_by_id), + ), + ), + ) + else: + return aggregation diff --git a/bigframes/core/rewrite/select_pullup.py b/bigframes/core/rewrite/select_pullup.py new file mode 100644 index 0000000000..415182f884 --- /dev/null +++ b/bigframes/core/rewrite/select_pullup.py @@ -0,0 +1,178 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import dataclasses +import functools +from typing import cast + +from bigframes.core import expression, identifiers, nodes + + +def defer_selection( + root: nodes.BigFrameNode, +) -> nodes.BigFrameNode: + """ + Defers SelectionNode operations in the tree, pulling them up. + + In many cases, these nodes will be merged or eliminated entirely, simplifying the overall tree. + """ + return nodes.bottom_up( + root, functools.partial(pull_up_select, prefer_source_names=True) + ) + + +def pull_up_select( + node: nodes.BigFrameNode, prefer_source_names: bool +) -> nodes.BigFrameNode: + if isinstance(node, nodes.LeafNode): + if prefer_source_names and isinstance(node, nodes.ReadTableNode): + return pull_up_source_ids(node) + else: + return node + if isinstance(node, nodes.JoinNode): + return pull_up_selects_under_join(node) + if isinstance(node, nodes.ConcatNode): + return handle_selects_under_concat(node) + if isinstance(node, nodes.UnaryNode): + return pull_up_select_unary(node) + # shouldn't hit this, but not worth crashing over + return node + + +def pull_up_source_ids(node: nodes.ReadTableNode) -> nodes.BigFrameNode: + if all(id.sql == source_id for id, source_id in node.scan_list.items): + return node + else: + source_ids = sorted( + set(scan_item.source_id for scan_item in node.scan_list.items) + ) + new_scan_list = nodes.ScanList.from_items( + [ + nodes.ScanItem(identifiers.ColumnId(source_id), source_id) + for source_id in source_ids + ] + ) + new_source = dataclasses.replace(node, scan_list=new_scan_list) + new_selection = nodes.SelectionNode( + new_source, + tuple( + nodes.AliasedRef( + expression.DerefOp(identifiers.ColumnId(source_id)), id + ) + for id, source_id in node.scan_list.items + ), + ) + return new_selection + + +def pull_up_select_unary(node: nodes.UnaryNode) -> nodes.BigFrameNode: + child = node.child + if not isinstance(child, nodes.SelectionNode): + return node + + # Schema-preserving nodes + if isinstance( + node, + ( + nodes.ReversedNode, + nodes.OrderByNode, + nodes.SliceNode, + nodes.FilterNode, + nodes.RandomSampleNode, + ), + ): + pushed_down_node: nodes.BigFrameNode = node.remap_refs( + {id: ref.id for ref, id in child.input_output_pairs} + ).replace_child(child.child) + pulled_up_select = cast( + nodes.SelectionNode, child.replace_child(pushed_down_node) + ) + return pulled_up_select + elif isinstance( + node, + ( + nodes.SelectionNode, + nodes.ResultNode, + ), + ): + return node.remap_refs( + {id: ref.id for ref, id in child.input_output_pairs} + ).replace_child(child.child) + elif isinstance(node, nodes.AggregateNode): + pushed_down_agg = node.remap_refs( + {id: ref.id for ref, id in child.input_output_pairs} + ).replace_child(child.child) + new_selection = tuple( + nodes.AliasedRef.identity(id).remap_refs( + {id: ref.id for ref, id in child.input_output_pairs} + ) + for id in node.ids + ) + return nodes.SelectionNode(pushed_down_agg, new_selection) + elif isinstance(node, nodes.ExplodeNode): + pushed_down_node = node.remap_refs( + {id: ref.id for ref, id in child.input_output_pairs} + ).replace_child(child.child) + pulled_up_select = cast( + nodes.SelectionNode, child.replace_child(pushed_down_node) + ) + if node.offsets_col: + pulled_up_select = dataclasses.replace( + pulled_up_select, + input_output_pairs=( + *pulled_up_select.input_output_pairs, + nodes.AliasedRef( + expression.DerefOp(node.offsets_col), node.offsets_col + ), + ), + ) + return pulled_up_select + elif isinstance(node, nodes.AdditiveNode): + pushed_down_node = node.replace_additive_base(child.child).remap_refs( + {id: ref.id for ref, id in child.input_output_pairs} + ) + new_selection = ( + *child.input_output_pairs, + *( + nodes.AliasedRef(expression.DerefOp(col.id), col.id) + for col in node.added_fields + ), + ) + pulled_up_select = dataclasses.replace( + child, child=pushed_down_node, input_output_pairs=new_selection + ) + return pulled_up_select + # shouldn't hit this, but not worth crashing over + return node + + +def pull_up_selects_under_join(node: nodes.JoinNode) -> nodes.JoinNode: + # Can in theory pull up selects here, but it is a bit dangerous, in particular or self-joins, when there are more transforms to do. + # TODO: Safely pull up selects above join + return node + + +def handle_selects_under_concat(node: nodes.ConcatNode) -> nodes.ConcatNode: + new_children = [] + for child in node.child_nodes: + # remove select if no-op + if not isinstance(child, nodes.SelectionNode): + new_children.append(child) + else: + inputs = (ref.id for ref in child.input_output_pairs) + if inputs == tuple(child.child.ids): + new_children.append(child.child) + else: + new_children.append(child) + return dataclasses.replace(node, children=tuple(new_children)) diff --git a/bigframes/core/rewrite/slices.py b/bigframes/core/rewrite/slices.py index 87a7720e2f..bed3a8a3f3 100644 --- a/bigframes/core/rewrite/slices.py +++ b/bigframes/core/rewrite/slices.py @@ -13,6 +13,7 @@ # limitations under the License. from __future__ import annotations +import dataclasses import functools from typing import Optional, Sequence, Tuple @@ -24,7 +25,19 @@ import bigframes.operations as ops -def pullup_limit_from_slice( +def pull_up_limits(root: nodes.ResultNode) -> nodes.ResultNode: + new_child, pulled_limit = pull_out_limit(root.child) + if new_child == root.child: + return root + elif pulled_limit is None: + return dataclasses.replace(root, child=new_child) + else: + # new child has redundant slice ops removed now + new_limit = min(pulled_limit, root.limit) if root.limit else pulled_limit + return dataclasses.replace(root, child=new_child, limit=new_limit) + + +def pull_out_limit( root: nodes.BigFrameNode, ) -> Tuple[nodes.BigFrameNode, Optional[int]]: """ @@ -40,15 +53,18 @@ def pullup_limit_from_slice( assert root.step == 1 assert root.stop is not None limit = root.stop - new_root, prior_limit = pullup_limit_from_slice(root.child) + new_root, prior_limit = pull_out_limit(root.child) if (prior_limit is not None) and (prior_limit < limit): limit = prior_limit return new_root, limit + if root.is_noop: + new_root, prior_limit = pull_out_limit(root.child) + return new_root, prior_limit elif ( isinstance(root, (nodes.SelectionNode, nodes.ProjectionNode)) and root.row_preserving ): - new_child, prior_limit = pullup_limit_from_slice(root.child) + new_child, prior_limit = pull_out_limit(root.child) if prior_limit is not None: return root.transform_children(lambda _: new_child), prior_limit # Most ops don't support pulling up slice, like filter, agg, join, etc. diff --git a/bigframes/core/rewrite/timedeltas.py b/bigframes/core/rewrite/timedeltas.py index bde1a4431c..7190810f71 100644 --- a/bigframes/core/rewrite/timedeltas.py +++ b/bigframes/core/rewrite/timedeltas.py @@ -20,6 +20,7 @@ from bigframes import dtypes from bigframes import operations as ops +from bigframes.core import agg_expressions as ex_types from bigframes.core import expression as ex from bigframes.core import nodes, schema, utils from bigframes.operations import aggregations as aggs @@ -63,11 +64,26 @@ def rewrite_timedelta_expressions(root: nodes.BigFrameNode) -> nodes.BigFrameNod if isinstance(root, nodes.WindowOpNode): return nodes.WindowOpNode( root.child, - _rewrite_aggregation(root.expression, root.schema), + tuple( + nodes.ColumnDef( + _rewrite_aggregation(cdef.expression, root.schema), cdef.id + ) + for cdef in root.agg_exprs + ), root.window_spec, - root.output_name, - root.never_skip_nulls, - root.skip_reproject_unsafe, + ) + + if isinstance(root, nodes.AggregateNode): + updated_aggregations = tuple( + (_rewrite_aggregation(agg, root.child.schema), col_id) + for agg, col_id in root.aggregations + ) + return nodes.AggregateNode( + root.child, + updated_aggregations, + root.by_column_ids, + root.order_by, + root.dropna, ) return root @@ -98,7 +114,9 @@ def _rewrite_expressions(expr: ex.Expression, schema: schema.ArraySchema) -> _Ty def _rewrite_scalar_constant_expr(expr: ex.ScalarConstantExpression) -> _TypedExpr: - if expr.dtype is dtypes.TIMEDELTA_DTYPE: + if expr.value is None: + return _TypedExpr(ex.const(None, expr.dtype), expr.dtype) + if expr.dtype == dtypes.TIMEDELTA_DTYPE: int_repr = utils.timedelta_to_micros(expr.value) # type: ignore return _TypedExpr(ex.const(int_repr, expr.dtype), expr.dtype) @@ -135,30 +153,44 @@ def _rewrite_sub_op(left: _TypedExpr, right: _TypedExpr) -> _TypedExpr: if dtypes.is_datetime_like(left.dtype) and dtypes.is_datetime_like(right.dtype): return _TypedExpr.create_op_expr(ops.timestamp_diff_op, left, right) - if dtypes.is_datetime_like(left.dtype) and right.dtype is dtypes.TIMEDELTA_DTYPE: + if dtypes.is_datetime_like(left.dtype) and right.dtype == dtypes.TIMEDELTA_DTYPE: return _TypedExpr.create_op_expr(ops.timestamp_sub_op, left, right) + if left.dtype == dtypes.DATE_DTYPE and right.dtype == dtypes.DATE_DTYPE: + return _TypedExpr.create_op_expr(ops.date_diff_op, left, right) + + if left.dtype == dtypes.DATE_DTYPE and right.dtype == dtypes.TIMEDELTA_DTYPE: + return _TypedExpr.create_op_expr(ops.date_sub_op, left, right) + return _TypedExpr.create_op_expr(ops.sub_op, left, right) def _rewrite_add_op(left: _TypedExpr, right: _TypedExpr) -> _TypedExpr: - if dtypes.is_datetime_like(left.dtype) and right.dtype is dtypes.TIMEDELTA_DTYPE: + if dtypes.is_datetime_like(left.dtype) and right.dtype == dtypes.TIMEDELTA_DTYPE: return _TypedExpr.create_op_expr(ops.timestamp_add_op, left, right) - if left.dtype is dtypes.TIMEDELTA_DTYPE and dtypes.is_datetime_like(right.dtype): + if left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_datetime_like(right.dtype): # Re-arrange operands such that timestamp is always on the left and timedelta is # always on the right. return _TypedExpr.create_op_expr(ops.timestamp_add_op, right, left) + if left.dtype == dtypes.DATE_DTYPE and right.dtype == dtypes.TIMEDELTA_DTYPE: + return _TypedExpr.create_op_expr(ops.date_add_op, left, right) + + if left.dtype == dtypes.TIMEDELTA_DTYPE and right.dtype == dtypes.DATE_DTYPE: + # Re-arrange operands such that date is always on the left and timedelta is + # always on the right. + return _TypedExpr.create_op_expr(ops.date_add_op, right, left) + return _TypedExpr.create_op_expr(ops.add_op, left, right) def _rewrite_mul_op(left: _TypedExpr, right: _TypedExpr) -> _TypedExpr: result = _TypedExpr.create_op_expr(ops.mul_op, left, right) - if left.dtype is dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): + if left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): return _TypedExpr.create_op_expr(ops.timedelta_floor_op, result) - if dtypes.is_numeric(left.dtype) and right.dtype is dtypes.TIMEDELTA_DTYPE: + if dtypes.is_numeric(left.dtype) and right.dtype == dtypes.TIMEDELTA_DTYPE: return _TypedExpr.create_op_expr(ops.timedelta_floor_op, result) return result @@ -167,7 +199,7 @@ def _rewrite_mul_op(left: _TypedExpr, right: _TypedExpr) -> _TypedExpr: def _rewrite_div_op(left: _TypedExpr, right: _TypedExpr) -> _TypedExpr: result = _TypedExpr.create_op_expr(ops.div_op, left, right) - if left.dtype is dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): + if left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): return _TypedExpr.create_op_expr(ops.timedelta_floor_op, result) return result @@ -176,14 +208,14 @@ def _rewrite_div_op(left: _TypedExpr, right: _TypedExpr) -> _TypedExpr: def _rewrite_floordiv_op(left: _TypedExpr, right: _TypedExpr) -> _TypedExpr: result = _TypedExpr.create_op_expr(ops.floordiv_op, left, right) - if left.dtype is dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): + if left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): return _TypedExpr.create_op_expr(ops.timedelta_floor_op, result) return result def _rewrite_to_timedelta_op(op: ops.ToTimedeltaOp, arg: _TypedExpr): - if arg.dtype is dtypes.TIMEDELTA_DTYPE: + if arg.dtype == dtypes.TIMEDELTA_DTYPE: # Do nothing for values that are already timedeltas return arg @@ -192,21 +224,43 @@ def _rewrite_to_timedelta_op(op: ops.ToTimedeltaOp, arg: _TypedExpr): @functools.cache def _rewrite_aggregation( - aggregation: ex.Aggregation, schema: schema.ArraySchema -) -> ex.Aggregation: - if not isinstance(aggregation, ex.UnaryAggregation): - return aggregation - if not isinstance(aggregation.op, aggs.DiffOp): + aggregation: ex_types.Aggregation, schema: schema.ArraySchema +) -> ex_types.Aggregation: + if not isinstance(aggregation, ex_types.UnaryAggregation): return aggregation if isinstance(aggregation.arg, ex.DerefOp): input_type = schema.get_type(aggregation.arg.id.sql) else: - input_type = aggregation.arg.dtype + input_type = aggregation.arg.output_type + + if isinstance(aggregation.op, aggs.DiffOp): + if dtypes.is_datetime_like(input_type): + return ex_types.UnaryAggregation( + aggs.TimeSeriesDiffOp(aggregation.op.periods), aggregation.arg + ) + elif input_type == dtypes.DATE_DTYPE: + return ex_types.UnaryAggregation( + aggs.DateSeriesDiffOp(aggregation.op.periods), aggregation.arg + ) + + if isinstance(aggregation.op, aggs.StdOp) and input_type == dtypes.TIMEDELTA_DTYPE: + return ex_types.UnaryAggregation( + aggs.StdOp(should_floor_result=True), aggregation.arg + ) + + if isinstance(aggregation.op, aggs.MeanOp) and input_type == dtypes.TIMEDELTA_DTYPE: + return ex_types.UnaryAggregation( + aggs.MeanOp(should_floor_result=True), aggregation.arg + ) - if dtypes.is_datetime_like(input_type): - return ex.UnaryAggregation( - aggs.TimeSeriesDiffOp(aggregation.op.periods), aggregation.arg + if ( + isinstance(aggregation.op, aggs.QuantileOp) + and input_type == dtypes.TIMEDELTA_DTYPE + ): + return ex_types.UnaryAggregation( + aggs.QuantileOp(q=aggregation.op.q, should_floor_result=True), + aggregation.arg, ) return aggregation diff --git a/bigframes/core/rewrite/windows.py b/bigframes/core/rewrite/windows.py new file mode 100644 index 0000000000..6e9ba0dd3d --- /dev/null +++ b/bigframes/core/rewrite/windows.py @@ -0,0 +1,76 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses + +from bigframes import operations as ops +from bigframes.core import guid, identifiers, nodes, ordering + + +def rewrite_range_rolling(node: nodes.BigFrameNode) -> nodes.BigFrameNode: + if not isinstance(node, nodes.WindowOpNode): + return node + + if not node.window_spec.is_range_bounded: + return node + + if len(node.window_spec.ordering) != 1: + raise ValueError( + "Range rolling should only be performed on exactly one column." + ) + + ordering_expr = node.window_spec.ordering[0] + + new_ordering = dataclasses.replace( + ordering_expr, + scalar_expression=ops.UnixMicros().as_expr(ordering_expr.scalar_expression), + ) + + return dataclasses.replace( + node, + window_spec=dataclasses.replace(node.window_spec, ordering=(new_ordering,)), + ) + + +def pull_out_window_order(root: nodes.BigFrameNode) -> nodes.BigFrameNode: + return root.bottom_up(rewrite_window_node) + + +def rewrite_window_node(node: nodes.BigFrameNode) -> nodes.BigFrameNode: + if not isinstance(node, nodes.WindowOpNode): + return node + if len(node.window_spec.ordering) == 0: + return node + else: + offsets_id = guid.generate_guid() + w_offsets = nodes.PromoteOffsetsNode( + node.child, identifiers.ColumnId(offsets_id) + ) + sorted_child = nodes.OrderByNode(w_offsets, node.window_spec.ordering) + new_window_node = dataclasses.replace( + node, + child=sorted_child, + window_spec=node.window_spec.without_order(force=True), + ) + w_resetted_order = nodes.OrderByNode( + new_window_node, + by=(ordering.ascending_over(identifiers.ColumnId(offsets_id)),), + is_total_order=True, + ) + w_offsets_dropped = nodes.SelectionNode( + w_resetted_order, tuple(nodes.AliasedRef.identity(id) for id in node.ids) + ) + return w_offsets_dropped diff --git a/bigframes/core/schema.py b/bigframes/core/schema.py index e3808dfffd..395ad55f49 100644 --- a/bigframes/core/schema.py +++ b/bigframes/core/schema.py @@ -17,11 +17,11 @@ from dataclasses import dataclass import functools import typing +from typing import Dict, List, Optional, Sequence import google.cloud.bigquery import pyarrow -import bigframes.core.guid import bigframes.dtypes ColumnIdentifierType = str @@ -35,19 +35,41 @@ class SchemaItem: @dataclass(frozen=True) class ArraySchema: - items: typing.Tuple[SchemaItem, ...] + items: tuple[SchemaItem, ...] + + def __iter__(self): + yield from self.items @classmethod def from_bq_table( cls, table: google.cloud.bigquery.Table, - column_type_overrides: typing.Dict[str, bigframes.dtypes.Dtype] = {}, + column_type_overrides: Optional[ + typing.Dict[str, bigframes.dtypes.Dtype] + ] = None, + columns: Optional[Sequence[str]] = None, ): + if not columns: + fields = table.schema + else: + lookup = {field.name: field for field in table.schema} + fields = [lookup[col] for col in columns] + + return ArraySchema.from_bq_schema( + fields, column_type_overrides=column_type_overrides + ) + + @classmethod + def from_bq_schema( + cls, + schema: List[google.cloud.bigquery.SchemaField], + column_type_overrides: Optional[Dict[str, bigframes.dtypes.Dtype]] = None, + ): + if column_type_overrides is None: + column_type_overrides = {} items = tuple( SchemaItem(name, column_type_overrides.get(name, dtype)) - for name, dtype in bigframes.dtypes.bf_type_from_type_kind( - table.schema - ).items() + for name, dtype in bigframes.dtypes.bf_type_from_type_kind(schema).items() ) return ArraySchema(items) @@ -63,20 +85,26 @@ def dtypes(self) -> typing.Tuple[bigframes.dtypes.Dtype, ...]: def _mapping(self) -> typing.Dict[ColumnIdentifierType, bigframes.dtypes.Dtype]: return {item.column: item.dtype for item in self.items} - def to_bigquery(self) -> typing.Tuple[google.cloud.bigquery.SchemaField, ...]: + def to_bigquery( + self, overrides: dict[bigframes.dtypes.Dtype, str] = {} + ) -> typing.Tuple[google.cloud.bigquery.SchemaField, ...]: return tuple( - bigframes.dtypes.convert_to_schema_field(item.column, item.dtype) + bigframes.dtypes.convert_to_schema_field( + item.column, item.dtype, overrides=overrides + ) for item in self.items ) - def to_pyarrow(self) -> pyarrow.Schema: + def to_pyarrow(self, use_storage_types: bool = False) -> pyarrow.Schema: fields = [] for item in self.items: pa_type = bigframes.dtypes.bigframes_dtype_to_arrow_dtype(item.dtype) + if use_storage_types: + pa_type = bigframes.dtypes.to_storage_type(pa_type) fields.append( pyarrow.field( item.column, - pa_type, + type=pa_type, nullable=not pyarrow.types.is_list(pa_type), ) ) diff --git a/bigframes/core/sequences.py b/bigframes/core/sequences.py new file mode 100644 index 0000000000..6f1b7e455b --- /dev/null +++ b/bigframes/core/sequences.py @@ -0,0 +1,105 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import collections.abc +import functools +import itertools +from typing import Iterable, Iterator, Sequence, TypeVar + +ColumnIdentifierType = str + + +T = TypeVar("T") + +# Further optimizations possible: +# * Support mapping operators +# * Support insertions and deletions + + +class ChainedSequence(collections.abc.Sequence[T]): + """ + Memory-optimized sequence from composing chain of existing sequences. + + Will use the provided parts as underlying storage - so do not mutate provided parts. + May merge small underlying parts for better access performance. + """ + + def __init__(self, *parts: Sequence[T]): + # Could build an index that makes random access faster? + self._parts: tuple[Sequence[T], ...] = tuple( + _defrag_parts(_flatten_parts(parts)) + ) + + def __getitem__(self, index): + if isinstance(index, slice): + return tuple(self)[index] + if index < 0: + index = len(self) + index + if index < 0: + raise IndexError("Index out of bounds") + + offset = 0 + for part in self._parts: + if (index - offset) < len(part): + return part[index - offset] + offset += len(part) + raise IndexError("Index out of bounds") + + @functools.cache + def __len__(self): + return sum(map(len, self._parts)) + + def __iter__(self): + for part in self._parts: + yield from part + + +def _flatten_parts(parts: Iterable[Sequence[T]]) -> Iterator[Sequence[T]]: + for part in parts: + if isinstance(part, ChainedSequence): + yield from part._parts + else: + yield part + + +# Should be a cache-friendly chunk size? +_TARGET_SIZE = 128 +_MAX_MERGABLE = 32 + + +def _defrag_parts(parts: Iterable[Sequence[T]]) -> Iterator[Sequence[T]]: + """ + Merge small chunks into larger chunks for better performance. + """ + parts_queue: list[Sequence[T]] = [] + queued_items = 0 + for part in parts: + # too big, just yield from the buffer + if len(part) > _MAX_MERGABLE: + yield from parts_queue + parts_queue = [] + queued_items = 0 + yield part + else: # can be merged, so lets add to the queue + parts_queue.append(part) + queued_items += len(part) + # if queue has reached target size, merge, dump and reset queue + if queued_items >= _TARGET_SIZE: + yield tuple(itertools.chain(*parts_queue)) + parts_queue = [] + queued_items = 0 + + yield from parts_queue diff --git a/bigframes/core/sql.py b/bigframes/core/sql/__init__.py similarity index 90% rename from bigframes/core/sql.py rename to bigframes/core/sql/__init__.py index f4de177f37..ccd2a16ddc 100644 --- a/bigframes/core/sql.py +++ b/bigframes/core/sql/__init__.py @@ -23,7 +23,7 @@ import math from typing import cast, Collection, Iterable, Mapping, Optional, TYPE_CHECKING, Union -import shapely # type: ignore +import shapely.geometry.base # type: ignore import bigframes.core.compile.googlesql as googlesql @@ -33,9 +33,33 @@ import bigframes.core.ordering +# shapely.wkt.dumps was moved to shapely.io.to_wkt in 2.0. +try: + from shapely.io import to_wkt # type: ignore +except ImportError: + from shapely.wkt import dumps # type: ignore + + to_wkt = dumps + + +SIMPLE_LITERAL_TYPES = Union[ + bytes, + str, + int, + bool, + float, + datetime.datetime, + datetime.date, + datetime.time, + decimal.Decimal, + list, +] + + ### Writing SQL Values (literals, column references, table references, etc.) -def simple_literal(value: bytes | str | int | bool | float | datetime.datetime | None): +def simple_literal(value: Union[SIMPLE_LITERAL_TYPES, None]) -> str: """Return quoted input string.""" + # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#literals if value is None: return "NULL" @@ -65,11 +89,15 @@ def simple_literal(value: bytes | str | int | bool | float | datetime.datetime | return f"DATE('{value.isoformat()}')" elif isinstance(value, datetime.time): return f"TIME(DATETIME('1970-01-01 {value.isoformat()}'))" - elif isinstance(value, shapely.Geometry): - return f"ST_GEOGFROMTEXT({simple_literal(shapely.to_wkt(value))})" + elif isinstance(value, shapely.geometry.base.BaseGeometry): + return f"ST_GEOGFROMTEXT({simple_literal(to_wkt(value))})" elif isinstance(value, decimal.Decimal): # TODO: disambiguate BIGNUMERIC based on scale and/or precision return f"CAST('{str(value)}' AS NUMERIC)" + elif isinstance(value, list): + simple_literals = [simple_literal(i) for i in value] + return f"[{', '.join(simple_literals)}]" + else: raise ValueError(f"Cannot produce literal for {value}") diff --git a/bigframes/core/sql/ml.py b/bigframes/core/sql/ml.py new file mode 100644 index 0000000000..ec55fe0426 --- /dev/null +++ b/bigframes/core/sql/ml.py @@ -0,0 +1,215 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Dict, Mapping, Optional, Union + +import bigframes.core.compile.googlesql as googlesql +import bigframes.core.sql + + +def create_model_ddl( + model_name: str, + *, + replace: bool = False, + if_not_exists: bool = False, + transform: Optional[list[str]] = None, + input_schema: Optional[Mapping[str, str]] = None, + output_schema: Optional[Mapping[str, str]] = None, + connection_name: Optional[str] = None, + options: Optional[Mapping[str, Union[str, int, float, bool, list]]] = None, + training_data: Optional[str] = None, + custom_holiday: Optional[str] = None, +) -> str: + """Encode the CREATE MODEL statement. + + See https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-create for reference. + """ + + if replace: + create = "CREATE OR REPLACE MODEL " + elif if_not_exists: + create = "CREATE MODEL IF NOT EXISTS " + else: + create = "CREATE MODEL " + + ddl = f"{create}{googlesql.identifier(model_name)}\n" + + # [TRANSFORM (select_list)] + if transform: + ddl += f"TRANSFORM ({', '.join(transform)})\n" + + # [INPUT (field_name field_type) OUTPUT (field_name field_type)] + if input_schema: + inputs = [f"{k} {v}" for k, v in input_schema.items()] + ddl += f"INPUT ({', '.join(inputs)})\n" + + if output_schema: + outputs = [f"{k} {v}" for k, v in output_schema.items()] + ddl += f"OUTPUT ({', '.join(outputs)})\n" + + # [REMOTE WITH CONNECTION {connection_name | DEFAULT}] + if connection_name: + if connection_name.upper() == "DEFAULT": + ddl += "REMOTE WITH CONNECTION DEFAULT\n" + else: + ddl += f"REMOTE WITH CONNECTION {googlesql.identifier(connection_name)}\n" + + # [OPTIONS(model_option_list)] + if options: + rendered_options = [] + for option_name, option_value in options.items(): + if isinstance(option_value, (list, tuple)): + # Handle list options like model_registry="vertex_ai" + # wait, usually options are key=value. + # if value is list, it is [val1, val2] + rendered_val = bigframes.core.sql.simple_literal(list(option_value)) + else: + rendered_val = bigframes.core.sql.simple_literal(option_value) + + rendered_options.append(f"{option_name} = {rendered_val}") + + ddl += f"OPTIONS({', '.join(rendered_options)})\n" + + # [AS {query_statement | ( training_data AS (query_statement), custom_holiday AS (holiday_statement) )}] + + if training_data: + if custom_holiday: + # When custom_holiday is present, we need named clauses + parts = [] + parts.append(f"training_data AS ({training_data})") + parts.append(f"custom_holiday AS ({custom_holiday})") + ddl += f"AS (\n {', '.join(parts)}\n)" + else: + # Just training_data is treated as the query_statement + ddl += f"AS {training_data}\n" + + return ddl + + +def _build_struct_sql( + struct_options: Mapping[str, Union[str, int, float, bool]] +) -> str: + if not struct_options: + return "" + + rendered_options = [] + for option_name, option_value in struct_options.items(): + rendered_val = bigframes.core.sql.simple_literal(option_value) + rendered_options.append(f"{rendered_val} AS {option_name}") + return f", STRUCT({', '.join(rendered_options)})" + + +def evaluate( + model_name: str, + *, + table: Optional[str] = None, + perform_aggregation: Optional[bool] = None, + horizon: Optional[int] = None, + confidence_level: Optional[float] = None, +) -> str: + """Encode the ML.EVAluate statement. + See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-evaluate for reference. + """ + struct_options: Dict[str, Union[str, int, float, bool]] = {} + if perform_aggregation is not None: + struct_options["perform_aggregation"] = perform_aggregation + if horizon is not None: + struct_options["horizon"] = horizon + if confidence_level is not None: + struct_options["confidence_level"] = confidence_level + + sql = f"SELECT * FROM ML.EVALUATE(MODEL {googlesql.identifier(model_name)}" + if table: + sql += f", ({table})" + + sql += _build_struct_sql(struct_options) + sql += ")\n" + return sql + + +def predict( + model_name: str, + table: str, + *, + threshold: Optional[float] = None, + keep_original_columns: Optional[bool] = None, + trial_id: Optional[int] = None, +) -> str: + """Encode the ML.PREDICT statement. + See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-predict for reference. + """ + struct_options = {} + if threshold is not None: + struct_options["threshold"] = threshold + if keep_original_columns is not None: + struct_options["keep_original_columns"] = keep_original_columns + if trial_id is not None: + struct_options["trial_id"] = trial_id + + sql = ( + f"SELECT * FROM ML.PREDICT(MODEL {googlesql.identifier(model_name)}, ({table})" + ) + sql += _build_struct_sql(struct_options) + sql += ")\n" + return sql + + +def explain_predict( + model_name: str, + table: str, + *, + top_k_features: Optional[int] = None, + threshold: Optional[float] = None, + integrated_gradients_num_steps: Optional[int] = None, + approx_feature_contrib: Optional[bool] = None, +) -> str: + """Encode the ML.EXPLAIN_PREDICT statement. + See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-explain-predict for reference. + """ + struct_options: Dict[str, Union[str, int, float, bool]] = {} + if top_k_features is not None: + struct_options["top_k_features"] = top_k_features + if threshold is not None: + struct_options["threshold"] = threshold + if integrated_gradients_num_steps is not None: + struct_options[ + "integrated_gradients_num_steps" + ] = integrated_gradients_num_steps + if approx_feature_contrib is not None: + struct_options["approx_feature_contrib"] = approx_feature_contrib + + sql = f"SELECT * FROM ML.EXPLAIN_PREDICT(MODEL {googlesql.identifier(model_name)}, ({table})" + sql += _build_struct_sql(struct_options) + sql += ")\n" + return sql + + +def global_explain( + model_name: str, + *, + class_level_explain: Optional[bool] = None, +) -> str: + """Encode the ML.GLOBAL_EXPLAIN statement. + See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-global-explain for reference. + """ + struct_options = {} + if class_level_explain is not None: + struct_options["class_level_explain"] = class_level_explain + + sql = f"SELECT * FROM ML.GLOBAL_EXPLAIN(MODEL {googlesql.identifier(model_name)}" + sql += _build_struct_sql(struct_options) + sql += ")\n" + return sql diff --git a/bigframes/core/tools/bigquery_schema.py b/bigframes/core/tools/bigquery_schema.py new file mode 100644 index 0000000000..eef7364a1b --- /dev/null +++ b/bigframes/core/tools/bigquery_schema.py @@ -0,0 +1,59 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Helpers for working with BigQuery SchemaFields.""" + +from typing import Tuple + +import google.cloud.bigquery + +_LEGACY_TO_GOOGLESQL_TYPES = { + "BOOLEAN": "BOOL", + "INTEGER": "INT64", + "FLOAT": "FLOAT64", +} + + +def _type_to_sql(field: google.cloud.bigquery.SchemaField): + """Turn the type information of the field into SQL. + + Ignores the mode, since this has already been handled by _field_to_sql. + """ + if field.field_type.casefold() in ("record", "struct"): + return _to_struct(field.fields) + + # Map from legacy SQL names (the ones used in the BigQuery schema API) to + # the GoogleSQL types. Importantly, FLOAT is from legacy SQL, but not valid + # in GoogleSQL. See internal issue b/428190014. + type_ = _LEGACY_TO_GOOGLESQL_TYPES.get(field.field_type.upper(), field.field_type) + return type_ + + +def _field_to_sql(field: google.cloud.bigquery.SchemaField): + if field.mode == "REPEATED": + # Unlike other types, ARRAY are represented as mode="REPEATED". To get + # the array type, we use SchemaField object but ignore the mode. + return f"`{field.name}` ARRAY<{_type_to_sql(field)}>" + + return f"`{field.name}` {_type_to_sql(field)}" + + +def _to_struct(bqschema: Tuple[google.cloud.bigquery.SchemaField, ...]): + fields = [_field_to_sql(field) for field in bqschema] + return f"STRUCT<{', '.join(fields)}>" + + +def to_sql_dry_run(bqschema: Tuple[google.cloud.bigquery.SchemaField, ...]): + """Create an empty table expression with the correct schema.""" + return f"UNNEST(ARRAY<{_to_struct(bqschema)}>[])" diff --git a/bigframes/core/tools/datetimes.py b/bigframes/core/tools/datetimes.py index 2abb86a2f3..0e5594d498 100644 --- a/bigframes/core/tools/datetimes.py +++ b/bigframes/core/tools/datetimes.py @@ -12,9 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + from collections.abc import Mapping -from datetime import datetime -from typing import Optional, Union +from datetime import date, datetime +from typing import Optional, TYPE_CHECKING, Union import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.tools.datetimes as vendored_pandas_datetimes @@ -25,10 +27,13 @@ import bigframes.operations as ops import bigframes.series +if TYPE_CHECKING: + import bigframes.session + def to_datetime( arg: Union[ - Union[int, float, str, datetime], + Union[int, float, str, datetime, date], vendored_pandas_datetimes.local_iterables, bigframes.series.Series, bigframes.dataframe.DataFrame, @@ -37,8 +42,9 @@ def to_datetime( utc: bool = False, format: Optional[str] = None, unit: Optional[str] = None, + session: Optional[bigframes.session.Session] = None, ) -> Union[pd.Timestamp, datetime, bigframes.series.Series]: - if isinstance(arg, (int, float, str, datetime)): + if isinstance(arg, (int, float, str, datetime, date)): return pd.to_datetime( arg, utc=utc, @@ -52,7 +58,7 @@ def to_datetime( f"to datetime is not implemented. {constants.FEEDBACK_LINK}" ) - arg = bigframes.series.Series(arg)._cached() + arg = bigframes.series.Series(arg, session=session) if format and unit and arg.dtype in (bigframes.dtypes.INT_DTYPE, bigframes.dtypes.FLOAT_DTYPE): # type: ignore raise ValueError("cannot specify both format and unit") @@ -62,7 +68,11 @@ def to_datetime( f"Unit parameter is not supported for non-numerical input types. {constants.FEEDBACK_LINK}" ) - if arg.dtype in (bigframes.dtypes.TIMESTAMP_DTYPE, bigframes.dtypes.DATETIME_DTYPE): + if arg.dtype in ( + bigframes.dtypes.TIMESTAMP_DTYPE, + bigframes.dtypes.DATETIME_DTYPE, + bigframes.dtypes.DATE_DTYPE, + ): to_type = ( bigframes.dtypes.TIMESTAMP_DTYPE if utc else bigframes.dtypes.DATETIME_DTYPE ) @@ -74,6 +84,11 @@ def to_datetime( ) assert unit is None + + # The following operations evaluate individual values to infer a format, + # so cache if needed. + arg = arg._cached(force=False) + as_datetime = arg._apply_unary_op( # type: ignore ops.ToDatetimeOp( format=format, diff --git a/bigframes/core/tree_properties.py b/bigframes/core/tree_properties.py index 82df53af82..baf4b12566 100644 --- a/bigframes/core/tree_properties.py +++ b/bigframes/core/tree_properties.py @@ -45,26 +45,13 @@ def can_fast_head(node: nodes.BigFrameNode) -> bool: # To do fast head operation: # (1) the underlying data must be arranged/indexed according to the logical ordering # (2) transformations must support pushing down LIMIT or a filter on row numbers - return has_fast_offset_address(node) or has_fast_offset_address(node) - - -def has_fast_orderby_limit(node: nodes.BigFrameNode) -> bool: - """True iff ORDER BY LIMIT can be performed without a large full table scan.""" - # TODO: In theory compatible with some Slice nodes, potentially by adding OFFSET - if isinstance(node, nodes.LeafNode): - return node.fast_ordered_limit - if isinstance(node, (nodes.ProjectionNode, nodes.SelectionNode)): - return has_fast_orderby_limit(node.child) - return False - - -def has_fast_offset_address(node: nodes.BigFrameNode) -> bool: - """True iff specific offsets can be scanned without a large full table scan.""" - # TODO: In theory can push offset lookups through slice operators by translating indices - if isinstance(node, nodes.LeafNode): - return node.fast_offsets + if isinstance(node, nodes.ReadLocalNode): + # always cheap to push slice into local data + return True + if isinstance(node, nodes.ReadTableNode): + return (node.source.ordering is None) or (node.fast_ordered_limit) if isinstance(node, (nodes.ProjectionNode, nodes.SelectionNode)): - return has_fast_offset_address(node.child) + return can_fast_head(node.child) return False diff --git a/bigframes/core/utils.py b/bigframes/core/utils.py index 502a40d92d..dd37a352a7 100644 --- a/bigframes/core/utils.py +++ b/bigframes/core/utils.py @@ -21,10 +21,8 @@ import bigframes_vendored.pandas.io.common as vendored_pandas_io_common import numpy as np import pandas as pd -import pandas.api.types as pdtypes import typing_extensions -import bigframes.dtypes as dtypes import bigframes.exceptions as bfe UNNAMED_COLUMN_ID = "bigframes_unnamed_column" @@ -43,8 +41,10 @@ def get_axis_number(axis: typing.Union[str, int]) -> typing.Literal[0, 1]: raise ValueError(f"Not a valid axis: {axis}") -def is_list_like(obj: typing.Any) -> typing_extensions.TypeGuard[typing.Sequence]: - return pd.api.types.is_list_like(obj) +def is_list_like( + obj: typing.Any, allow_sets: bool = True +) -> typing_extensions.TypeGuard[typing.Sequence]: + return pd.api.types.is_list_like(obj, allow_sets=allow_sets) def is_dict_like(obj: typing.Any) -> typing_extensions.TypeGuard[typing.Mapping]: @@ -52,7 +52,7 @@ def is_dict_like(obj: typing.Any) -> typing_extensions.TypeGuard[typing.Mapping] def combine_indices(index1: pd.Index, index2: pd.Index) -> pd.MultiIndex: - """Combines indices into multi-index while preserving dtypes, names.""" + """Combines indices into multi-index while preserving dtypes, names merging by rows 1:1""" multi_index = pd.MultiIndex.from_frame( pd.concat([index1.to_frame(index=False), index2.to_frame(index=False)], axis=1) ) @@ -61,6 +61,20 @@ def combine_indices(index1: pd.Index, index2: pd.Index) -> pd.MultiIndex: return multi_index +def cross_indices(index1: pd.Index, index2: pd.Index) -> pd.MultiIndex: + """Combines indices into multi-index while preserving dtypes, names using cross product""" + multi_index = pd.MultiIndex.from_frame( + pd.merge( + left=index1.to_frame(index=False), + right=index2.to_frame(index=False), + how="cross", + ) + ) + # to_frame will produce numbered default names, we don't want these + multi_index.names = [*index1.names, *index2.names] + return multi_index + + def index_as_tuples(index: pd.Index) -> typing.Sequence[typing.Tuple]: if isinstance(index, pd.MultiIndex): return [label for label in index] @@ -130,6 +144,35 @@ def label_to_identifier(label: typing.Hashable, strict: bool = False) -> str: identifier = re.sub(r"[^a-zA-Z0-9_]", "", identifier) if not identifier: identifier = "id" + elif identifier[0].isdigit(): + # first character must be letter or underscore + identifier = "_" + identifier + + else: + # Even with flexible column names, there are constraints + # Convert illegal characters + # See: https://cloud.google.com/bigquery/docs/schemas#flexible-column-names + identifier = re.sub(r"[!\"$\(\)\*\,\./;\?@[\]^`{}~]", "_", identifier) + + # Except in special circumstances (true anonymous query results tables), + # field names are not allowed to start with these (case-insensitive) + # prefixes. + # _PARTITION, _TABLE_, _FILE_, _ROW_TIMESTAMP, __ROOT__ and _COLIDENTIFIER + if any( + identifier.casefold().startswith(invalid_prefix.casefold()) + for invalid_prefix in ( + "_PARTITION", + "_TABLE_", + "_FILE_", + "_ROW_TIMESTAMP", + "__ROOT__", + "_COLIDENTIFIER", + ) + ): + # Remove leading _ character(s) to avoid collisions with preserved + # prefixes. + identifier = re.sub("^_+", "", identifier) + return identifier @@ -182,7 +225,7 @@ def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): - warnings.warn(msg, category=bfe.PreviewWarning) + warnings.warn(bfe.format_message(msg), category=bfe.PreviewWarning) return func(*args, **kwargs) return wrapper @@ -206,45 +249,3 @@ def timedelta_to_micros( ) * 1_000_000 + timedelta.microseconds raise TypeError(f"Unrecognized input type: {type(timedelta)}") - - -def replace_timedeltas_with_micros(dataframe: pd.DataFrame) -> List[str]: - """ - Replaces in-place timedeltas to integer values in microseconds. Nanosecond part is ignored. - - Returns: - The names of updated columns - """ - updated_columns = [] - - for col in dataframe.columns: - if pdtypes.is_timedelta64_dtype(dataframe[col].dtype): - dataframe[col] = dataframe[col].apply(timedelta_to_micros) - updated_columns.append(col) - - if pdtypes.is_timedelta64_dtype(dataframe.index.dtype): - dataframe.index = dataframe.index.map(timedelta_to_micros) - updated_columns.append(dataframe.index.name) - - return updated_columns - - -def replace_json_with_string(dataframe: pd.DataFrame) -> List[str]: - """ - Due to a BigQuery IO limitation with loading JSON from Parquet files (b/374784249), - we're using a workaround: storing JSON as strings and then parsing them into JSON - objects. - TODO(b/395912450): Remove workaround solution once b/374784249 got resolved. - """ - updated_columns = [] - - for col in dataframe.columns: - if dataframe[col].dtype == dtypes.JSON_DTYPE: - dataframe[col] = dataframe[col].astype(dtypes.STRING_DTYPE) - updated_columns.append(col) - - if dataframe.index.dtype == dtypes.JSON_DTYPE: - dataframe.index = dataframe.index.astype(dtypes.STRING_DTYPE) - updated_columns.append(dataframe.index.name) - - return updated_columns diff --git a/bigframes/core/validations.py b/bigframes/core/validations.py index 701752c9fc..e6fdcb7bd5 100644 --- a/bigframes/core/validations.py +++ b/bigframes/core/validations.py @@ -27,7 +27,7 @@ from bigframes import Session from bigframes.core.blocks import Block from bigframes.dataframe import DataFrame - from bigframes.operations.base import SeriesMethods + from bigframes.series import Series class HasSession(Protocol): @@ -42,7 +42,7 @@ def _block(self) -> Block: def requires_index(meth): @functools.wraps(meth) - def guarded_meth(df: Union[DataFrame, SeriesMethods], *args, **kwargs): + def guarded_meth(df: Union[DataFrame, Series], *args, **kwargs): df._throw_if_null_index(meth.__name__) return meth(df, *args, **kwargs) diff --git a/bigframes/core/window/__init__.py b/bigframes/core/window/__init__.py index 7758145fd4..1d888ca7e6 100644 --- a/bigframes/core/window/__init__.py +++ b/bigframes/core/window/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2023 Google LLC +# Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,86 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations +from bigframes.core.window.rolling import Window -import typing - -import bigframes_vendored.pandas.core.window.rolling as vendored_pandas_rolling - -from bigframes.core import log_adapter, window_spec -import bigframes.core.blocks as blocks -import bigframes.operations.aggregations as agg_ops - - -@log_adapter.class_logger -class Window(vendored_pandas_rolling.Window): - __doc__ = vendored_pandas_rolling.Window.__doc__ - - def __init__( - self, - block: blocks.Block, - window_spec: window_spec.WindowSpec, - value_column_ids: typing.Sequence[str], - drop_null_groups: bool = True, - is_series: bool = False, - ): - self._block = block - self._window_spec = window_spec - self._value_column_ids = value_column_ids - self._drop_null_groups = drop_null_groups - self._is_series = is_series - - def count(self): - return self._apply_aggregate(agg_ops.count_op) - - def sum(self): - return self._apply_aggregate(agg_ops.sum_op) - - def mean(self): - return self._apply_aggregate(agg_ops.mean_op) - - def var(self): - return self._apply_aggregate(agg_ops.var_op) - - def std(self): - return self._apply_aggregate(agg_ops.std_op) - - def max(self): - return self._apply_aggregate(agg_ops.max_op) - - def min(self): - return self._apply_aggregate(agg_ops.min_op) - - def _apply_aggregate( - self, - op: agg_ops.UnaryAggregateOp, - ): - block = self._block - labels = [block.col_id_to_label[col] for col in self._value_column_ids] - block, result_ids = block.multi_apply_window_op( - self._value_column_ids, - op, - self._window_spec, - skip_null_groups=self._drop_null_groups, - never_skip_nulls=True, - ) - - if self._window_spec.grouping_keys: - original_index_ids = block.index_columns - block = block.reset_index(drop=False) - index_ids = ( - *[col.id.name for col in self._window_spec.grouping_keys], - *original_index_ids, - ) - block = block.set_index(col_ids=index_ids) - - if self._is_series: - from bigframes.series import Series - - return Series(block.select_columns(result_ids).with_column_labels(labels)) - else: - from bigframes.dataframe import DataFrame - - return DataFrame( - block.select_columns(result_ids).with_column_labels(labels) - ) +__all__ = ["Window"] diff --git a/bigframes/core/window/ordering.py b/bigframes/core/window/ordering.py new file mode 100644 index 0000000000..0bea585bb0 --- /dev/null +++ b/bigframes/core/window/ordering.py @@ -0,0 +1,86 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from functools import singledispatch + +from bigframes.core import expression as ex +from bigframes.core import nodes, ordering + + +@singledispatch +def find_order_direction( + root: nodes.BigFrameNode, column_id: str +) -> ordering.OrderingDirection | None: + """Returns the order of the given column with tree traversal. If the column cannot be found, + or the ordering information is not available, return None. + """ + return None + + +@find_order_direction.register +def _(root: nodes.OrderByNode, column_id: str): + if len(root.by) == 0: + # This is a no-op + return find_order_direction(root.child, column_id) + + # Make sure the window key is the prefix of sorting keys. + order_expr = root.by[0] + scalar_expr = order_expr.scalar_expression + if isinstance(scalar_expr, ex.DerefOp) and scalar_expr.id.name == column_id: + return order_expr.direction + + return None + + +@find_order_direction.register +def _(root: nodes.ReversedNode, column_id: str): + direction = find_order_direction(root.child, column_id) + + if direction is None: + return None + return direction.reverse() + + +@find_order_direction.register +def _(root: nodes.SelectionNode, column_id: str): + for alias_ref in root.input_output_pairs: + if alias_ref.id.name == column_id: + return find_order_direction(root.child, alias_ref.ref.id.name) + + +@find_order_direction.register +def _(root: nodes.FilterNode, column_id: str): + return find_order_direction(root.child, column_id) + + +@find_order_direction.register +def _(root: nodes.InNode, column_id: str): + return find_order_direction(root.left_child, column_id) + + +@find_order_direction.register +def _(root: nodes.WindowOpNode, column_id: str): + return find_order_direction(root.child, column_id) + + +@find_order_direction.register +def _(root: nodes.ProjectionNode, column_id: str): + for expr, ref in root.assignments: + if ref.name == column_id and isinstance(expr, ex.DerefOp): + # This source column is renamed. + return find_order_direction(root.child, expr.id.name) + + return find_order_direction(root.child, column_id) diff --git a/bigframes/core/window/rolling.py b/bigframes/core/window/rolling.py new file mode 100644 index 0000000000..d6c77bf0a7 --- /dev/null +++ b/bigframes/core/window/rolling.py @@ -0,0 +1,276 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import datetime +from typing import Literal, Mapping, Sequence, TYPE_CHECKING, Union + +import bigframes_vendored.pandas.core.window.rolling as vendored_pandas_rolling +import numpy +import pandas + +from bigframes import dtypes +from bigframes.core import agg_expressions +from bigframes.core import expression as ex +from bigframes.core import log_adapter, ordering, utils, window_spec +import bigframes.core.blocks as blocks +from bigframes.core.window import ordering as window_ordering +import bigframes.operations.aggregations as agg_ops + +if TYPE_CHECKING: + import bigframes.dataframe as df + import bigframes.series as series + + +@log_adapter.class_logger +class Window(vendored_pandas_rolling.Window): + __doc__ = vendored_pandas_rolling.Window.__doc__ + + def __init__( + self, + block: blocks.Block, + window_spec: window_spec.WindowSpec, + value_column_ids: Sequence[str], + drop_null_groups: bool = True, + is_series: bool = False, + skip_agg_column_id: str | None = None, + ): + self._block = block + self._window_spec = window_spec + self._value_column_ids = value_column_ids + self._drop_null_groups = drop_null_groups + self._is_series = is_series + # The column ID that won't be aggregated on. + # This is equivalent to pandas `on` parameter in rolling() + self._skip_agg_column_id = skip_agg_column_id + + def count(self): + return self._apply_aggregate_op(agg_ops.count_op) + + def sum(self): + return self._apply_aggregate_op(agg_ops.sum_op) + + def mean(self): + return self._apply_aggregate_op(agg_ops.mean_op) + + def var(self): + return self._apply_aggregate_op(agg_ops.var_op) + + def std(self): + return self._apply_aggregate_op(agg_ops.std_op) + + def max(self): + return self._apply_aggregate_op(agg_ops.max_op) + + def min(self): + return self._apply_aggregate_op(agg_ops.min_op) + + def agg(self, func) -> Union[df.DataFrame, series.Series]: + if utils.is_dict_like(func): + return self._agg_dict(func) + elif utils.is_list_like(func): + return self._agg_list(func) + else: + return self._agg_func(func) + + aggregate = agg + + def _agg_func(self, func) -> df.DataFrame: + ids, labels = self._aggregated_columns() + aggregations = [agg(col_id, agg_ops.lookup_agg_func(func)[0]) for col_id in ids] + return self._apply_aggs(aggregations, labels) + + def _agg_dict(self, func: Mapping) -> df.DataFrame: + aggregations: list[agg_expressions.Aggregation] = [] + column_labels = [] + function_labels = [] + + want_aggfunc_level = any(utils.is_list_like(aggs) for aggs in func.values()) + + for label, funcs_for_id in func.items(): + col_id = self._block.label_to_col_id[label][-1] # get last matching column + func_list = ( + funcs_for_id if utils.is_list_like(funcs_for_id) else [funcs_for_id] + ) + for f in func_list: + f_op, f_label = agg_ops.lookup_agg_func(f) + aggregations.append(agg(col_id, f_op)) + column_labels.append(label) + function_labels.append(f_label) + if want_aggfunc_level: + result_labels: pandas.Index = utils.combine_indices( + pandas.Index(column_labels), + pandas.Index(function_labels), + ) + else: + result_labels = pandas.Index(column_labels) + + return self._apply_aggs(aggregations, result_labels) + + def _agg_list(self, func: Sequence) -> df.DataFrame: + ids, labels = self._aggregated_columns() + aggregations = [ + agg(col_id, agg_ops.lookup_agg_func(f)[0]) for col_id in ids for f in func + ] + + if self._is_series: + # if series, no need to rebuild + result_cols_idx = pandas.Index( + [agg_ops.lookup_agg_func(f)[1] for f in func] + ) + else: + if self._block.column_labels.nlevels > 1: + # Restructure MultiIndex for proper format: (idx1, idx2, func) + # rather than ((idx1, idx2), func). + column_labels = [ + tuple(label) + (agg_ops.lookup_agg_func(f)[1],) + for label in labels.to_frame(index=False).to_numpy() + for f in func + ] + else: # Single-level index + column_labels = [ + (label, agg_ops.lookup_agg_func(f)[1]) + for label in labels + for f in func + ] + result_cols_idx = pandas.MultiIndex.from_tuples( + column_labels, names=[*self._block.column_labels.names, None] + ) + return self._apply_aggs(aggregations, result_cols_idx) + + def _apply_aggs( + self, exprs: Sequence[agg_expressions.Aggregation], labels: pandas.Index + ): + block, ids = self._block.apply_analytic( + agg_exprs=exprs, + window=self._window_spec, + result_labels=labels, + skip_null_groups=self._drop_null_groups, + ) + + if self._window_spec.grouping_keys: + original_index_ids = block.index_columns + block = block.reset_index(drop=False) + # grouping keys will always be direct column references, but we should probably + # refactor this class to enforce this statically + index_ids = ( + *[col.id.name for col in self._window_spec.grouping_keys], # type: ignore + *original_index_ids, + ) + block = block.set_index(col_ids=index_ids) + + if self._skip_agg_column_id is not None: + block = block.select_columns([self._skip_agg_column_id, *ids]) + else: + block = block.select_columns(ids).with_column_labels(labels) + + if self._is_series and (len(block.value_columns) == 1): + import bigframes.series as series + + return series.Series(block) + else: + import bigframes.dataframe as df + + return df.DataFrame(block) + + def _apply_aggregate_op( + self, + op: agg_ops.UnaryAggregateOp, + ): + ids, labels = self._aggregated_columns() + aggregations = [agg(col_id, op) for col_id in ids] + return self._apply_aggs(aggregations, labels) + + def _aggregated_columns(self) -> tuple[Sequence[str], pandas.Index]: + agg_col_ids = [ + col_id + for col_id in self._value_column_ids + if col_id != self._skip_agg_column_id + ] + labels: pandas.Index = pandas.Index( + [self._block.col_id_to_label[col] for col in agg_col_ids] + ) + return agg_col_ids, labels + + +def create_range_window( + block: blocks.Block, + window: pandas.Timedelta | numpy.timedelta64 | datetime.timedelta | str, + *, + value_column_ids: Sequence[str] = tuple(), + min_periods: int | None, + on: str | None = None, + closed: Literal["right", "left", "both", "neither"], + is_series: bool, + grouping_keys: Sequence[str] = tuple(), + drop_null_groups: bool = True, +) -> Window: + + if on is None: + # Rolling on index + index_dtypes = block.index.dtypes + if len(index_dtypes) > 1: + raise ValueError("Range rolling on MultiIndex is not supported") + if index_dtypes[0] != dtypes.TIMESTAMP_DTYPE: + raise ValueError("Index type should be timestamps with timezones") + rolling_key_col_id = block.index_columns[0] + else: + # Rolling on a specific column + rolling_key_col_id = block.resolve_label_exact_or_error(on) + if block.expr.get_column_type(rolling_key_col_id) != dtypes.TIMESTAMP_DTYPE: + raise ValueError(f"Column {on} type should be timestamps with timezones") + + order_direction = window_ordering.find_order_direction( + block.expr.node, rolling_key_col_id + ) + if order_direction is None: + target_str = "index" if on is None else f"column {on}" + raise ValueError( + f"The {target_str} might not be in a monotonic order. Please sort by {target_str} before rolling." + ) + if isinstance(window, str): + window = pandas.Timedelta(window) + spec = window_spec.WindowSpec( + bounds=window_spec.RangeWindowBounds.from_timedelta_window(window, closed), + min_periods=1 if min_periods is None else min_periods, + ordering=( + ordering.OrderingExpression(ex.deref(rolling_key_col_id), order_direction), + ), + grouping_keys=tuple(ex.deref(col) for col in grouping_keys), + ) + + selected_value_col_ids = ( + value_column_ids if value_column_ids else block.value_columns + ) + # This step must be done after finding the order direction of the window key. + if grouping_keys: + block = block.order_by([ordering.ascending_over(col) for col in grouping_keys]) + + return Window( + block, + spec, + value_column_ids=selected_value_col_ids, + is_series=is_series, + skip_agg_column_id=None if on is None else rolling_key_col_id, + drop_null_groups=drop_null_groups, + ) + + +def agg(input: str, op: agg_ops.AggregateOp) -> agg_expressions.Aggregation: + if isinstance(op, agg_ops.UnaryAggregateOp): + return agg_expressions.UnaryAggregation(op, ex.deref(input)) + else: + assert isinstance(op, agg_ops.NullaryAggregateOp) + return agg_expressions.NullaryAggregation(op) diff --git a/bigframes/core/window_spec.py b/bigframes/core/window_spec.py index b4a3d35471..9e4ee17103 100644 --- a/bigframes/core/window_spec.py +++ b/bigframes/core/window_spec.py @@ -14,8 +14,12 @@ from __future__ import annotations from dataclasses import dataclass, replace +import datetime import itertools -from typing import Mapping, Optional, Set, Tuple, Union +from typing import Callable, Literal, Mapping, Optional, Sequence, Set, Tuple, Union + +import numpy as np +import pandas as pd import bigframes.core.expression as ex import bigframes.core.identifiers as ids @@ -52,8 +56,8 @@ def unbound( ### Rows-based Windows def rows( grouping_keys: Tuple[str, ...] = (), - preceding: Optional[int] = None, - following: Optional[int] = None, + start: Optional[int] = None, + end: Optional[int] = None, min_periods: int = 0, ordering: Tuple[orderings.OrderingExpression, ...] = (), ) -> WindowSpec: @@ -63,10 +67,12 @@ def rows( Args: grouping_keys: Columns ids of grouping keys - preceding: - number of preceding rows to include. If None, include all preceding rows + start: + The window's starting boundary relative to the current row. For example, "-1" means one row prior + "1" means one row after, and "0" means the current row. If None, the window is unbounded from the start. following: - number of following rows to include. If None, include all following rows + The window's ending boundary relative to the current row. For example, "-1" means one row prior + "1" means one row after, and "0" means the current row. If None, the window is unbounded until the end. min_periods (int, default 0): Minimum number of input rows to generate output. ordering: @@ -74,7 +80,10 @@ def rows( Returns: WindowSpec """ - bounds = RowsWindowBounds(preceding=preceding, following=following) + bounds = RowsWindowBounds( + start=start, + end=end, + ) return WindowSpec( grouping_keys=tuple(map(ex.deref, grouping_keys)), bounds=bounds, @@ -97,7 +106,7 @@ def cumulative_rows( Returns: WindowSpec """ - bounds = RowsWindowBounds(following=0) + bounds = RowsWindowBounds(end=0) return WindowSpec( grouping_keys=tuple(map(ex.deref, grouping_keys)), bounds=bounds, @@ -119,7 +128,7 @@ def inverse_cumulative_rows( Returns: WindowSpec """ - bounds = RowsWindowBounds(preceding=0) + bounds = RowsWindowBounds(start=0) return WindowSpec( grouping_keys=tuple(map(ex.deref, grouping_keys)), bounds=bounds, @@ -132,44 +141,129 @@ def inverse_cumulative_rows( @dataclass(frozen=True) class RowsWindowBounds: - preceding: Optional[int] = None - following: Optional[int] = None + start: Optional[int] = None + end: Optional[int] = None + @classmethod + def from_window_size( + cls, window: int, closed: Literal["right", "left", "both", "neither"] + ) -> RowsWindowBounds: + if closed == "right": + return cls(-(window - 1), 0) + elif closed == "left": + return cls(-window, -1) + elif closed == "both": + return cls(-window, 0) + elif closed == "neither": + return cls(-(window - 1), -1) + else: + raise ValueError(f"Unsupported value for 'closed' parameter: {closed}") -# TODO: Expand to datetime offsets -OffsetType = Union[float, int] + def __post_init__(self): + if self.start is None: + return + if self.end is None: + return + if self.start > self.end: + raise ValueError( + f"Invalid window: start({self.start}) is greater than end({self.end})" + ) @dataclass(frozen=True) class RangeWindowBounds: - preceding: Optional[OffsetType] = None - following: Optional[OffsetType] = None + """Represents a time range window, inclusively bounded by start and end""" + + start: pd.Timedelta | None = None + end: pd.Timedelta | None = None + + @classmethod + def from_timedelta_window( + cls, + window: pd.Timedelta | np.timedelta64 | datetime.timedelta, + closed: Literal["right", "left", "both", "neither"], + ) -> RangeWindowBounds: + window = pd.Timedelta(window) + tick = pd.Timedelta("1us") + zero = pd.Timedelta(0) + + if closed == "right": + return cls(-(window - tick), zero) + elif closed == "left": + return cls(-window, -tick) + elif closed == "both": + return cls(-window, zero) + elif closed == "neither": + return cls(-(window - tick), -tick) + else: + raise ValueError(f"Unsupported value for 'closed' parameter: {closed}") + + def __post_init__(self): + if self.start is None: + return + if self.end is None: + return + if self.start > self.end: + raise ValueError( + f"Invalid window: start({self.start}) is greater than end({self.end})" + ) @dataclass(frozen=True) class WindowSpec: """ Specifies a window over which aggregate and analytic function may be applied. - grouping_keys: set of column ids to group on - preceding: Number of preceding rows in the window - following: Number of preceding rows in the window - ordering: List of columns ids and ordering direction to override base ordering + + Attributes: + grouping_keys: A set of columns to group on + bounds: The window boundaries + ordering: A list of columns ids and ordering direction to override base ordering + min_periods: The minimum number of observations in window required to have a value """ - grouping_keys: Tuple[ex.DerefOp, ...] = tuple() + grouping_keys: Tuple[ex.Expression, ...] = tuple() ordering: Tuple[orderings.OrderingExpression, ...] = tuple() bounds: Union[RowsWindowBounds, RangeWindowBounds, None] = None min_periods: int = 0 @property - def row_bounded(self): + def is_row_bounded(self): """ Whether the window is bounded by row offsets. This is relevant for determining whether the window requires a total order to calculate deterministically. """ - return isinstance(self.bounds, RowsWindowBounds) + return isinstance(self.bounds, RowsWindowBounds) and ( + (self.bounds.start is not None) or (self.bounds.end is not None) + ) + + @property + def is_range_bounded(self): + """ + Whether the window is bounded by range offsets. + + This is relevant for determining whether the window requires a total order + to calculate deterministically. + """ + return isinstance(self.bounds, RangeWindowBounds) + + @property + def is_unbounded(self): + """ + Whether the window is unbounded. + + This is relevant for determining whether the window requires a total order + to calculate deterministically. + """ + return self.bounds is None or ( + self.bounds.start is None and self.bounds.end is None + ) + + @property + def expressions(self) -> Sequence[ex.Expression]: + ordering_exprs = (item.scalar_expression for item in self.ordering) + return (*self.grouping_keys, *ordering_exprs) @property def all_referenced_columns(self) -> Set[ids.ColumnId]: @@ -179,11 +273,14 @@ def all_referenced_columns(self) -> Set[ids.ColumnId]: ordering_vars = itertools.chain.from_iterable( item.scalar_expression.column_references for item in self.ordering ) - return set(itertools.chain((i.id for i in self.grouping_keys), ordering_vars)) + grouping_vars = itertools.chain.from_iterable( + item.column_references for item in self.grouping_keys + ) + return set(itertools.chain(grouping_vars, ordering_vars)) - def without_order(self) -> WindowSpec: + def without_order(self, force: bool = False) -> WindowSpec: """Removes ordering clause if ordering isn't required to define bounds.""" - if self.row_bounded: + if self.is_row_bounded and not force: raise ValueError("Cannot remove order from row-bounded window") return replace(self, ordering=()) @@ -204,3 +301,15 @@ def remap_column_refs( bounds=self.bounds, min_periods=self.min_periods, ) + + def transform_exprs( + self: WindowSpec, t: Callable[[ex.Expression], ex.Expression] + ) -> WindowSpec: + return WindowSpec( + grouping_keys=tuple(t(key) for key in self.grouping_keys), + ordering=tuple( + order_part.transform_exprs(t) for order_part in self.ordering + ), + bounds=self.bounds, + min_periods=self.min_periods, + ) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index c02b182ee3..9efc6ba061 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -19,19 +19,22 @@ import datetime import inspect import itertools -import json import re import sys import textwrap import typing from typing import ( + Any, Callable, + cast, Dict, + Hashable, Iterable, List, Literal, Mapping, Optional, + overload, Sequence, Tuple, Union, @@ -45,14 +48,14 @@ import google.cloud.bigquery as bigquery import numpy import pandas +from pandas.api import extensions as pd_ext import pandas.io.formats.format import pyarrow import tabulate -import bigframes._config.display_options as display_options import bigframes.constants import bigframes.core -from bigframes.core import log_adapter +from bigframes.core import agg_expressions, log_adapter import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks import bigframes.core.convert @@ -62,29 +65,39 @@ import bigframes.core.guid import bigframes.core.indexers as indexers import bigframes.core.indexes as indexes +import bigframes.core.interchange import bigframes.core.ordering as order import bigframes.core.utils as utils import bigframes.core.validations as validations import bigframes.core.window +from bigframes.core.window import rolling import bigframes.core.window_spec as windows import bigframes.dtypes import bigframes.exceptions as bfe import bigframes.formatting_helpers as formatter +import bigframes.functions +from bigframes.functions import function_typing import bigframes.operations as ops -import bigframes.operations.aggregations import bigframes.operations.aggregations as agg_ops +import bigframes.operations.ai import bigframes.operations.plotting as plotting import bigframes.operations.semantics import bigframes.operations.structs import bigframes.series import bigframes.session._io.bigquery +import bigframes.session.execution_spec as ex_spec if typing.TYPE_CHECKING: from _typeshed import SupportsRichComparison import bigframes.session - SingleItemValue = Union[bigframes.series.Series, int, float, str, Callable] + SingleItemValue = Union[ + bigframes.series.Series, int, float, str, pandas.Timedelta, Callable + ] + MultiItemValue = Union[ + "DataFrame", Sequence[int | float | str | pandas.Timedelta | Callable] + ] LevelType = typing.Hashable LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] @@ -191,6 +204,9 @@ def __init__( block = block.multi_apply_unary_op(ops.AsTypeOp(to_type=bf_dtype)) else: + if isinstance(dtype, str) and dtype.lower() == "json": + dtype = bigframes.dtypes.JSON_DTYPE + import bigframes.pandas pd_dataframe = pandas.DataFrame( @@ -302,7 +318,9 @@ def at(self) -> indexers.AtDataFrameIndexer: @property def dtypes(self) -> pandas.Series: - return pandas.Series(data=self._block.dtypes, index=self._block.column_labels) + dtypes = self._block.dtypes + bigframes.dtypes.warn_on_db_dtypes_json_dtype(dtypes) + return pandas.Series(data=dtypes, index=self._block.column_labels) @property def columns(self) -> pandas.Index: @@ -366,6 +384,9 @@ def __len__(self): def __iter__(self): return iter(self.columns) + def __contains__(self, key) -> bool: + return key in self.columns + def astype( self, dtype: Union[ @@ -392,6 +413,21 @@ def astype( return self._apply_unary_op(ops.AsTypeOp(dtype, safe_cast)) + def _should_sql_have_index(self) -> bool: + """Should the SQL we pass to BQML and other I/O include the index?""" + + return self._has_index and ( + self.index.name is not None or len(self.index.names) > 1 + ) + + def _to_placeholder_table(self, dry_run: bool = False) -> bigquery.TableReference: + """Compiles this DataFrame's expression tree to SQL and saves it to a + (temporary) view or table (in the case of a dry run). + """ + return self._block.to_placeholder_table( + include_index=self._should_sql_have_index(), dry_run=dry_run + ) + def _to_sql_query( self, include_index: bool, enable_cache: bool = True ) -> Tuple[str, list[str], list[blocks.Label]]: @@ -417,11 +453,21 @@ def sql(self) -> str: str: string representing the compiled SQL. """ - include_index = self._has_index and ( - self.index.name is not None or len(self.index.names) > 1 - ) - sql, _, _ = self._to_sql_query(include_index=include_index) - return sql + try: + include_index = self._should_sql_have_index() + sql, _, _ = self._to_sql_query(include_index=include_index) + return sql + except AttributeError as e: + # Workaround for a development-mode debugging issue: + # An `AttributeError` originating *inside* this @property getter (e.g., due to + # a typo or referencing a non-existent attribute) can be mistakenly intercepted + # by the class's __getattr__ method if one is defined. + # We catch the AttributeError and raise SyntaxError instead to make it clear + # the error originates *here* in the property implementation. + # See: https://stackoverflow.com/questions/50542177/correct-handling-of-attributeerror-in-getattr-when-using-property + raise SyntaxError( + "AttributeError encountered. Please check the implementation for incorrect attribute access." + ) from e @property def query_job(self) -> Optional[bigquery.QueryJob]: @@ -447,7 +493,6 @@ def memory_usage(self, index: bool = True): column_sizes = pandas.concat([index_size, column_sizes]) return column_sizes - @validations.requires_index def info( self, verbose: Optional[bool] = None, @@ -470,12 +515,23 @@ def info( obuf.write(f"{type(self)}\n") - index_type = "MultiIndex" if self.index.nlevels > 1 else "Index" + if self._block.has_index: + index_type = "MultiIndex" if self.index.nlevels > 1 else "Index" + + index_stats = f"{n_rows} entries" + if n_rows > 0: + # These accessses are kind of expensive, maybe should try to skip? + first_indice = self.index[0] + last_indice = self.index[-1] + index_stats += f", {first_indice} to {last_indice}" + obuf.write(f"{index_type}: {index_stats}\n") + else: + obuf.write("NullIndex\n") - # These accessses are kind of expensive, maybe should try to skip? - first_indice = self.index[0] - last_indice = self.index[-1] - obuf.write(f"{index_type}: {n_rows} entries, {first_indice} to {last_indice}\n") + if n_columns == 0: + # We don't display any more information if the dataframe has no columns + obuf.write("Empty DataFrame\n") + return dtype_strings = self.dtypes.astype("string") if show_all_columns: @@ -532,28 +588,58 @@ def select_dtypes(self, include=None, exclude=None) -> DataFrame: ) return DataFrame(self._block.select_columns(selected_columns)) - def _select_exact_dtypes( - self, dtypes: Sequence[bigframes.dtypes.Dtype] - ) -> DataFrame: - """Selects columns without considering inheritance relationships.""" - columns = [ - col_id - for col_id, dtype in zip(self._block.value_columns, self._block.dtypes) - if dtype in dtypes - ] - return DataFrame(self._block.select_columns(columns)) - def _set_internal_query_job(self, query_job: Optional[bigquery.QueryJob]): self._query_job = query_job + @overload + def __getitem__( + self, + key: bigframes.series.Series, + ) -> DataFrame: + ... + + @overload + def __getitem__( + self, + key: slice, + ) -> DataFrame: + ... + + @overload + def __getitem__( + self, + key: List[str], + ) -> DataFrame: + ... + + @overload + def __getitem__( + self, + key: List[blocks.Label], + ) -> DataFrame: + ... + + @overload + def __getitem__(self, key: pandas.Index) -> DataFrame: + ... + + @overload + def __getitem__( + self, + key: blocks.Label, + ) -> bigframes.series.Series: + ... + def __getitem__( self, key: Union[ blocks.Label, - Sequence[blocks.Label], + List[str], + List[blocks.Label], # Index of column labels can be treated the same as a sequence of column labels. pandas.Index, bigframes.series.Series, + slice, ], ): # No return type annotations (like pandas) as type cannot always be determined statically # NOTE: This implements the operations described in @@ -562,24 +648,23 @@ def __getitem__( if isinstance(key, bigframes.series.Series): return self._getitem_bool_series(key) - if isinstance(key, typing.Hashable): + if isinstance(key, slice): + return self.iloc[key] + + # TODO(tswast): Fix this pylance warning: Class overlaps "Hashable" + # unsafely and could produce a match at runtime + if isinstance(key, blocks.Label): return self._getitem_label(key) - # Select a subset of columns or re-order columns. - # In Ibis after you apply a projection, any column objects from the - # table before the projection can't be combined with column objects - # from the table after the projection. This is because the table after - # a projection is considered a totally separate table expression. - # - # This is unexpected behavior for a pandas user, who expects their old - # Series objects to still work with the new / mutated DataFrame. We - # avoid applying a projection in Ibis until it's absolutely necessary - # to provide pandas-like semantics. - # TODO(swast): Do we need to apply implicit join when doing a - # projection? - # Select a number of columns as DF. - key = key if utils.is_list_like(key) else [key] # type:ignore + if utils.is_list_like(key): + return self._getitem_columns(key) + else: + # TODO(tswast): What case is this supposed to be handling? + return self._getitem_columns([cast(Hashable, key)]) + + __getitem__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__getitem__) + def _getitem_columns(self, key: Sequence[blocks.Label]) -> DataFrame: selected_ids: Tuple[str, ...] = () for label in key: col_ids = self._block.label_to_col_id[label] @@ -587,12 +672,12 @@ def __getitem__( return DataFrame(self._block.select_columns(selected_ids)) - __getitem__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__getitem__) - def _getitem_label(self, key: blocks.Label): col_ids = self._block.cols_matching_label(key) if len(col_ids) == 0: - raise KeyError(key) + raise KeyError( + f"{key} not found in DataFrame columns: {self._block.column_labels}" + ) block = self._block.select_columns(col_ids) if isinstance(self.columns, pandas.MultiIndex): # Multiindex should drop-level if not selecting entire @@ -712,112 +797,57 @@ def __repr__(self) -> str: ) self._set_internal_query_job(query_job) + from bigframes.display import plaintext + + return plaintext.create_text_representation( + pandas_df, + row_count, + is_series=False, + has_index=self._has_index, + column_count=len(self.columns), + ) - column_count = len(pandas_df.columns) - - with display_options.pandas_repr(opts): - import pandas.io.formats - - # safe to mutate this, this dict is owned by this code, and does not affect global config - to_string_kwargs = ( - pandas.io.formats.format.get_dataframe_repr_params() # type: ignore - ) - if not self._has_index: - to_string_kwargs.update({"index": False}) - repr_string = pandas_df.to_string(**to_string_kwargs) - - # Modify the end of the string to reflect count. - lines = repr_string.split("\n") - pattern = re.compile("\\[[0-9]+ rows x [0-9]+ columns\\]") - if pattern.match(lines[-1]): - lines = lines[:-2] - - if row_count > len(lines) - 1: - lines.append("...") - - lines.append("") - lines.append(f"[{row_count} rows x {column_count} columns]") - return "\n".join(lines) - - def _repr_html_(self) -> str: - """ - Returns an html string primarily for use by notebooks for displaying - a representation of the DataFrame. Displays 20 rows by default since - many notebooks are not configured for large tables. - """ - opts = bigframes.options.display - max_results = opts.max_rows - if opts.repr_mode == "deferred": - return formatter.repr_query_job(self._compute_dry_run()) - - df = self.copy() - if bigframes.options.experiments.blob: + def _get_display_df_and_blob_cols(self) -> tuple[DataFrame, list[str]]: + """Process blob columns for display.""" + df = self + blob_cols = [] + if bigframes.options.display.blob_display: blob_cols = [ - col - for col in df.columns - if df[col].dtype == bigframes.dtypes.OBJ_REF_DTYPE + series_name + for series_name, series in self.items() + if series.dtype == bigframes.dtypes.OBJ_REF_DTYPE ] - for col in blob_cols: - # TODO(garrettwu): Not necessary to get access urls for all the rows. Update when having a to get URLs from local data. - df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True) - - # TODO(swast): pass max_columns and get the true column count back. Maybe - # get 1 more column than we have requested so that pandas can add the - # ... for us? - pandas_df, row_count, query_job = df._block.retrieve_repr_request_results( - max_results - ) - - self._set_internal_query_job(query_job) - - column_count = len(pandas_df.columns) - - with display_options.pandas_repr(opts): - # Allows to preview images in the DataFrame. The implementation changes the string repr as well, that it doesn't truncate strings or escape html charaters such as "<" and ">". We may need to implement a full-fledged repr module to better support types not in pandas. - if bigframes.options.experiments.blob: - - def obj_ref_rt_to_html(obj_ref_rt) -> str: - obj_ref_rt_json = json.loads(obj_ref_rt) - gcs_metadata = obj_ref_rt_json["objectref"]["details"][ - "gcs_metadata" - ] - content_type = typing.cast( - str, gcs_metadata.get("content_type", "") - ) - if content_type.startswith("image"): - url = obj_ref_rt_json["access_urls"]["read_url"] - return f'' - - return f'uri: {obj_ref_rt_json["objectref"]["uri"]}, authorizer: {obj_ref_rt_json["objectref"]["authorizer"]}' - - formatters = {blob_col: obj_ref_rt_to_html for blob_col in blob_cols} - - # set max_colwidth so not to truncate the image url - with pandas.option_context("display.max_colwidth", None): - max_rows = pandas.get_option("display.max_rows") - max_cols = pandas.get_option("display.max_columns") - show_dimensions = pandas.get_option("display.show_dimensions") - html_string = pandas_df.to_html( - escape=False, - notebook=True, - max_rows=max_rows, - max_cols=max_cols, - show_dimensions=show_dimensions, - formatters=formatters, # type: ignore - ) - else: - # _repr_html_ stub is missing so mypy thinks it's a Series. Ignore mypy. - html_string = pandas_df._repr_html_() # type:ignore + if blob_cols: + df = self.copy() + for col in blob_cols: + # TODO(garrettwu): Not necessary to get access urls for all the rows. Update when having a to get URLs from local data. + df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True) + return df, blob_cols + + def _repr_mimebundle_(self, include=None, exclude=None): + """ + Custom display method for IPython/Jupyter environments. + This is called by IPython's display system when the object is displayed. + """ + # TODO(b/467647693): Anywidget integration has been tested in Jupyter, VS Code, and + # BQ Studio, but there is a known compatibility issue with Marimo that needs to be addressed. + from bigframes.display import html - html_string += f"[{row_count} rows x {column_count} columns in total]" - return html_string + return html.repr_mimebundle(self, include=include, exclude=exclude) def __delitem__(self, key: str): df = self.drop(columns=[key]) self._set_block(df._get_block()) - def __setitem__(self, key: str, value: SingleItemValue): - df = self._assign_single_item(key, value) + def __setitem__( + self, + key: str | list[str] | pandas.Index, + value: SingleItemValue | MultiItemValue, + ): + if isinstance(key, (list, pandas.Index)): + df = self._assign_multi_items(key, value) + else: + df = self._assign_single_item(key, value) self._set_block(df._get_block()) __setitem__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__setitem__) @@ -995,14 +1025,17 @@ def radd( ) -> DataFrame: # TODO(swast): Support fill_value parameter. # TODO(swast): Support level parameter with MultiIndex. - return self.add(other, axis=axis) + return self._apply_binop(other, ops.add_op, axis=axis, reverse=True) def __add__(self, other) -> DataFrame: return self.add(other) __add__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__add__) - __radd__ = __add__ + def __radd__(self, other) -> DataFrame: + return self.radd(other) + + __radd__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__radd__) def sub( self, @@ -1177,6 +1210,11 @@ def __pos__(self) -> DataFrame: def __neg__(self) -> DataFrame: return self._apply_unary_op(ops.neg_op) + def __abs__(self) -> DataFrame: + return self._apply_unary_op(ops.abs_op) + + __abs__.__doc__ = abs.__doc__ + def align( self, other: typing.Union[DataFrame, bigframes.series.Series], @@ -1270,6 +1308,37 @@ def combine( def combine_first(self, other: DataFrame): return self._apply_dataframe_binop(other, ops.fillna_op) + def _fast_stat_matrix(self, op: agg_ops.BinaryAggregateOp) -> DataFrame: + """Faster corr, cov calculations, but creates more sql text, so cannot scale to many columns""" + assert len(self.columns) * len(self.columns) < bigframes.constants.MAX_COLUMNS + orig_columns = self.columns + frame = self.copy() + # Replace column names with 0 to n - 1 to keep order + # and avoid the influence of duplicated column name + frame.columns = pandas.Index(range(len(orig_columns))) + frame = frame.astype(bigframes.dtypes.FLOAT_DTYPE) + block = frame._block + + aggregations = [ + agg_expressions.BinaryAggregation( + op, ex.deref(left_col), ex.deref(right_col) + ) + for left_col in block.value_columns + for right_col in block.value_columns + ] + # unique columns stops + uniq_orig_columns = utils.combine_indices( + orig_columns, pandas.Index(range(len(orig_columns))) + ) + labels = utils.cross_indices(uniq_orig_columns, uniq_orig_columns) + + block = block.aggregate(aggregations=aggregations, column_labels=labels) + + block = block.stack(levels=orig_columns.nlevels + 1) + # The aggregate operation crated a index level with just 0, need to drop it + # Also, drop the last level of each index, which was created to guarantee uniqueness + return DataFrame(block).droplevel(0).droplevel(-1, axis=0).droplevel(-1, axis=1) + def corr(self, method="pearson", min_periods=None, numeric_only=False) -> DataFrame: if method != "pearson": raise NotImplementedError( @@ -1285,6 +1354,10 @@ def corr(self, method="pearson", min_periods=None, numeric_only=False) -> DataFr else: frame = self._drop_non_numeric() + if len(frame.columns) <= 30: + return frame._fast_stat_matrix(agg_ops.CorrOp()) + + frame = frame.copy() orig_columns = frame.columns # Replace column names with 0 to n - 1 to keep order # and avoid the influence of duplicated column name @@ -1393,6 +1466,10 @@ def cov(self, *, numeric_only: bool = False) -> DataFrame: else: frame = self._drop_non_numeric() + if len(frame.columns) <= 30: + return frame._fast_stat_matrix(agg_ops.CovOp()) + + frame = frame.copy() orig_columns = frame.columns # Replace column names with 0 to n - 1 to keep order # and avoid the influence of duplicated column name @@ -1510,9 +1587,9 @@ def corrwith( r_block.column_labels, how="outer" ).difference(labels) - block, _ = block.aggregate( + block = block.aggregate( aggregations=tuple( - ex.BinaryAggregation(agg_ops.CorrOp(), left_ex, right_ex) + agg_expressions.BinaryAggregation(agg_ops.CorrOp(), left_ex, right_ex) for left_ex, right_ex in expr_pairs ), column_labels=labels, @@ -1525,10 +1602,16 @@ def corrwith( ) return bigframes.pandas.Series(block) + def __dataframe__( + self, nan_as_null: bool = False, allow_copy: bool = True + ) -> bigframes.core.interchange.InterchangeDataFrame: + return bigframes.core.interchange.InterchangeDataFrame._from_bigframes(self) + def to_arrow( self, *, ordered: bool = True, + allow_large_results: Optional[bool] = None, ) -> pyarrow.Table: """Write DataFrame to an Arrow table / record batch. @@ -1536,17 +1619,52 @@ def to_arrow( ordered (bool, default True): Determines whether the resulting Arrow table will be ordered. In some cases, unordered may result in a faster-executing query. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large query results + over the default size limit of 10 GB. Returns: pyarrow.Table: A pyarrow Table with all rows and columns of this DataFrame. """ - msg = "to_arrow is in preview. Types and unnamed / duplicate name columns may change in future." + msg = bfe.format_message( + "to_arrow is in preview. Types and unnamed or duplicate name columns may " + "change in future." + ) warnings.warn(msg, category=bfe.PreviewWarning) - pa_table, query_job = self._block.to_arrow(ordered=ordered) - self._set_internal_query_job(query_job) + pa_table, query_job = self._block.to_arrow( + ordered=ordered, allow_large_results=allow_large_results + ) + if query_job: + self._set_internal_query_job(query_job) return pa_table + @overload + def to_pandas( # type: ignore[overload-overlap] + self, + max_download_size: Optional[int] = ..., + sampling_method: Optional[str] = ..., + random_state: Optional[int] = ..., + *, + ordered: bool = ..., + dry_run: Literal[False] = ..., + allow_large_results: Optional[bool] = ..., + ) -> pandas.DataFrame: + ... + + @overload + def to_pandas( + self, + max_download_size: Optional[int] = ..., + sampling_method: Optional[str] = ..., + random_state: Optional[int] = ..., + *, + ordered: bool = ..., + dry_run: Literal[True] = ..., + allow_large_results: Optional[bool] = ..., + ) -> pandas.Series: + ... + def to_pandas( self, max_download_size: Optional[int] = None, @@ -1554,57 +1672,166 @@ def to_pandas( random_state: Optional[int] = None, *, ordered: bool = True, - ) -> pandas.DataFrame: + dry_run: bool = False, + allow_large_results: Optional[bool] = None, + ) -> pandas.DataFrame | pandas.Series: """Write DataFrame to pandas DataFrame. + **Examples:** + + >>> df = bpd.DataFrame({'col': [4, 2, 2]}) + + Download the data from BigQuery and convert it into an in-memory pandas DataFrame. + + >>> df.to_pandas() + col + 0 4 + 1 2 + 2 2 + + Estimate job statistics without processing or downloading data by using `dry_run=True`. + + >>> df.to_pandas(dry_run=True) # doctest: +SKIP + columnCount 1 + columnDtypes {'col': Int64} + indexLevel 1 + indexDtypes [Int64] + projectId bigframes-dev + location US + jobType QUERY + destinationTable {'projectId': 'bigframes-dev', 'datasetId': '_... + useLegacySql False + referencedTables None + totalBytesProcessed 0 + cacheHit False + statementType SELECT + creationTime 2025-04-02 20:17:12.038000+00:00 + dtype: object + Args: max_download_size (int, default None): - Download size threshold in MB. If max_download_size is exceeded when downloading data - (e.g., to_pandas()), the data will be downsampled if - bigframes.options.sampling.enable_downsampling is True, otherwise, an error will be - raised. If set to a value other than None, this will supersede the global config. + .. deprecated:: 2.0.0 + ``max_download_size`` parameter is deprecated. Please use ``to_pandas_batches()`` + method instead. + + Download size threshold in MB. If ``max_download_size`` is exceeded when downloading data, + the data will be downsampled if ``bigframes.options.sampling.enable_downsampling`` is + ``True``, otherwise, an error will be raised. If set to a value other than ``None``, + this will supersede the global config. sampling_method (str, default None): + .. deprecated:: 2.0.0 + ``sampling_method`` parameter is deprecated. Please use ``sample()`` method instead. + Downsampling algorithms to be chosen from, the choices are: "head": This algorithm returns a portion of the data from the beginning. It is fast and requires minimal computations to perform the downsampling; "uniform": This algorithm returns uniform random samples of the data. If set to a value other than None, this will supersede the global config. random_state (int, default None): + .. deprecated:: 2.0.0 + ``random_state`` parameter is deprecated. Please use ``sample()`` method instead. + The seed for the uniform downsampling algorithm. If provided, the uniform method may take longer to execute and require more computation. If set to a value other than None, this will supersede the global config. ordered (bool, default True): Determines whether the resulting pandas dataframe will be ordered. In some cases, unordered may result in a faster-executing query. + dry_run (bool, default False): + If this argument is true, this method will not process the data. Instead, it returns + a Pandas Series containing dry run statistics + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large query results + over the default size limit of 10 GB. Returns: pandas.DataFrame: A pandas DataFrame with all rows and columns of this DataFrame if the data_sampling_threshold_mb is not exceeded; otherwise, a pandas DataFrame with - downsampled rows and all columns of this DataFrame. + downsampled rows and all columns of this DataFrame. If dry_run is set, a pandas + Series containing dry run statistics will be returned. """ - # TODO(orrbradford): Optimize this in future. Potentially some cases where we can return the stored query job + if max_download_size is not None: + msg = bfe.format_message( + "DEPRECATED: The `max_download_size` parameters for `DataFrame.to_pandas()` " + "are deprecated and will be removed soon. Please use `DataFrame.to_pandas_batches()`." + ) + warnings.warn(msg, category=FutureWarning) + if sampling_method is not None or random_state is not None: + msg = bfe.format_message( + "DEPRECATED: The `sampling_method` and `random_state` parameters for " + "`DataFrame.to_pandas()` are deprecated and will be removed soon. " + "Please use `DataFrame.sample().to_pandas()` instead for sampling." + ) + warnings.warn(msg, category=FutureWarning, stacklevel=2) + + if dry_run: + dry_run_stats, dry_run_job = self._block._compute_dry_run( + max_download_size=max_download_size, + sampling_method=sampling_method, + random_state=random_state, + ordered=ordered, + ) + self._set_internal_query_job(dry_run_job) + return dry_run_stats + df, query_job = self._block.to_pandas( max_download_size=max_download_size, sampling_method=sampling_method, random_state=random_state, ordered=ordered, + allow_large_results=allow_large_results, ) - self._set_internal_query_job(query_job) + if query_job: + self._set_internal_query_job(query_job) return df.set_axis(self._block.column_labels, axis=1, copy=False) def to_pandas_batches( - self, page_size: Optional[int] = None, max_results: Optional[int] = None - ) -> Iterable[pandas.DataFrame]: + self, + page_size: Optional[int] = None, + max_results: Optional[int] = None, + *, + allow_large_results: Optional[bool] = None, + ) -> blocks.PandasBatches: """Stream DataFrame results to an iterable of pandas DataFrame. page_size and max_results determine the size and number of batches, see https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJob#google_cloud_bigquery_job_QueryJob_result + **Examples:** + + >>> df = bpd.DataFrame({'col': [4, 3, 2, 2, 3]}) + + Iterate through the results in batches, limiting the total rows yielded + across all batches via `max_results`: + + >>> for df_batch in df.to_pandas_batches(max_results=3): + ... print(df_batch) + col + 0 4 + 1 3 + 2 2 + + Alternatively, control the approximate size of each batch using `page_size` + and fetch batches manually using `next()`: + + >>> it = df.to_pandas_batches(page_size=2) + >>> next(it) + col + 0 4 + 1 3 + >>> next(it) + col + 2 2 + 3 2 + Args: page_size (int, default None): - The size of each batch. + The maximum number of rows of each batch. Non-positive values are ignored. max_results (int, default None): - If given, only download this many rows at maximum. + The maximum total number of rows of all batches. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large query results + over the default size limit of 10 GB. Returns: Iterable[pandas.DataFrame]: @@ -1612,12 +1839,28 @@ def to_pandas_batches( form the original dataframe. Results stream from bigquery, see https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.table.RowIterator#google_cloud_bigquery_table_RowIterator_to_arrow_iterable """ + return self._to_pandas_batches( + page_size=page_size, + max_results=max_results, + allow_large_results=allow_large_results, + ) + + def _to_pandas_batches( + self, + page_size: Optional[int] = None, + max_results: Optional[int] = None, + *, + allow_large_results: Optional[bool] = None, + ) -> blocks.PandasBatches: return self._block.to_pandas_batches( - page_size=page_size, max_results=max_results + page_size=page_size, + max_results=max_results, + allow_large_results=allow_large_results, ) def _compute_dry_run(self) -> bigquery.QueryJob: - return self._block._compute_dry_run() + _, query_job = self._block._compute_dry_run() + return query_job def copy(self) -> DataFrame: return DataFrame(self._block) @@ -1630,7 +1873,9 @@ def head(self, n: int = 5) -> DataFrame: def tail(self, n: int = 5) -> DataFrame: return typing.cast(DataFrame, self.iloc[-n:]) - def peek(self, n: int = 5, *, force: bool = True) -> pandas.DataFrame: + def peek( + self, n: int = 5, *, force: bool = True, allow_large_results=None + ) -> pandas.DataFrame: """ Preview n arbitrary rows from the dataframe. No guarantees about row selection or ordering. ``DataFrame.peek(force=False)`` will always be very fast, but will not succeed if data requires @@ -1643,17 +1888,22 @@ def peek(self, n: int = 5, *, force: bool = True) -> pandas.DataFrame: force (bool, default True): If the data cannot be peeked efficiently, the dataframe will instead be fully materialized as part of the operation if ``force=True``. If ``force=False``, the operation will throw a ValueError. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large query results + over the default size limit of 10 GB. Returns: pandas.DataFrame: A pandas DataFrame with n rows. Raises: ValueError: If force=False and data cannot be efficiently peeked. """ - maybe_result = self._block.try_peek(n) + maybe_result = self._block.try_peek(n, allow_large_results=allow_large_results) if maybe_result is None: if force: self._cached() - maybe_result = self._block.try_peek(n, force=True) + maybe_result = self._block.try_peek( + n, force=True, allow_large_results=allow_large_results + ) assert maybe_result is not None else: raise ValueError( @@ -1715,6 +1965,7 @@ def insert( self._set_block(block) + @overload def drop( self, labels: typing.Any = None, @@ -1723,7 +1974,33 @@ def drop( index: typing.Any = None, columns: Union[blocks.Label, Sequence[blocks.Label]] = None, level: typing.Optional[LevelType] = None, + inplace: Literal[False] = False, ) -> DataFrame: + ... + + @overload + def drop( + self, + labels: typing.Any = None, + *, + axis: typing.Union[int, str] = 0, + index: typing.Any = None, + columns: Union[blocks.Label, Sequence[blocks.Label]] = None, + level: typing.Optional[LevelType] = None, + inplace: Literal[True], + ) -> None: + ... + + def drop( + self, + labels: typing.Any = None, + *, + axis: typing.Union[int, str] = 0, + index: typing.Any = None, + columns: Union[blocks.Label, Sequence[blocks.Label]] = None, + level: typing.Optional[LevelType] = None, + inplace: bool = False, + ) -> Optional[DataFrame]: if labels: if index or columns: raise ValueError("Cannot specify both 'labels' and 'index'/'columns") @@ -1765,7 +2042,11 @@ def drop( inverse_condition_id, ops.invert_op ) elif isinstance(index, indexes.Index): - return self._drop_by_index(index) + dropped_block = self._drop_by_index(index)._get_block() + if inplace: + self._set_block(dropped_block) + return None + return DataFrame(dropped_block) else: block, condition_id = block.project_expr( ops.ne_op.as_expr(level_id, ex.const(index)) @@ -1777,7 +2058,12 @@ def drop( block = block.drop_columns(self._sql_names(columns)) if index is None and not columns: raise ValueError("Must specify 'labels' or 'index'/'columns") - return DataFrame(block) + + if inplace: + self._set_block(block) + return None + else: + return DataFrame(block) def _drop_by_index(self, index: indexes.Index) -> DataFrame: block = index._block @@ -1846,15 +2132,67 @@ def reorder_levels(self, order: LevelsType, axis: int | str = 0): def _resolve_levels(self, level: LevelsType) -> typing.Sequence[str]: return self._block.index.resolve_level(level) + @overload def rename(self, *, columns: Mapping[blocks.Label, blocks.Label]) -> DataFrame: + ... + + @overload + def rename( + self, *, columns: Mapping[blocks.Label, blocks.Label], inplace: Literal[False] + ) -> DataFrame: + ... + + @overload + def rename( + self, *, columns: Mapping[blocks.Label, blocks.Label], inplace: Literal[True] + ) -> None: + ... + + def rename( + self, *, columns: Mapping[blocks.Label, blocks.Label], inplace: bool = False + ) -> Optional[DataFrame]: block = self._block.rename(columns=columns) - return DataFrame(block) + if inplace: + self._block = block + return None + else: + return DataFrame(block) + + @overload def rename_axis( self, mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], + ) -> DataFrame: + ... + + @overload + def rename_axis( + self, + mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], + *, + inplace: Literal[False], **kwargs, ) -> DataFrame: + ... + + @overload + def rename_axis( + self, + mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], + *, + inplace: Literal[True], + **kwargs, + ) -> None: + ... + + def rename_axis( + self, + mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], + *, + inplace: bool = False, + **kwargs, + ) -> Optional[DataFrame]: if len(kwargs) != 0: raise NotImplementedError( f"rename_axis does not currently support any keyword arguments. {constants.FEEDBACK_LINK}" @@ -1864,7 +2202,14 @@ def rename_axis( labels = mapper else: labels = [mapper] - return DataFrame(self._block.with_index_labels(labels)) + + block = self._block.with_index_labels(labels) + + if inplace: + self._block = block + return None + else: + return DataFrame(block) @validations.requires_ordering() def equals(self, other: typing.Union[bigframes.series.Series, DataFrame]) -> bool: @@ -1886,7 +2231,7 @@ def assign(self, **kwargs) -> DataFrame: def _assign_single_item( self, k: str, - v: SingleItemValue, + v: SingleItemValue | MultiItemValue, ) -> DataFrame: if isinstance(v, bigframes.series.Series): return self._assign_series_join_on_index(k, v) @@ -1904,7 +2249,33 @@ def _assign_single_item( elif utils.is_list_like(v): return self._assign_single_item_listlike(k, v) else: - return self._assign_scalar(k, v) + return self._assign_scalar(k, v) # type: ignore + + def _assign_multi_items( + self, + k: list[str] | pandas.Index, + v: SingleItemValue | MultiItemValue, + ) -> DataFrame: + value_sources: Sequence[Any] = [] + if isinstance(v, DataFrame): + value_sources = [v[col] for col in v.columns] + elif isinstance(v, bigframes.series.Series): + # For behavior consistency with Pandas. + raise ValueError("Columns must be same length as key") + elif isinstance(v, Sequence): + value_sources = v + else: + # We assign the same scalar value to all target columns. + value_sources = [v] * len(k) + + if len(value_sources) != len(k): + raise ValueError("Columns must be same length as key") + + # Repeatedly assign columns in order. + result = self._assign_single_item(k[0], value_sources[0]) + for target, source in zip(k[1:], value_sources[1:]): + result = result._assign_single_item(target, source) + return result def _assign_single_item_listlike(self, k: str, v: Sequence) -> DataFrame: given_rows = len(v) @@ -1989,9 +2360,80 @@ def _assign_series_join_on_index( return DataFrame(block.with_index_labels(self._block.index.names)) - def reset_index(self, *, drop: bool = False) -> DataFrame: - block = self._block.reset_index(drop) - return DataFrame(block) + @overload # type: ignore[override] + def reset_index( + self, + level: blocks.LevelsType = ..., + drop: bool = ..., + inplace: Literal[False] = ..., + col_level: Union[int, str] = ..., + col_fill: Hashable = ..., + allow_duplicates: Optional[bool] = ..., + names: Union[None, Hashable, Sequence[Hashable]] = ..., + ) -> DataFrame: + ... + + @overload + def reset_index( + self, + level: blocks.LevelsType = ..., + drop: bool = ..., + inplace: Literal[True] = ..., + col_level: Union[int, str] = ..., + col_fill: Hashable = ..., + allow_duplicates: Optional[bool] = ..., + names: Union[None, Hashable, Sequence[Hashable]] = ..., + ) -> None: + ... + + @overload + def reset_index( + self, + level: blocks.LevelsType = None, + drop: bool = False, + inplace: bool = ..., + col_level: Union[int, str] = ..., + col_fill: Hashable = ..., + allow_duplicates: Optional[bool] = ..., + names: Union[None, Hashable, Sequence[Hashable]] = ..., + ) -> Optional[DataFrame]: + ... + + def reset_index( + self, + level: blocks.LevelsType = None, + drop: bool = False, + inplace: bool = False, + col_level: Union[int, str] = 0, + col_fill: Hashable = "", + allow_duplicates: Optional[bool] = None, + names: Union[None, Hashable, Sequence[Hashable]] = None, + ) -> Optional[DataFrame]: + block = self._block + if names is not None: + if isinstance(names, blocks.Label) and not isinstance(names, tuple): + names = [names] + else: + names = list(names) + + if len(names) != self.index.nlevels: + raise ValueError("'names' must be same length as levels") + + block = block.with_index_labels(names) + if allow_duplicates is None: + allow_duplicates = False + block = block.reset_index( + level, + drop, + col_level=col_level, + col_fill=col_fill, + allow_duplicates=allow_duplicates, + ) + if inplace: + self._set_block(block) + return None + else: + return DataFrame(block) def set_index( self, @@ -2011,30 +2453,92 @@ def set_index( col_ids_strs: List[str] = [col_id for col_id in col_ids if col_id is not None] return DataFrame(self._block.set_index(col_ids_strs, append=append, drop=drop)) - @validations.requires_index + @overload # type: ignore[override] def sort_index( - self, ascending: bool = True, na_position: Literal["first", "last"] = "last" + self, + *, + ascending: bool = ..., + inplace: Literal[False] = ..., + na_position: Literal["first", "last"] = ..., ) -> DataFrame: - if na_position not in ["first", "last"]: - raise ValueError("Param na_position must be one of 'first' or 'last'") - na_last = na_position == "last" - index_columns = self._block.index_columns - ordering = [ - order.ascending_over(column, na_last) - if ascending - else order.descending_over(column, na_last) - for column in index_columns - ] - return DataFrame(self._block.order_by(ordering)) + ... + + @overload + def sort_index( + self, + *, + ascending: bool = ..., + inplace: Literal[True] = ..., + na_position: Literal["first", "last"] = ..., + ) -> None: + ... + + def sort_index( + self, + *, + axis: Union[int, str] = 0, + ascending: bool = True, + inplace: bool = False, + na_position: Literal["first", "last"] = "last", + ) -> Optional[DataFrame]: + if utils.get_axis_number(axis) == 0: + if na_position not in ["first", "last"]: + raise ValueError("Param na_position must be one of 'first' or 'last'") + na_last = na_position == "last" + index_columns = self._block.index_columns + ordering = [ + order.ascending_over(column, na_last) + if ascending + else order.descending_over(column, na_last) + for column in index_columns + ] + block = self._block.order_by(ordering) + else: # axis=1 + _, indexer = self.columns.sort_values( + return_indexer=True, ascending=ascending, na_position=na_position # type: ignore + ) + block = self._block.select_columns( + [self._block.value_columns[i] for i in indexer] + ) + if inplace: + self._set_block(block) + return None + else: + return DataFrame(block) + + @overload # type: ignore[override] + def sort_values( + self, + by: str | typing.Sequence[str], + *, + inplace: Literal[False] = ..., + ascending: bool | typing.Sequence[bool] = ..., + kind: str = ..., + na_position: typing.Literal["first", "last"] = ..., + ) -> DataFrame: + ... + + @overload + def sort_values( + self, + by: str | typing.Sequence[str], + *, + inplace: Literal[True] = ..., + ascending: bool | typing.Sequence[bool] = ..., + kind: str = ..., + na_position: typing.Literal["first", "last"] = ..., + ) -> None: + ... def sort_values( self, by: str | typing.Sequence[str], *, + inplace: bool = False, ascending: bool | typing.Sequence[bool] = True, kind: str = "quicksort", na_position: typing.Literal["first", "last"] = "last", - ) -> DataFrame: + ) -> Optional[DataFrame]: if isinstance(by, (bigframes.series.Series, indexes.Index, DataFrame)): raise KeyError( f"Invalid key type: {type(by).__name__}. Please provide valid column name(s)." @@ -2064,7 +2568,12 @@ def sort_values( if is_ascending else order.descending_over(column_id, na_last) ) - return DataFrame(self._block.order_by(ordering)) + block = self._block.order_by(ordering) + if inplace: + self._set_block(block) + return None + else: + return DataFrame(block) def eval(self, expr: str) -> DataFrame: import bigframes.core.eval as bf_eval @@ -2093,7 +2602,7 @@ def value_counts( normalize=normalize, sort=sort, ascending=ascending, - dropna=dropna, + drop_na=dropna, ) return bigframes.series.Series(block) @@ -2105,6 +2614,18 @@ def add_suffix(self, suffix: str, axis: int | str | None = None) -> DataFrame: axis = 1 if axis is None else axis return DataFrame(self._get_block().add_suffix(suffix, axis)) + def take( + self, indices: typing.Sequence[int], axis: int | str | None = 0, **kwargs + ) -> DataFrame: + if not utils.is_list_like(indices): + raise ValueError("indices should be a list-like object.") + if axis == 0 or axis == "index": + return self.iloc[indices] + elif axis == 1 or axis == "columns": + return self.iloc[:, indices] + else: + raise ValueError(f"No axis named {axis} for object type DataFrame") + def filter( self, items: typing.Optional[typing.Iterable] = None, @@ -2302,12 +2823,12 @@ def replace( @validations.requires_ordering() def ffill(self, *, limit: typing.Optional[int] = None) -> DataFrame: - window = windows.rows(preceding=limit, following=0) + window = windows.rows(start=None if limit is None else -limit, end=0) return self._apply_window_op(agg_ops.LastNonNullOp(), window) @validations.requires_ordering() def bfill(self, *, limit: typing.Optional[int] = None) -> DataFrame: - window = windows.rows(preceding=0, following=limit) + window = windows.rows(start=0, end=limit) return self._apply_window_op(agg_ops.FirstNonNullOp(), window) def isin(self, values) -> DataFrame: @@ -2328,11 +2849,11 @@ def isin(self, values) -> DataFrame: False, label=label, dtype=pandas.BooleanDtype() ) result_ids.append(result_id) - return DataFrame(block.select_columns(result_ids)).fillna(value=False) + return DataFrame(block.select_columns(result_ids)) elif utils.is_list_like(values): return self._apply_unary_op( ops.IsInOp(values=tuple(values), match_nulls=True) - ).fillna(value=False) + ) else: raise TypeError( "only list-like objects are allowed to be passed to " @@ -2360,15 +2881,33 @@ def itertuples( for item in df.itertuples(index=index, name=name): yield item - def where(self, cond, other=None): - if isinstance(other, bigframes.series.Series): - raise ValueError("Seires is not a supported replacement type!") + def _apply_callable(self, condition): + """Executes the possible callable condition as needed.""" + if callable(condition): + # When it's a bigframes function. + if hasattr(condition, "bigframes_bigquery_function"): + return self.apply(condition, axis=1) + + # When it's a plain Python function. + return condition(self) + + # When it's not a callable. + return condition - if self.columns.nlevels > 1 or self.index.nlevels > 1: + def where(self, cond, other=None): + if self.columns.nlevels > 1: raise NotImplementedError( - "The dataframe.where() method does not support multi-index and/or multi-column." + "The dataframe.where() method does not support multi-column." ) + # Execute it with the DataFrame when cond or/and other is callable. + # It can be either a plain python function or remote/managed function. + cond = self._apply_callable(cond) + other = self._apply_callable(other) + + if isinstance(other, bigframes.series.Series): + raise ValueError("Seires is not a supported replacement type!") + aligned_block, (_, _) = self._block.join(cond._block, how="left") # No left join is needed when 'other' is None or constant. if isinstance(other, bigframes.dataframe.DataFrame): @@ -2380,7 +2919,7 @@ def where(self, cond, other=None): labels = aligned_block.column_labels[:self_len] self_col = {x: ex.deref(y) for x, y in zip(labels, ids)} - if isinstance(cond, bigframes.series.Series) and cond.name in self_col: + if isinstance(cond, bigframes.series.Series): # This is when 'cond' is a valid series. y = aligned_block.value_columns[self_len] cond_col = {x: ex.deref(y) for x in self_col.keys()} @@ -2418,13 +2957,14 @@ def where(self, cond, other=None): return result def mask(self, cond, other=None): - return self.where(~cond, other=other) + return self.where(~self._apply_callable(cond), other=other) def dropna( self, *, axis: int | str = 0, how: str = "any", + thresh: typing.Optional[int] = None, subset: typing.Union[None, blocks.Label, Sequence[blocks.Label]] = None, inplace: bool = False, ignore_index=False, @@ -2433,8 +2973,18 @@ def dropna( raise NotImplementedError( f"'inplace'=True not supported. {constants.FEEDBACK_LINK}" ) - if how not in ("any", "all"): - raise ValueError("'how' must be one of 'any', 'all'") + + # Check if both thresh and how are explicitly provided + if thresh is not None: + # cannot specify both thresh and how parameters + if how != "any": + raise TypeError( + "You cannot set both the how and thresh arguments at the same time." + ) + else: + # Only validate 'how' when thresh is not provided + if how not in ("any", "all"): + raise ValueError("'how' must be one of 'any', 'all'") axis_n = utils.get_axis_number(axis) @@ -2456,21 +3006,38 @@ def dropna( for id_ in self._block.label_to_col_id[label] ] - result = block_ops.dropna(self._block, self._block.value_columns, how=how, subset=subset_ids) # type: ignore + result = block_ops.dropna( + self._block, + self._block.value_columns, + how=how, + thresh=thresh, + subset=subset_ids, + ) # type: ignore if ignore_index: result = result.reset_index() return DataFrame(result) else: - isnull_block = self._block.multi_apply_unary_op(ops.isnull_op) - if how == "any": - null_locations = DataFrame(isnull_block).any().to_pandas() - else: # 'all' - null_locations = DataFrame(isnull_block).all().to_pandas() - keep_columns = [ - col - for col, to_drop in zip(self._block.value_columns, null_locations) - if not to_drop - ] + if thresh is not None: + # Keep columns with at least 'thresh' non-null values + notnull_block = self._block.multi_apply_unary_op(ops.notnull_op) + notnull_counts = DataFrame(notnull_block).sum().to_pandas() + + keep_columns = [ + col + for col, count in zip(self._block.value_columns, notnull_counts) + if count >= thresh + ] + else: + isnull_block = self._block.multi_apply_unary_op(ops.isnull_op) + if how == "any": + null_locations = DataFrame(isnull_block).any().to_pandas() + else: # 'all' + null_locations = DataFrame(isnull_block).all().to_pandas() + keep_columns = [ + col + for col, to_drop in zip(self._block.value_columns, null_locations) + if not to_drop + ] return DataFrame(self._block.select_columns(keep_columns)) def any( @@ -2618,11 +3185,52 @@ def nunique(self) -> bigframes.series.Series: block = self._block.aggregate_all_and_stack(agg_ops.nunique_op) return bigframes.series.Series(block) - def agg( - self, func: str | typing.Sequence[str] - ) -> DataFrame | bigframes.series.Series: - if utils.is_list_like(func): - aggregations = [agg_ops.lookup_agg_func(f) for f in func] + def agg(self, func) -> DataFrame | bigframes.series.Series: + if utils.is_dict_like(func): + # Must check dict-like first because dictionaries are list-like + # according to Pandas. + + aggs = [] + labels = [] + funcnames = [] + for col_label, agg_func in func.items(): + agg_func_list = agg_func if utils.is_list_like(agg_func) else [agg_func] + col_id = self._block.resolve_label_exact(col_label) + if col_id is None: + raise KeyError(f"Column {col_label} does not exist") + for agg_func in agg_func_list: + op_and_label = agg_ops.lookup_agg_func(agg_func) + agg_expr = ( + agg_expressions.UnaryAggregation( + op_and_label[0], ex.deref(col_id) + ) + if isinstance(op_and_label[0], agg_ops.UnaryAggregateOp) + else agg_expressions.NullaryAggregation(op_and_label[0]) + ) + aggs.append(agg_expr) + labels.append(col_label) + funcnames.append(op_and_label[1]) + + # if any list in dict values, format output differently + if any(utils.is_list_like(v) for v in func.values()): + new_index, _ = self.columns.reindex(labels) + new_index = utils.combine_indices(new_index, pandas.Index(funcnames)) + agg_block = self._block.aggregate( + aggregations=aggs, column_labels=new_index + ) + return DataFrame(agg_block).stack().droplevel(0, axis="index") + else: + new_index, _ = self.columns.reindex(labels) + agg_block = self._block.aggregate( + aggregations=aggs, column_labels=new_index + ) + return bigframes.series.Series( + agg_block.transpose( + single_row_mode=True, original_row_index=pandas.Index([None]) + ) + ) + elif utils.is_list_like(func): + aggregations = [agg_ops.lookup_agg_func(f)[0] for f in func] for dtype, agg in itertools.product(self.dtypes, aggregations): agg.output_type( @@ -2635,11 +3243,10 @@ def agg( aggregations, ) ) - else: + + else: # function name string return bigframes.series.Series( - self._block.aggregate_all_and_stack( - agg_ops.lookup_agg_func(typing.cast(str, func)) - ) + self._block.aggregate_all_and_stack(agg_ops.lookup_agg_func(func)[0]) ) aggregate = agg @@ -2695,92 +3302,9 @@ def melt( ) def describe(self, include: None | Literal["all"] = None) -> DataFrame: - if include is None: - numeric_df = self._select_exact_dtypes( - bigframes.dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE - + bigframes.dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES - ) - if len(numeric_df.columns) == 0: - # Describe eligible non-numeric columns - return self._describe_non_numeric() - - # Otherwise, only describe numeric columns - return self._describe_numeric() - - elif include == "all": - numeric_result = self._describe_numeric() - non_numeric_result = self._describe_non_numeric() + from bigframes.pandas.core.methods import describe - if len(numeric_result.columns) == 0: - return non_numeric_result - elif len(non_numeric_result.columns) == 0: - return numeric_result - else: - import bigframes.core.reshape.api as rs - - # Use reindex after join to preserve the original column order. - return rs.concat( - [non_numeric_result, numeric_result], axis=1 - )._reindex_columns(self.columns) - - else: - raise ValueError(f"Unsupported include type: {include}") - - def _describe_numeric(self) -> DataFrame: - number_df_result = typing.cast( - DataFrame, - self._select_exact_dtypes( - bigframes.dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE - ).agg( - [ - "count", - "mean", - "std", - "min", - "25%", - "50%", - "75%", - "max", - ] - ), - ) - temporal_df_result = typing.cast( - DataFrame, - self._select_exact_dtypes( - bigframes.dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES - ).agg(["count"]), - ) - - if len(number_df_result.columns) == 0: - return temporal_df_result - elif len(temporal_df_result.columns) == 0: - return number_df_result - else: - import bigframes.core.reshape.api as rs - - original_columns = self._select_exact_dtypes( - bigframes.dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE - + bigframes.dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES - ).columns - - # Use reindex after join to preserve the original column order. - return rs.concat( - [number_df_result, temporal_df_result], - axis=1, - )._reindex_columns(original_columns) - - def _describe_non_numeric(self) -> DataFrame: - return typing.cast( - DataFrame, - self._select_exact_dtypes( - [ - bigframes.dtypes.STRING_DTYPE, - bigframes.dtypes.BOOL_DTYPE, - bigframes.dtypes.BYTES_DTYPE, - bigframes.dtypes.TIME_DTYPE, - ] - ).agg(["count", "nunique"]), - ) + return typing.cast(DataFrame, describe.describe(self, include)) def skew(self, *, numeric_only: bool = False): if not numeric_only: @@ -2836,8 +3360,6 @@ def _pivot( ) return DataFrame(pivot_block) - @validations.requires_index - @validations.requires_ordering() def pivot( self, *, @@ -2851,8 +3373,6 @@ def pivot( ) -> DataFrame: return self._pivot(columns=columns, index=index, values=values) - @validations.requires_index - @validations.requires_ordering() def pivot_table( self, values: typing.Optional[ @@ -2863,7 +3383,30 @@ def pivot_table( ] = None, columns: typing.Union[blocks.Label, Sequence[blocks.Label]] = None, aggfunc: str = "mean", + fill_value=None, + margins: bool = False, + dropna: bool = True, + margins_name: Hashable = "All", + observed: bool = False, + sort: bool = True, ) -> DataFrame: + if margins: + raise NotImplementedError( + "DataFrame.pivot_table margins arg not supported. {constants.FEEDBACK_LINK}" + ) + if not dropna: + raise NotImplementedError( + "DataFrame.pivot_table dropna arg not supported. {constants.FEEDBACK_LINK}" + ) + if margins_name != "All": + raise NotImplementedError( + "DataFrame.pivot_table margins_name arg not supported. {constants.FEEDBACK_LINK}" + ) + if observed: + raise NotImplementedError( + "DataFrame.pivot_table observed arg not supported. {constants.FEEDBACK_LINK}" + ) + if isinstance(index, Iterable) and not ( isinstance(index, blocks.Label) and index in self.columns ): @@ -2905,13 +3448,17 @@ def pivot_table( columns=columns, index=index, values=values if len(values) > 1 else None, - ).sort_index() + ) + if fill_value is not None: + pivoted = pivoted.fillna(fill_value) + if sort: + pivoted = pivoted.sort_index() # TODO: Remove the reordering step once the issue is resolved. # The pivot_table method results in multi-index columns that are always ordered. # However, the order of the pivoted result columns is not guaranteed to be sorted. # Sort and reorder. - return pivoted[pivoted.columns.sort_values()] + return pivoted.sort_index(axis=1) # type: ignore def stack(self, level: LevelsType = -1): if not isinstance(self.columns, pandas.MultiIndex): @@ -3030,89 +3577,51 @@ def merge( "right", "cross", ] = "inner", - # TODO(garrettwu): Currently can take inner, outer, left and right. To support - # cross joins on: Union[blocks.Label, Sequence[blocks.Label], None] = None, *, left_on: Union[blocks.Label, Sequence[blocks.Label], None] = None, right_on: Union[blocks.Label, Sequence[blocks.Label], None] = None, + left_index: bool = False, + right_index: bool = False, sort: bool = False, suffixes: tuple[str, str] = ("_x", "_y"), ) -> DataFrame: - if how == "cross": - if on is not None: - raise ValueError("'on' is not supported for cross join.") - result_block = self._block.merge( - right._block, - left_join_ids=[], - right_join_ids=[], - suffixes=suffixes, - how=how, - sort=True, - ) - return DataFrame(result_block) - - if on is None: - if left_on is None or right_on is None: - raise ValueError("Must specify `on` or `left_on` + `right_on`.") - else: - if left_on is not None or right_on is not None: - raise ValueError( - "Can not pass both `on` and `left_on` + `right_on` params." - ) - left_on, right_on = on, on + from bigframes.core.reshape import merge - if utils.is_list_like(left_on): - left_on = list(left_on) # type: ignore - else: - left_on = [left_on] - - if utils.is_list_like(right_on): - right_on = list(right_on) # type: ignore - else: - right_on = [right_on] - - left_join_ids = [] - for label in left_on: # type: ignore - left_col_id = self._resolve_label_exact(label) - # 0 elements already throws an exception - if not left_col_id: - raise ValueError(f"No column {label} found in self.") - left_join_ids.append(left_col_id) - - right_join_ids = [] - for label in right_on: # type: ignore - right_col_id = right._resolve_label_exact(label) - if not right_col_id: - raise ValueError(f"No column {label} found in other.") - right_join_ids.append(right_col_id) - - block = self._block.merge( - right._block, + return merge.merge( + self, + right, how, - left_join_ids, - right_join_ids, + on, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, sort=sort, suffixes=suffixes, ) - return DataFrame(block) def join( self, other: Union[DataFrame, bigframes.series.Series], - *, on: Optional[str] = None, how: str = "left", + lsuffix: str = "", + rsuffix: str = "", ) -> DataFrame: if isinstance(other, bigframes.series.Series): other = other.to_frame() left, right = self, other - if not left.columns.intersection(right.columns).empty: - raise NotImplementedError( - f"Deduping column names is not implemented. {constants.FEEDBACK_LINK}" - ) + col_intersection = left.columns.intersection(right.columns) + + if not col_intersection.empty: + if lsuffix == rsuffix == "": + raise ValueError( + f"columns overlap but no suffix specified: {col_intersection}" + ) + if how == "cross": if on is not None: raise ValueError("'on' is not supported for cross join.") @@ -3120,7 +3629,7 @@ def join( right._block, left_join_ids=[], right_join_ids=[], - suffixes=("", ""), + suffixes=(lsuffix, rsuffix), how="cross", sort=True, ) @@ -3128,60 +3637,203 @@ def join( # Join left columns with right index if on is not None: + if left._has_index and (on in left.index.names): + if on in left.columns: + raise ValueError( + f"'{on}' is both an index level and a column label, which is ambiguous." + ) + else: + raise NotImplementedError( + f"Joining on index level '{on}' is not yet supported. {constants.FEEDBACK_LINK}" + ) + if (left.columns == on).sum() > 1: + raise ValueError(f"The column label '{on}' is not unique.") + if other._block.index.nlevels != 1: raise ValueError( "Join on columns must match the index level of the other DataFrame. Join on column with multi-index haven't been supported." ) - # Switch left index with on column - left_columns = left.columns - left_idx_original_names = left.index.names if left._has_index else () - left_idx_names_in_cols = [ - f"bigframes_left_idx_name_{i}" - for i in range(len(left_idx_original_names)) - ] - if left._has_index: - left.index.names = left_idx_names_in_cols - left = left.reset_index(drop=False) - left = left.set_index(on) - - # Join on index and switch back - combined_df = left._perform_join_by_index(right, how=how) - combined_df.index.name = on - combined_df = combined_df.reset_index(drop=False) - combined_df = combined_df.set_index(left_idx_names_in_cols) - - # To be consistent with Pandas - if combined_df._has_index: - combined_df.index.names = ( - left_idx_original_names - if how in ("inner", "left") - else ([None] * len(combined_df.index.names)) - ) - # Reorder columns - combined_df = combined_df[list(left_columns) + list(right.columns)] - return combined_df + return self._join_on_key( + other, + on=on, + how=how, + lsuffix=lsuffix, + rsuffix=rsuffix, + should_duplicate_on_key=(on in col_intersection), + ) # Join left index with right index if left._block.index.nlevels != right._block.index.nlevels: raise ValueError("Index to join on must have the same number of levels.") - return left._perform_join_by_index(right, how=how) + return left._perform_join_by_index(right, how=how)._add_join_suffix( + left.columns, right.columns, lsuffix=lsuffix, rsuffix=rsuffix + ) + + def _join_on_key( + self, + other: DataFrame, + on: str, + how: str, + lsuffix: str, + rsuffix: str, + should_duplicate_on_key: bool, + ) -> DataFrame: + left, right = self.copy(), other + # Replace all columns names with unique names for reordering. + left_col_original_names = left.columns + on_col_name = "bigframes_left_col_on" + dup_on_col_name = "bigframes_left_col_on_dup" + left_col_temp_names = [ + f"bigframes_left_col_name_{i}" if col_name != on else on_col_name + for i, col_name in enumerate(left_col_original_names) + ] + left.columns = pandas.Index(left_col_temp_names) + # if on column is also in right df, we need to duplicate the column + # and set it to be the first column + if should_duplicate_on_key: + left[dup_on_col_name] = left[on_col_name] + on_col_name = dup_on_col_name + left_col_temp_names = [on_col_name] + left_col_temp_names + left = left[left_col_temp_names] + + # Switch left index with on column + left_idx_original_names = left.index.names if left._has_index else () + left_idx_names_in_cols = [ + f"bigframes_left_idx_name_{i}" for i in range(len(left_idx_original_names)) + ] + if left._has_index: + left.index.names = left_idx_names_in_cols + left = left.reset_index(drop=False) + left = left.set_index(on_col_name) + + right_col_original_names = right.columns + right_col_temp_names = [ + f"bigframes_right_col_name_{i}" + for i in range(len(right_col_original_names)) + ] + right.columns = pandas.Index(right_col_temp_names) + + # Join on index and switch back + combined_df = left._perform_join_by_index(right, how=how) + combined_df.index.name = on_col_name + combined_df = combined_df.reset_index(drop=False) + combined_df = combined_df.set_index(left_idx_names_in_cols) + + # To be consistent with Pandas + if combined_df._has_index: + combined_df.index.names = ( + left_idx_original_names + if how in ("inner", "left") + else ([None] * len(combined_df.index.names)) + ) + + # Reorder columns + combined_df = combined_df[left_col_temp_names + right_col_temp_names] + return combined_df._add_join_suffix( + left_col_original_names, + right_col_original_names, + lsuffix=lsuffix, + rsuffix=rsuffix, + extra_col=on if on_col_name == dup_on_col_name else None, + ) def _perform_join_by_index( - self, other: Union[DataFrame, indexes.Index], *, how: str = "left" + self, + other: Union[DataFrame, indexes.Index], + *, + how: str = "left", + always_order: bool = False, ): - block, _ = self._block.join(other._block, how=how, block_identity_join=True) + block, _ = self._block.join( + other._block, how=how, block_identity_join=True, always_order=always_order + ) return DataFrame(block) + def _add_join_suffix( + self, + left_columns, + right_columns, + lsuffix: str = "", + rsuffix: str = "", + extra_col: typing.Optional[str] = None, + ): + """Applies suffixes to overlapping column names to mimic a pandas join. + + This method identifies columns that are common to both a "left" and "right" + set of columns and renames them using the provided suffixes. Columns that + are not in the intersection are kept with their original names. + + Args: + left_columns (pandas.Index): + The column labels from the left DataFrame. + right_columns (pandas.Index): + The column labels from the right DataFrame. + lsuffix (str): + The suffix to apply to overlapping column names from the left side. + rsuffix (str): + The suffix to apply to overlapping column names from the right side. + extra_col (typing.Optional[str]): + An optional column name to prepend to the final list of columns. + This argument is used specifically to match the behavior of a + pandas join. When a join key (i.e., the 'on' column) exists + in both the left and right DataFrames, pandas creates two versions + of that column: one copy keeps its original name and is placed as + the first column, while the other instances receive the normal + suffix. Passing the join key's name here replicates that behavior. + + Returns: + DataFrame: + A new DataFrame with the columns renamed to resolve overlaps. + """ + combined_df = self.copy() + col_intersection = left_columns.intersection(right_columns) + final_col_names = [] if extra_col is None else [extra_col] + for col_name in left_columns: + if col_name in col_intersection: + final_col_names.append(f"{col_name}{lsuffix}") + else: + final_col_names.append(col_name) + + for col_name in right_columns: + if col_name in col_intersection: + final_col_names.append(f"{col_name}{rsuffix}") + else: + final_col_names.append(col_name) + combined_df.columns = pandas.Index(final_col_names) + return combined_df + @validations.requires_ordering() - def rolling(self, window: int, min_periods=None) -> bigframes.core.window.Window: - # To get n size window, need current row and n-1 preceding rows. - window_def = windows.rows( - preceding=window - 1, following=0, min_periods=min_periods or window - ) - return bigframes.core.window.Window( - self._block, window_def, self._block.value_columns + def rolling( + self, + window: int | pandas.Timedelta | numpy.timedelta64 | datetime.timedelta | str, + min_periods=None, + on: str | None = None, + closed: Literal["right", "left", "both", "neither"] = "right", + ) -> bigframes.core.window.Window: + if isinstance(window, int): + window_def = windows.WindowSpec( + bounds=windows.RowsWindowBounds.from_window_size(window, closed), + min_periods=min_periods if min_periods is not None else window, + ) + skip_agg_col_id = ( + None if on is None else self._block.resolve_label_exact_or_error(on) + ) + return bigframes.core.window.Window( + self._block, + window_def, + self._block.value_columns, + skip_agg_column_id=skip_agg_col_id, + ) + + return rolling.create_range_window( + self._block, + window, + min_periods=min_periods, + on=on, + closed=closed, + is_series=False, ) @validations.requires_ordering() @@ -3220,11 +3872,17 @@ def _groupby_level( as_index: bool = True, dropna: bool = True, ): + if utils.is_list_like(level): + by_key_is_singular = False + else: + by_key_is_singular = True + return groupby.DataFrameGroupBy( self._block, by_col_ids=self._resolve_levels(level), as_index=as_index, dropna=dropna, + by_key_is_singular=by_key_is_singular, ) def _groupby_series( @@ -3237,10 +3895,14 @@ def _groupby_series( as_index: bool = True, dropna: bool = True, ): + # Pandas makes a distinction between groupby with a list of keys + # versus groupby with a single item in some methods, like __iter__. if not isinstance(by, bigframes.series.Series) and utils.is_list_like(by): by = list(by) + by_key_is_singular = False else: by = [typing.cast(typing.Union[blocks.Label, bigframes.series.Series], by)] + by_key_is_singular = True block = self._block col_ids: typing.Sequence[str] = [] @@ -3270,11 +3932,53 @@ def _groupby_series( by_col_ids=col_ids, as_index=as_index, dropna=dropna, + by_key_is_singular=by_key_is_singular, ) def abs(self) -> DataFrame: return self._apply_unary_op(ops.abs_op) + def round(self, decimals: Union[int, dict[Hashable, int]] = 0) -> DataFrame: + is_mapping = utils.is_dict_like(decimals) + if not (is_mapping or isinstance(decimals, int)): + raise TypeError("'decimals' must be either a dict-like or integer.") + block = self._block + exprs = [] + for label, col_id, dtype in zip( + block.column_labels, block.value_columns, block.dtypes + ): + if dtype in set(bigframes.dtypes.NUMERIC_BIGFRAMES_TYPES_PERMISSIVE) - { + bigframes.dtypes.BOOL_DTYPE + }: + if is_mapping: + if label in decimals: # type: ignore + exprs.append( + ops.round_op.as_expr( + col_id, + ex.const( + decimals[label], dtype=bigframes.dtypes.INT_DTYPE # type: ignore + ), + ) + ) + else: + exprs.append(ex.deref(col_id)) + else: + exprs.append( + ops.round_op.as_expr( + col_id, + ex.const( + typing.cast(int, decimals), + dtype=bigframes.dtypes.INT_DTYPE, + ), + ) + ) + else: + exprs.append(ex.deref(col_id)) + + return DataFrame( + block.project_exprs(exprs, labels=block.column_labels, drop=True) + ) + def isna(self) -> DataFrame: return self._apply_unary_op(ops.isnull_op) @@ -3345,7 +4049,7 @@ def pct_change(self, periods: int = 1) -> DataFrame: def _apply_window_op( self, - op: agg_ops.WindowOp, + op: agg_ops.UnaryWindowOp, window_spec: windows.WindowSpec, ): block, result_ids = self._block.multi_apply_window_op( @@ -3353,7 +4057,22 @@ def _apply_window_op( op, window_spec=window_spec, ) - return DataFrame(block.select_columns(result_ids)) + if op.skips_nulls: + block = block.project_exprs( + tuple( + bigframes.operations.where_op.as_expr( + r_col, + bigframes.operations.notnull_op.as_expr(og_col), + ex.const(None), + ) + for og_col, r_col in zip(self._block.value_columns, result_ids) + ), + labels=self._block.column_labels, + drop=True, + ) + else: + block = block.select_columns(result_ids) + return DataFrame(block) @validations.requires_ordering() def sample( @@ -3413,10 +4132,12 @@ def _split( return [DataFrame(block) for block in blocks] @validations.requires_ordering() - def _resample( + def resample( self, rule: str, *, + closed: Optional[Literal["right", "left"]] = None, + label: Optional[Literal["right", "left"]] = None, on: blocks.Label = None, level: Optional[LevelsType] = None, origin: Union[ @@ -3426,67 +4147,10 @@ def _resample( Literal["epoch", "start", "start_day", "end", "end_day"], ] = "start_day", ) -> bigframes.core.groupby.DataFrameGroupBy: - """Internal function to support resample. Resample time-series data. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None - - >>> data = { - ... "timestamp_col": pd.date_range( - ... start="2021-01-01 13:00:00", periods=30, freq="1s" - ... ), - ... "int64_col": range(30), - ... "int64_too": range(10, 40), - ... } - - Resample on a DataFrame with index: - - >>> df = bpd.DataFrame(data).set_index("timestamp_col") - >>> df._resample(rule="7s").min() - int64_col int64_too - 2021-01-01 12:59:55 0 10 - 2021-01-01 13:00:02 2 12 - 2021-01-01 13:00:09 9 19 - 2021-01-01 13:00:16 16 26 - 2021-01-01 13:00:23 23 33 - - [5 rows x 2 columns] - - Resample with column and origin set to 'start': - - >>> df = bpd.DataFrame(data) - >>> df._resample(rule="7s", on = "timestamp_col", origin="start").min() - int64_col int64_too - 2021-01-01 13:00:00 0 10 - 2021-01-01 13:00:07 7 17 - 2021-01-01 13:00:14 14 24 - 2021-01-01 13:00:21 21 31 - 2021-01-01 13:00:28 28 38 - - [5 rows x 2 columns] - - Args: - rule (str): - The offset string representing target conversion. - on (str, default None): - For a DataFrame, column to use instead of index for resampling. Column - must be datetime-like. - level (str or int, default None): - For a MultiIndex, level (name or number) to use for resampling. - level must be datetime-like. - origin(str, default 'start_day'): - The timestamp on which to adjust the grouping. Must be one of the following: - 'epoch': origin is 1970-01-01 - 'start': origin is the first value of the timeseries - 'start_day': origin is the first day at midnight of the timeseries - Returns: - DataFrameGroupBy: DataFrameGroupBy object. - """ block = self._block._generate_resample_label( rule=rule, + closed=closed, + label=label, on=on, level=level, origin=origin, @@ -3527,6 +4191,7 @@ def to_csv( *, header: bool = True, index: bool = True, + allow_large_results: Optional[bool] = None, ) -> Optional[str]: # TODO(swast): Can we support partition columns argument? # TODO(chelsealin): Support local file paths. @@ -3534,7 +4199,7 @@ def to_csv( # query results? See: # https://cloud.google.com/bigquery/docs/exporting-data#limit_the_exported_file_size if not utils.is_gcs_path(path_or_buf): - pd_df = self.to_pandas() + pd_df = self.to_pandas(allow_large_results=allow_large_results) return pd_df.to_csv(path_or_buf, sep=sep, header=header, index=index) if "*" not in path_or_buf: raise NotImplementedError(ERROR_IO_REQUIRES_WILDCARD) @@ -3543,18 +4208,19 @@ def to_csv( index=index and self._has_index, ordering_id=bigframes.session._io.bigquery.IO_ORDERING_ID, ) - options = { + options: dict[str, Union[bool, str]] = { "field_delimiter": sep, "header": header, } - query_job = self._session._executor.export_gcs( - export_array, - id_overrides, - path_or_buf, - format="csv", - export_options=options, + result = self._session._executor.execute( + export_array.rename_columns(id_overrides), + ex_spec.ExecutionSpec( + ex_spec.GcsOutputSpec( + uri=path_or_buf, format="csv", export_options=tuple(options.items()) + ) + ), ) - self._set_internal_query_job(query_job) + self._set_internal_query_job(result.query_job) return None def to_json( @@ -3566,10 +4232,11 @@ def to_json( *, lines: bool = False, index: bool = True, + allow_large_results: Optional[bool] = None, ) -> Optional[str]: # TODO(swast): Can we support partition columns argument? if not utils.is_gcs_path(path_or_buf): - pd_df = self.to_pandas() + pd_df = self.to_pandas(allow_large_results=allow_large_results) return pd_df.to_json( path_or_buf, orient=orient, @@ -3596,10 +4263,13 @@ def to_json( index=index and self._has_index, ordering_id=bigframes.session._io.bigquery.IO_ORDERING_ID, ) - query_job = self._session._executor.export_gcs( - export_array, id_overrides, path_or_buf, format="json", export_options={} + result = self._session._executor.execute( + export_array.rename_columns(id_overrides), + ex_spec.ExecutionSpec( + ex_spec.GcsOutputSpec(uri=path_or_buf, format="json", export_options=()) + ), ) - self._set_internal_query_job(query_job) + self._set_internal_query_job(result.query_job) return None def to_gbq( @@ -3624,10 +4294,9 @@ def to_gbq( ) if_exists = "replace" - temp_table_ref = self._session._temp_storage_manager._random_table( - # The client code owns this table reference now, so skip_cleanup=True - # to not clean it up when we close the session. - skip_cleanup=True, + # The client code owns this table reference now + temp_table_ref = ( + self._session._anon_dataset_manager.generate_unique_resource_id() ) destination_table = f"{temp_table_ref.project}.{temp_table_ref.dataset_id}.{temp_table_ref.table_id}" @@ -3672,17 +4341,22 @@ def to_gbq( default_project=default_project, ) ) - query_job = self._session._executor.export_gbq( - export_array, - destination=destination, - col_id_overrides=id_overrides, - cluster_cols=clustering_fields, - if_exists=if_exists, + + result = self._session._executor.execute( + export_array.rename_columns(id_overrides), + ex_spec.ExecutionSpec( + ex_spec.TableOutputSpec( + destination, + cluster_cols=tuple(clustering_fields), + if_exists=if_exists, + ) + ), ) - self._set_internal_query_job(query_job) + assert result.query_job is not None + self._set_internal_query_job(result.query_job) # The query job should have finished, so there should be always be a result table. - result_table = query_job.destination + result_table = result.query_job.destination assert result_table is not None if temp_table_ref: @@ -3701,11 +4375,21 @@ def to_gbq( return destination_table def to_numpy( - self, dtype=None, copy=False, na_value=None, **kwargs + self, + dtype=None, + copy=False, + na_value=pd_ext.no_default, + *, + allow_large_results=None, + **kwargs, ) -> numpy.ndarray: - return self.to_pandas().to_numpy(dtype, copy, na_value, **kwargs) + return self.to_pandas(allow_large_results=allow_large_results).to_numpy( + dtype, copy, na_value, **kwargs + ) - def __array__(self, dtype=None) -> numpy.ndarray: + def __array__(self, dtype=None, copy: Optional[bool] = None) -> numpy.ndarray: + if copy is False: + raise ValueError("Cannot convert to array without copy.") return self.to_numpy(dtype=dtype) __array__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__array__) @@ -3716,6 +4400,7 @@ def to_parquet( *, compression: Optional[Literal["snappy", "gzip"]] = "snappy", index: bool = True, + allow_large_results: Optional[bool] = None, ) -> Optional[bytes]: # TODO(swast): Can we support partition columns argument? # TODO(chelsealin): Support local file paths. @@ -3723,7 +4408,7 @@ def to_parquet( # query results? See: # https://cloud.google.com/bigquery/docs/exporting-data#limit_the_exported_file_size if not utils.is_gcs_path(path): - pd_df = self.to_pandas() + pd_df = self.to_pandas(allow_large_results=allow_large_results) return pd_df.to_parquet(path, compression=compression, index=index) if "*" not in path: raise NotImplementedError(ERROR_IO_REQUIRES_WILDCARD) @@ -3739,14 +4424,17 @@ def to_parquet( index=index and self._has_index, ordering_id=bigframes.session._io.bigquery.IO_ORDERING_ID, ) - query_job = self._session._executor.export_gcs( - export_array, - id_overrides, - path, - format="parquet", - export_options=export_options, + result = self._session._executor.execute( + export_array.rename_columns(id_overrides), + ex_spec.ExecutionSpec( + ex_spec.GcsOutputSpec( + uri=path, + format="parquet", + export_options=tuple(export_options.items()), + ) + ), ) - self._set_internal_query_job(query_job) + self._set_internal_query_job(result.query_job) return None def to_dict( @@ -3755,12 +4443,23 @@ def to_dict( "dict", "list", "series", "split", "tight", "records", "index" ] = "dict", into: type[dict] = dict, + *, + allow_large_results: Optional[bool] = None, **kwargs, ) -> dict | list[dict]: - return self.to_pandas().to_dict(orient, into, **kwargs) # type: ignore + return self.to_pandas(allow_large_results=allow_large_results).to_dict(orient=orient, into=into, **kwargs) # type: ignore - def to_excel(self, excel_writer, sheet_name: str = "Sheet1", **kwargs) -> None: - return self.to_pandas().to_excel(excel_writer, sheet_name, **kwargs) + def to_excel( + self, + excel_writer, + sheet_name: str = "Sheet1", + *, + allow_large_results: Optional[bool] = None, + **kwargs, + ) -> None: + return self.to_pandas(allow_large_results=allow_large_results).to_excel( + excel_writer, sheet_name, **kwargs + ) def to_latex( self, @@ -3768,16 +4467,25 @@ def to_latex( columns: Sequence | None = None, header: bool | Sequence[str] = True, index: bool = True, + *, + allow_large_results: Optional[bool] = None, **kwargs, ) -> str | None: - return self.to_pandas().to_latex( + return self.to_pandas(allow_large_results=allow_large_results).to_latex( buf, columns=columns, header=header, index=index, **kwargs # type: ignore ) def to_records( - self, index: bool = True, column_dtypes=None, index_dtypes=None + self, + index: bool = True, + column_dtypes=None, + index_dtypes=None, + *, + allow_large_results=None, ) -> numpy.recarray: - return self.to_pandas().to_records(index, column_dtypes, index_dtypes) + return self.to_pandas(allow_large_results=allow_large_results).to_records( + index, column_dtypes, index_dtypes + ) def to_string( self, @@ -3800,27 +4508,29 @@ def to_string( min_rows: int | None = None, max_colwidth: int | None = None, encoding: str | None = None, + *, + allow_large_results: Optional[bool] = None, ) -> str | None: - return self.to_pandas().to_string( + return self.to_pandas(allow_large_results=allow_large_results).to_string( buf, - columns, # type: ignore - col_space, - header, # type: ignore - index, - na_rep, - formatters, - float_format, - sparsify, - index_names, - justify, - max_rows, - max_cols, - show_dimensions, - decimal, - line_width, - min_rows, - max_colwidth, - encoding, + columns=columns, # type: ignore + col_space=col_space, + header=header, # type: ignore + index=index, + na_rep=na_rep, + formatters=formatters, + float_format=float_format, + sparsify=sparsify, + index_names=index_names, + justify=justify, + max_rows=max_rows, + max_cols=max_cols, + show_dimensions=show_dimensions, + decimal=decimal, + line_width=line_width, + min_rows=min_rows, + max_colwidth=max_colwidth, + encoding=encoding, ) def to_html( @@ -3848,31 +4558,33 @@ def to_html( table_id: str | None = None, render_links: bool = False, encoding: str | None = None, + *, + allow_large_results: bool | None = None, ) -> str: - return self.to_pandas().to_html( + return self.to_pandas(allow_large_results=allow_large_results).to_html( buf, - columns, # type: ignore - col_space, - header, - index, - na_rep, - formatters, - float_format, - sparsify, - index_names, - justify, # type: ignore - max_rows, - max_cols, - show_dimensions, - decimal, - bold_rows, - classes, - escape, - notebook, - border, - table_id, - render_links, - encoding, + columns=columns, # type: ignore + col_space=col_space, + header=header, + index=index, + na_rep=na_rep, + formatters=formatters, + float_format=float_format, + sparsify=sparsify, + index_names=index_names, + justify=justify, # type: ignore + max_rows=max_rows, + max_cols=max_cols, + show_dimensions=show_dimensions, + decimal=decimal, + bold_rows=bold_rows, + classes=classes, + escape=escape, + notebook=notebook, + border=border, + table_id=table_id, + render_links=render_links, + encoding=encoding, ) def to_markdown( @@ -3880,15 +4592,19 @@ def to_markdown( buf=None, mode: str = "wt", index: bool = True, + *, + allow_large_results: Optional[bool] = None, **kwargs, ) -> str | None: - return self.to_pandas().to_markdown(buf, mode, index, **kwargs) # type: ignore + return self.to_pandas(allow_large_results=allow_large_results).to_markdown(buf, mode=mode, index=index, **kwargs) # type: ignore - def to_pickle(self, path, **kwargs) -> None: - return self.to_pandas().to_pickle(path, **kwargs) + def to_pickle(self, path, *, allow_large_results=None, **kwargs) -> None: + return self.to_pandas(allow_large_results=allow_large_results).to_pickle( + path, **kwargs + ) - def to_orc(self, path=None, **kwargs) -> bytes | None: - as_pandas = self.to_pandas() + def to_orc(self, path=None, *, allow_large_results=None, **kwargs) -> bytes | None: + as_pandas = self.to_pandas(allow_large_results=allow_large_results) # to_orc only works with default index as_pandas_default_index = as_pandas.reset_index() return as_pandas_default_index.to_orc(path, **kwargs) @@ -3968,7 +4684,9 @@ def _prepare_export( # the arbitrary unicode column labels feature in BigQuery, which is # currently (June 2023) in preview. id_overrides = { - col_id: col_label for col_id, col_label in zip(columns, column_labels) + col_id: col_label + for col_id, col_label in zip(columns, column_labels) + if (col_id != col_label) } if ordering_id is not None: @@ -3977,7 +4695,7 @@ def _prepare_export( return array_value, id_overrides def map(self, func, na_action: Optional[str] = None) -> DataFrame: - if not callable(func): + if not isinstance(func, bigframes.functions.BigqueryCallableRoutine): raise TypeError("the first argument must be callable") if na_action not in {None, "ignore"}: @@ -3985,29 +4703,39 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: # TODO(shobs): Support **kwargs return self._apply_unary_op( - ops.RemoteFunctionOp(func=func, apply_on_null=(na_action is None)) + ops.RemoteFunctionOp( + function_def=func.udf_def, apply_on_null=(na_action is None) + ) ) def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs): - # In Bigframes remote function, DataFrame '.apply' method is specifically + # In Bigframes BigQuery function, DataFrame '.apply' method is specifically # designed to work with row-wise or column-wise operations, where the input # to the applied function should be a Series, not a scalar. if utils.get_axis_number(axis) == 1: - msg = "axis=1 scenario is in preview." - warnings.warn(msg, category=bfe.PreviewWarning) + msg = bfe.format_message( + "DataFrame.apply with parameter axis=1 scenario is in preview." + ) + warnings.warn(msg, category=bfe.FunctionAxisOnePreviewWarning) - # Check if the function is a remote function - if not hasattr(func, "bigframes_remote_function"): - raise ValueError("For axis=1 a remote function must be used.") + if not isinstance( + func, + ( + bigframes.functions.BigqueryCallableRoutine, + bigframes.functions.BigqueryCallableRowRoutine, + ), + ): + raise ValueError( + "For axis=1 a BigFrames BigQuery function must be used." + ) - is_row_processor = getattr(func, "is_row_processor") - if is_row_processor: + if func.is_row_processor: # Early check whether the dataframe dtypes are currently supported - # in the remote function + # in the bigquery function # NOTE: Keep in sync with the value converters used in the gcf code # generated in function_template.py - remote_function_supported_dtypes = ( + bigquery_function_supported_dtypes = ( bigframes.dtypes.INT_DTYPE, bigframes.dtypes.FLOAT_DTYPE, bigframes.dtypes.BOOL_DTYPE, @@ -4016,18 +4744,18 @@ def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs): ) supported_dtypes_types = tuple( type(dtype) - for dtype in remote_function_supported_dtypes + for dtype in bigquery_function_supported_dtypes if not isinstance(dtype, pandas.ArrowDtype) ) # Check ArrowDtype separately since multiple BigQuery types map to # ArrowDtype, including BYTES and TIMESTAMP. supported_arrow_types = tuple( dtype.pyarrow_dtype - for dtype in remote_function_supported_dtypes + for dtype in bigquery_function_supported_dtypes if isinstance(dtype, pandas.ArrowDtype) ) supported_dtypes_hints = tuple( - str(dtype) for dtype in remote_function_supported_dtypes + str(dtype) for dtype in bigquery_function_supported_dtypes ) for dtype in self.dtypes: @@ -4055,59 +4783,89 @@ def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs): ) # Apply the function - result_series = rows_as_json_series._apply_unary_op( - ops.RemoteFunctionOp(func=func, apply_on_null=True) - ) + if args: + result_series = rows_as_json_series._apply_nary_op( + ops.NaryRemoteFunctionOp(function_def=func.udf_def), + list(args), + ) + else: + result_series = rows_as_json_series._apply_unary_op( + ops.RemoteFunctionOp( + function_def=func.udf_def, apply_on_null=True + ) + ) else: # This is a special case where we are providing not-pandas-like - # extension. If the remote function can take one or more params - # then we assume that here the user intention is to use the - # column values of the dataframe as arguments to the function. - # For this to work the following condition must be true: - # 1. The number or input params in the function must be same - # as the number of columns in the dataframe + # extension. If the bigquery function can take one or more + # params (excluding the args) then we assume that here the user + # intention is to use the column values of the dataframe as + # arguments to the function. For this to work the following + # condition must be true: + # 1. The number or input params (excluding the args) in the + # function must be same as the number of columns in the + # dataframe. # 2. The dtypes of the columns in the dataframe must be - # compatible with the data types of the input params + # compatible with the data types of the input params. # 3. The order of the columns in the dataframe must correspond - # to the order of the input params in the function - udf_input_dtypes = getattr(func, "input_dtypes") - if len(udf_input_dtypes) != len(self.columns): + # to the order of the input params in the function. + udf_input_dtypes = func.udf_def.signature.bf_input_types + if not args and len(udf_input_dtypes) != len(self.columns): + raise ValueError( + f"Parameter count mismatch: BigFrames BigQuery function" + f" expected {len(udf_input_dtypes)} parameters but" + f" received {len(self.columns)} DataFrame columns." + ) + if args and len(udf_input_dtypes) != len(self.columns) + len(args): raise ValueError( - f"Remote function takes {len(udf_input_dtypes)} arguments but DataFrame has {len(self.columns)} columns." + f"Parameter count mismatch: BigFrames BigQuery function" + f" expected {len(udf_input_dtypes)} parameters but" + f" received {len(self.columns) + len(args)} values" + f" ({len(self.columns)} DataFrame columns and" + f" {len(args)} args)." ) - if udf_input_dtypes != tuple(self.dtypes.to_list()): + end_slice = -len(args) if args else None + if udf_input_dtypes[:end_slice] != tuple(self.dtypes.to_list()): raise ValueError( - f"Remote function takes arguments of types {udf_input_dtypes} but DataFrame dtypes are {tuple(self.dtypes)}." + f"Data type mismatch for DataFrame columns:" + f" Expected {udf_input_dtypes[:end_slice]}" + f" Received {tuple(self.dtypes)}." ) + if args: + bq_types = ( + function_typing.sdk_type_from_python_type(type(arg)) + for arg in args + ) + args_dtype = tuple( + function_typing.sdk_type_to_bf_type(bq_type) + for bq_type in bq_types + ) + if udf_input_dtypes[end_slice:] != args_dtype: + raise ValueError( + f"Data type mismatch for 'args' parameter:" + f" Expected {udf_input_dtypes[end_slice:]}" + f" Received {args_dtype}." + ) series_list = [self[col] for col in self.columns] + op_list = series_list[1:] + list(args) result_series = series_list[0]._apply_nary_op( - ops.NaryRemoteFunctionOp(func=func), series_list[1:] + ops.NaryRemoteFunctionOp(function_def=func.udf_def), op_list ) result_series.name = None - # if the output is an array, reconstruct it from the json serialized - # string form - if bigframes.dtypes.is_array_like(func.output_dtype): - import bigframes.bigquery as bbq - - result_dtype = bigframes.dtypes.arrow_dtype_to_bigframes_dtype( - func.output_dtype.pyarrow_dtype.value_type - ) - result_series = bbq.json_extract_string_array( - result_series, value_dtype=result_dtype - ) - + result_series = func._post_process_series(result_series) return result_series - # At this point column-wise or element-wise remote function operation will + # At this point column-wise or element-wise bigquery function operation will # be performed (not supported). - if hasattr(func, "bigframes_remote_function"): - raise NotImplementedError( - "BigFrames DataFrame '.apply()' does not support remote function " - "for column-wise (i.e. with axis=0) operations, please use a " - "regular python function instead. For element-wise operations of " - "the remote function, please use '.map()'." + if hasattr(func, "bigframes_bigquery_function"): + raise formatter.create_exception_with_feedback_link( + NotImplementedError, + "BigFrames DataFrame '.apply()' does not support BigFrames " + "BigQuery function for column-wise (i.e. with axis=0) " + "operations, please use a regular python function instead. For " + "element-wise operations of the BigFrames BigQuery function, " + "please use '.map()'.", ) # Per-column apply @@ -4129,8 +4887,6 @@ def drop_duplicates( *, keep: str = "first", ) -> DataFrame: - if keep is not False: - validations.enforce_ordered(self, "drop_duplicates(keep != False)") if subset is None: column_ids = self._block.value_columns elif utils.is_list_like(subset): @@ -4144,8 +4900,6 @@ def drop_duplicates( return DataFrame(block) def duplicated(self, subset=None, keep: str = "first") -> bigframes.series.Series: - if keep is not False: - validations.enforce_ordered(self, "duplicated(keep != False)") if subset is None: column_ids = self._block.value_columns else: @@ -4156,7 +4910,7 @@ def duplicated(self, subset=None, keep: str = "first") -> bigframes.series.Serie return bigframes.series.Series( block.select_column( indicator, - ) + ).with_column_labels(pandas.Index([None])), ) def rank( @@ -4166,9 +4920,12 @@ def rank( numeric_only=False, na_option: str = "keep", ascending=True, + pct: bool = False, ) -> DataFrame: df = self._drop_non_numeric() if numeric_only else self - return DataFrame(block_ops.rank(df._block, method, na_option, ascending)) + return DataFrame( + block_ops.rank(df._block, method, na_option, ascending, pct=pct) + ) def first_valid_index(self): return @@ -4390,4 +5147,17 @@ def _throw_if_null_index(self, opname: str): @property def semantics(self): + msg = bfe.format_message( + "The 'semantics' property will be removed. Please use 'bigframes.bigquery.ai' instead." + ) + warnings.warn(msg, category=FutureWarning) return bigframes.operations.semantics.Semantics(self) + + @property + def ai(self): + """Returns the accessor for AI operators.""" + msg = bfe.format_message( + "The 'ai' property will be removed. Please use 'bigframes.bigquery.ai' instead." + ) + warnings.warn(msg, category=FutureWarning) + return bigframes.operations.ai.AIAccessor(self) diff --git a/bigframes/display/__init__.py b/bigframes/display/__init__.py new file mode 100644 index 0000000000..aa1371db56 --- /dev/null +++ b/bigframes/display/__init__.py @@ -0,0 +1,48 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Interactive display objects for BigQuery DataFrames.""" + +from __future__ import annotations + +from typing import Any + + +def __getattr__(name: str) -> Any: + """Lazily import TableWidget to avoid ZMQ port conflicts. + + anywidget and traitlets eagerly initialize kernel communication channels on + import. This can lead to race conditions and ZMQ port conflicts when + multiple Jupyter kernels are started in parallel, such as during notebook + tests. By using __getattr__, we defer the import of TableWidget until it is + explicitly accessed, preventing premature initialization and avoiding port + collisions. + """ + if name == "TableWidget": + try: + import anywidget # noqa + + from bigframes.display.anywidget import TableWidget + + return TableWidget + except Exception: + raise AttributeError( + f"module '{__name__}' has no attribute '{name}'. " + "TableWidget requires anywidget and traitlets to be installed. " + "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'`." + ) + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") + + +__all__ = ["TableWidget"] diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py new file mode 100644 index 0000000000..a81aff9080 --- /dev/null +++ b/bigframes/display/anywidget.py @@ -0,0 +1,385 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Interactive, paginated table widget for BigFrames DataFrames.""" + +from __future__ import annotations + +import dataclasses +from importlib import resources +import functools +import math +import threading +from typing import Any, Iterator, Optional +import uuid + +import pandas as pd + +import bigframes +from bigframes.core import blocks +import bigframes.dataframe +import bigframes.display.html +import bigframes.dtypes as dtypes + +# anywidget and traitlets are optional dependencies. We don't want the import of +# this module to fail if they aren't installed, though. Instead, we try to +# limit the surface that these packages could affect. This makes unit testing +# easier and ensures we don't accidentally make these required packages. +try: + import anywidget + import traitlets + + _ANYWIDGET_INSTALLED = True +except Exception: + _ANYWIDGET_INSTALLED = False + +_WIDGET_BASE: type[Any] +if _ANYWIDGET_INSTALLED: + _WIDGET_BASE = anywidget.AnyWidget +else: + _WIDGET_BASE = object + + +@dataclasses.dataclass(frozen=True) +class _SortState: + column: str + ascending: bool + + +class TableWidget(_WIDGET_BASE): + """An interactive, paginated table widget for BigFrames DataFrames. + + This widget provides a user-friendly way to display and navigate through + large BigQuery DataFrames within a Jupyter environment. + """ + + page = traitlets.Int(0).tag(sync=True) + page_size = traitlets.Int(0).tag(sync=True) + row_count = traitlets.Int(allow_none=True, default_value=None).tag(sync=True) + table_html = traitlets.Unicode("").tag(sync=True) + sort_column = traitlets.Unicode("").tag(sync=True) + sort_ascending = traitlets.Bool(True).tag(sync=True) + orderable_columns = traitlets.List(traitlets.Unicode(), []).tag(sync=True) + _initial_load_complete = traitlets.Bool(False).tag(sync=True) + _batches: Optional[blocks.PandasBatches] = None + _error_message = traitlets.Unicode(allow_none=True, default_value=None).tag( + sync=True + ) + + def __init__(self, dataframe: bigframes.dataframe.DataFrame): + """Initialize the TableWidget. + + Args: + dataframe: The Bigframes Dataframe to display in the widget. + """ + if not _ANYWIDGET_INSTALLED: + raise ImportError( + "Please `pip install anywidget traitlets` or " + "`pip install 'bigframes[anywidget]'` to use TableWidget." + ) + + self._dataframe = dataframe + + super().__init__() + + # Initialize attributes that might be needed by observers first + self._table_id = str(uuid.uuid4()) + self._all_data_loaded = False + self._batch_iter: Optional[Iterator[pd.DataFrame]] = None + self._cached_batches: list[pd.DataFrame] = [] + self._last_sort_state: Optional[_SortState] = None + # Lock to ensure only one thread at a time is updating the table HTML. + self._setting_html_lock = threading.Lock() + + # respect display options for initial page size + initial_page_size = bigframes.options.display.max_rows + + # set traitlets properties that trigger observers + # TODO(b/462525985): Investigate and improve TableWidget UX for DataFrames with a large number of columns. + self.page_size = initial_page_size + # TODO(b/469861913): Nested columns from structs (e.g., 'struct_col.name') are not currently sortable. + # TODO(b/463754889): Support non-string column labels for sorting. + if all(isinstance(col, str) for col in dataframe.columns): + self.orderable_columns = [ + str(col_name) + for col_name, dtype in dataframe.dtypes.items() + if dtypes.is_orderable(dtype) + ] + else: + self.orderable_columns = [] + + self._initial_load() + + # Signals to the frontend that the initial data load is complete. + # Also used as a guard to prevent observers from firing during initialization. + self._initial_load_complete = True + + def _initial_load(self) -> None: + """Get initial data and row count.""" + # obtain the row counts + # TODO(b/428238610): Start iterating over the result of `to_pandas_batches()` + # before we get here so that the count might already be cached. + self._reset_batches_for_new_page_size() + + if self._batches is None: + self._error_message = ( + "Could not retrieve data batches. Data might be unavailable or " + "an error occurred." + ) + self.row_count = None + elif self._batches.total_rows is None: + # Total rows is unknown, this is an expected state. + # TODO(b/461536343): Cheaply discover if we have exactly 1 page. + # There are cases where total rows is not set, but there are no additional + # pages. We could disable the "next" button in these cases. + self.row_count = None + else: + self.row_count = self._batches.total_rows + + # get the initial page + self._set_table_html() + + @traitlets.observe("_initial_load_complete") + def _on_initial_load_complete(self, change: dict[str, Any]): + if change["new"]: + self._set_table_html() + + @functools.cached_property + def _esm(self): + """Load JavaScript code from external file.""" + return resources.read_text(bigframes.display, "table_widget.js") + + @functools.cached_property + def _css(self): + """Load CSS code from external file.""" + return resources.read_text(bigframes.display, "table_widget.css") + + @traitlets.validate("page") + def _validate_page(self, proposal: dict[str, Any]) -> int: + """Validate and clamp the page number to a valid range. + + Args: + proposal: A dictionary from the traitlets library containing the + proposed change. The new value is in proposal["value"]. + + Returns: + The validated and clamped page number as an integer. + """ + value = proposal["value"] + + if value < 0: + raise ValueError("Page number cannot be negative.") + + # If truly empty or invalid page size, stay on page 0. + # This handles cases where row_count is 0 or page_size is 0, preventing + # division by zero or nonsensical pagination, regardless of row_count being None. + if self.row_count == 0 or self.page_size == 0: + return 0 + + # If row count is unknown, allow any non-negative page. The previous check + # ensures that invalid page_size (0) is already handled. + if self.row_count is None: + return value + + # Calculate the zero-indexed maximum page number. + max_page = max(0, math.ceil(self.row_count / self.page_size) - 1) + + # Clamp the proposed value to the valid range [0, max_page]. + return max(0, min(value, max_page)) + + @traitlets.validate("page_size") + def _validate_page_size(self, proposal: dict[str, Any]) -> int: + """Validate page size to ensure it's positive and reasonable. + + Args: + proposal: A dictionary from the traitlets library containing the + proposed change. The new value is in proposal["value"]. + + Returns: + The validated page size as an integer. + """ + value = proposal["value"] + + # Ensure page size is positive and within reasonable bounds + if value <= 0: + return self.page_size # Keep current value + + # Cap at reasonable maximum to prevent performance issues + max_page_size = 1000 + return min(value, max_page_size) + + def _get_next_batch(self) -> bool: + """ + Gets the next batch of data from the generator and appends to cache. + + Returns: + True if a batch was successfully loaded, False otherwise. + """ + if self._all_data_loaded: + return False + + try: + iterator = self._batch_iterator + batch = next(iterator) + self._cached_batches.append(batch) + return True + except StopIteration: + self._all_data_loaded = True + return False + + @property + def _batch_iterator(self) -> Iterator[pd.DataFrame]: + """Lazily initializes and returns the batch iterator.""" + if self._batch_iter is None: + if self._batches is None: + self._batch_iter = iter([]) + else: + self._batch_iter = iter(self._batches) + return self._batch_iter + + @property + def _cached_data(self) -> pd.DataFrame: + """Combine all cached batches into a single DataFrame.""" + if not self._cached_batches: + return pd.DataFrame(columns=self._dataframe.columns) + return pd.concat(self._cached_batches) + + def _reset_batch_cache(self) -> None: + """Resets batch caching attributes.""" + self._cached_batches = [] + self._batch_iter = None + self._all_data_loaded = False + + def _reset_batches_for_new_page_size(self) -> None: + """Reset the batch iterator when page size changes.""" + self._batches = self._dataframe.to_pandas_batches(page_size=self.page_size) + + self._reset_batch_cache() + + def _set_table_html(self) -> None: + """Sets the current html data based on the current page and page size.""" + new_page = None + with self._setting_html_lock: + if self._error_message: + self.table_html = ( + f"
" + f"{self._error_message}
" + ) + return + + # Apply sorting if a column is selected + df_to_display = self._dataframe + if self.sort_column: + # TODO(b/463715504): Support sorting by index columns. + df_to_display = df_to_display.sort_values( + by=self.sort_column, ascending=self.sort_ascending + ) + + # Reset batches when sorting changes + if self._last_sort_state != _SortState( + self.sort_column, self.sort_ascending + ): + self._batches = df_to_display.to_pandas_batches( + page_size=self.page_size + ) + self._reset_batch_cache() + self._last_sort_state = _SortState( + self.sort_column, self.sort_ascending + ) + if self.page != 0: + new_page = 0 # Reset to first page + + if new_page is None: + start = self.page * self.page_size + end = start + self.page_size + + # fetch more data if the requested page is outside our cache + cached_data = self._cached_data + while len(cached_data) < end and not self._all_data_loaded: + if self._get_next_batch(): + cached_data = self._cached_data + else: + break + + # Get the data for the current page + page_data = cached_data.iloc[start:end].copy() + + # Handle case where user navigated beyond available data with unknown row count + is_unknown_count = self.row_count is None + is_beyond_data = ( + self._all_data_loaded and len(page_data) == 0 and self.page > 0 + ) + if is_unknown_count and is_beyond_data: + # Calculate the last valid page (zero-indexed) + total_rows = len(cached_data) + last_valid_page = max(0, math.ceil(total_rows / self.page_size) - 1) + if self.page != last_valid_page: + new_page = last_valid_page + + if new_page is None: + # Handle index display + if self._dataframe._block.has_index: + is_unnamed_single_index = ( + page_data.index.name is None + and not isinstance(page_data.index, pd.MultiIndex) + ) + page_data = page_data.reset_index() + if is_unnamed_single_index and "index" in page_data.columns: + page_data.rename(columns={"index": ""}, inplace=True) + + # Default index - include as "Row" column if no index was present originally + if not self._dataframe._block.has_index: + page_data.insert( + 0, "Row", range(start + 1, start + len(page_data) + 1) + ) + + # Generate HTML table + self.table_html = bigframes.display.html.render_html( + dataframe=page_data, + table_id=f"table-{self._table_id}", + ) + + if new_page is not None: + # Navigate to the new page. This triggers the observer, which will + # re-enter _set_table_html. Since we've released the lock, this is safe. + self.page = new_page + + @traitlets.observe("sort_column", "sort_ascending") + def _sort_changed(self, _change: dict[str, Any]): + """Handler for when sorting parameters change from the frontend.""" + self._set_table_html() + + @traitlets.observe("page") + def _page_changed(self, _change: dict[str, Any]) -> None: + """Handler for when the page number is changed from the frontend.""" + if not self._initial_load_complete: + return + self._set_table_html() + + @traitlets.observe("page_size") + def _page_size_changed(self, _change: dict[str, Any]) -> None: + """Handler for when the page size is changed from the frontend.""" + if not self._initial_load_complete: + return + # Reset the page to 0 when page size changes to avoid invalid page states + self.page = 0 + # Reset the sort state to default (no sort) + self.sort_column = "" + self.sort_ascending = True + + # Reset batches to use new page size for future data fetching + self._reset_batches_for_new_page_size() + + # Update the table display + self._set_table_html() diff --git a/bigframes/display/html.py b/bigframes/display/html.py new file mode 100644 index 0000000000..912f1d7e3a --- /dev/null +++ b/bigframes/display/html.py @@ -0,0 +1,317 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""HTML rendering for DataFrames and other objects.""" + +from __future__ import annotations + +import html +import json +import traceback +import typing +from typing import Any, Union +import warnings + +import pandas as pd +import pandas.api.types + +import bigframes +from bigframes._config import display_options, options +from bigframes.display import plaintext +import bigframes.formatting_helpers as formatter + +if typing.TYPE_CHECKING: + import bigframes.dataframe + import bigframes.series + + +def _is_dtype_numeric(dtype: Any) -> bool: + """Check if a dtype is numeric for alignment purposes.""" + return pandas.api.types.is_numeric_dtype(dtype) + + +def render_html( + *, + dataframe: pd.DataFrame, + table_id: str, + orderable_columns: list[str] | None = None, +) -> str: + """Render a pandas DataFrame to HTML with specific styling.""" + orderable_columns = orderable_columns or [] + classes = "dataframe table table-striped table-hover" + table_html_parts = [f''] + table_html_parts.append(_render_table_header(dataframe, orderable_columns)) + table_html_parts.append(_render_table_body(dataframe)) + table_html_parts.append("
") + return "".join(table_html_parts) + + +def _render_table_header(dataframe: pd.DataFrame, orderable_columns: list[str]) -> str: + """Render the header of the HTML table.""" + header_parts = [" ", " "] + for col in dataframe.columns: + th_classes = [] + if col in orderable_columns: + th_classes.append("sortable") + class_str = f'class="{" ".join(th_classes)}"' if th_classes else "" + header_parts.append( + f'
' + f"{html.escape(str(col))}
" + ) + header_parts.extend([" ", " "]) + return "\n".join(header_parts) + + +def _render_table_body(dataframe: pd.DataFrame) -> str: + """Render the body of the HTML table.""" + body_parts = [" "] + precision = options.display.precision + + for i in range(len(dataframe)): + body_parts.append(" ") + row = dataframe.iloc[i] + for col_name, value in row.items(): + dtype = dataframe.dtypes.loc[col_name] # type: ignore + align = "right" if _is_dtype_numeric(dtype) else "left" + + # TODO(b/438181139): Consider semi-exploding ARRAY/STRUCT columns + # into multiple rows/columns like the BQ UI does. + if pandas.api.types.is_scalar(value) and pd.isna(value): + body_parts.append( + f' ' + '<NA>' + ) + else: + if isinstance(value, float): + cell_content = f"{value:.{precision}f}" + else: + cell_content = str(value) + body_parts.append( + f' ' + f"{html.escape(cell_content)}" + ) + body_parts.append(" ") + body_parts.append(" ") + return "\n".join(body_parts) + + +def _obj_ref_rt_to_html(obj_ref_rt: str) -> str: + obj_ref_rt_json = json.loads(obj_ref_rt) + obj_ref_details = obj_ref_rt_json["objectref"]["details"] + if "gcs_metadata" in obj_ref_details: + gcs_metadata = obj_ref_details["gcs_metadata"] + content_type = typing.cast(str, gcs_metadata.get("content_type", "")) + if content_type.startswith("image"): + size_str = "" + if options.display.blob_display_width: + size_str = f' width="{options.display.blob_display_width}"' + if options.display.blob_display_height: + size_str = size_str + f' height="{options.display.blob_display_height}"' + url = obj_ref_rt_json["access_urls"]["read_url"] + return f'' + + return f'uri: {obj_ref_rt_json["objectref"]["uri"]}, authorizer: {obj_ref_rt_json["objectref"]["authorizer"]}' + + +def create_html_representation( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], + pandas_df: pd.DataFrame, + total_rows: int, + total_columns: int, + blob_cols: list[str], +) -> str: + """Create an HTML representation of the DataFrame or Series.""" + from bigframes.series import Series + + opts = options.display + with display_options.pandas_repr(opts): + if isinstance(obj, Series): + # Some pandas objects may not have a _repr_html_ method, or it might + # fail in certain environments. We fall back to a pre-formatted + # string representation to ensure something is always displayed. + pd_series = pandas_df.iloc[:, 0] + try: + # TODO(b/464053870): Support rich display for blob Series. + html_string = pd_series._repr_html_() + except AttributeError: + html_string = f"
{pd_series.to_string()}
" + + is_truncated = total_rows is not None and total_rows > len(pandas_df) + if is_truncated: + html_string += f"

[{total_rows} rows]

" + return html_string + else: + # It's a DataFrame + # TODO(shuowei, b/464053870): Escaping HTML would be useful, but + # `escape=False` is needed to show images. We may need to implement + # a full-fledged repr module to better support types not in pandas. + if options.display.blob_display and blob_cols: + formatters = {blob_col: _obj_ref_rt_to_html for blob_col in blob_cols} + + # set max_colwidth so not to truncate the image url + with pandas.option_context("display.max_colwidth", None): + html_string = pandas_df.to_html( + escape=False, + notebook=True, + max_rows=pandas.get_option("display.max_rows"), + max_cols=pandas.get_option("display.max_columns"), + show_dimensions=pandas.get_option("display.show_dimensions"), + formatters=formatters, # type: ignore + ) + else: + # _repr_html_ stub is missing so mypy thinks it's a Series. Ignore mypy. + html_string = pandas_df._repr_html_() # type:ignore + + html_string += f"[{total_rows} rows x {total_columns} columns in total]" + return html_string + + +def _get_obj_metadata( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], +) -> tuple[bool, bool]: + from bigframes.series import Series + + is_series = isinstance(obj, Series) + if is_series: + has_index = len(obj._block.index_columns) > 0 + else: + has_index = obj._has_index + return is_series, has_index + + +def get_anywidget_bundle( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], + include=None, + exclude=None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """ + Helper method to create and return the anywidget mimebundle. + This function encapsulates the logic for anywidget display. + """ + from bigframes import display + from bigframes.series import Series + + if isinstance(obj, Series): + df = obj.to_frame() + else: + df, blob_cols = obj._get_display_df_and_blob_cols() + + widget = display.TableWidget(df) + widget_repr_result = widget._repr_mimebundle_(include=include, exclude=exclude) + + if isinstance(widget_repr_result, tuple): + widget_repr, widget_metadata = widget_repr_result + else: + widget_repr = widget_repr_result + widget_metadata = {} + + widget_repr = dict(widget_repr) + + # Use cached data from widget to render HTML and plain text versions. + cached_pd = widget._cached_data + total_rows = widget.row_count + total_columns = len(df.columns) + + widget_repr["text/html"] = create_html_representation( + obj, + cached_pd, + total_rows, + total_columns, + blob_cols if "blob_cols" in locals() else [], + ) + is_series, has_index = _get_obj_metadata(obj) + widget_repr["text/plain"] = plaintext.create_text_representation( + cached_pd, + total_rows, + is_series=is_series, + has_index=has_index, + column_count=len(df.columns) if not is_series else 0, + ) + + return widget_repr, widget_metadata + + +def repr_mimebundle_deferred( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], +) -> dict[str, str]: + return { + "text/plain": formatter.repr_query_job(obj._compute_dry_run()), + "text/html": formatter.repr_query_job_html(obj._compute_dry_run()), + } + + +def repr_mimebundle_head( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], +) -> dict[str, str]: + from bigframes.series import Series + + opts = options.display + blob_cols: list[str] + if isinstance(obj, Series): + pandas_df, row_count, query_job = obj._block.retrieve_repr_request_results( + opts.max_rows + ) + blob_cols = [] + else: + df, blob_cols = obj._get_display_df_and_blob_cols() + pandas_df, row_count, query_job = df._block.retrieve_repr_request_results( + opts.max_rows + ) + + obj._set_internal_query_job(query_job) + column_count = len(pandas_df.columns) + + html_string = create_html_representation( + obj, pandas_df, row_count, column_count, blob_cols + ) + + is_series, has_index = _get_obj_metadata(obj) + text_representation = plaintext.create_text_representation( + pandas_df, + row_count, + is_series=is_series, + has_index=has_index, + column_count=len(pandas_df.columns) if not is_series else 0, + ) + + return {"text/html": html_string, "text/plain": text_representation} + + +def repr_mimebundle( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], + include=None, + exclude=None, +): + """Custom display method for IPython/Jupyter environments.""" + # TODO(b/467647693): Anywidget integration has been tested in Jupyter, VS Code, and + # BQ Studio, but there is a known compatibility issue with Marimo that needs to be addressed. + + opts = options.display + if opts.repr_mode == "deferred": + return repr_mimebundle_deferred(obj) + + if opts.repr_mode == "anywidget": + try: + return get_anywidget_bundle(obj, include=include, exclude=exclude) + except ImportError: + # Anywidget is an optional dependency, so warn rather than fail. + # TODO(shuowei): When Anywidget becomes the default for all repr modes, + # remove this warning. + warnings.warn( + "Anywidget mode is not available. " + "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use interactive tables. " + f"Falling back to static HTML. Error: {traceback.format_exc()}" + ) + + return repr_mimebundle_head(obj) diff --git a/bigframes/display/plaintext.py b/bigframes/display/plaintext.py new file mode 100644 index 0000000000..2f7bc1df07 --- /dev/null +++ b/bigframes/display/plaintext.py @@ -0,0 +1,102 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Plaintext display representations.""" + +from __future__ import annotations + +import typing + +import pandas +import pandas.io.formats + +from bigframes._config import display_options, options + +if typing.TYPE_CHECKING: + import pandas as pd + + +def create_text_representation( + pandas_df: pd.DataFrame, + total_rows: typing.Optional[int], + is_series: bool, + has_index: bool = True, + column_count: int = 0, +) -> str: + """Create a text representation of the DataFrame or Series. + + Args: + pandas_df: + The pandas DataFrame containing the data to represent. + total_rows: + The total number of rows in the original BigFrames object. + is_series: + Whether the object being represented is a Series. + has_index: + Whether the object has an index to display. + column_count: + The total number of columns in the original BigFrames object. + Only used for DataFrames. + + Returns: + A plaintext string representation. + """ + opts = options.display + + if is_series: + with display_options.pandas_repr(opts): + pd_series = pandas_df.iloc[:, 0] + if not has_index: + repr_string = pd_series.to_string( + length=False, index=False, name=True, dtype=True + ) + else: + repr_string = pd_series.to_string(length=False, name=True, dtype=True) + + lines = repr_string.split("\n") + is_truncated = total_rows is not None and total_rows > len(pandas_df) + + if is_truncated: + lines.append("...") + lines.append("") # Add empty line for spacing only if truncated + lines.append(f"[{total_rows} rows]") + + return "\n".join(lines) + + else: + # DataFrame + with display_options.pandas_repr(opts): + # safe to mutate this, this dict is owned by this code, and does not affect global config + to_string_kwargs = ( + pandas.io.formats.format.get_dataframe_repr_params() # type: ignore + ) + if not has_index: + to_string_kwargs.update({"index": False}) + + # We add our own dimensions string, so don't want pandas to. + to_string_kwargs.update({"show_dimensions": False}) + repr_string = pandas_df.to_string(**to_string_kwargs) + + lines = repr_string.split("\n") + is_truncated = total_rows is not None and total_rows > len(pandas_df) + + if is_truncated: + lines.append("...") + lines.append("") # Add empty line for spacing only if truncated + lines.append(f"[{total_rows or '?'} rows x {column_count} columns]") + else: + # For non-truncated DataFrames, we still need to add dimensions if show_dimensions was False + lines.append("") + lines.append(f"[{total_rows or '?'} rows x {column_count} columns]") + return "\n".join(lines) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css new file mode 100644 index 0000000000..34134b043d --- /dev/null +++ b/bigframes/display/table_widget.css @@ -0,0 +1,152 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.bigframes-widget { + display: flex; + flex-direction: column; +} + +.bigframes-widget .table-container { + max-height: 620px; + overflow: auto; +} + +.bigframes-widget .footer { + align-items: center; + /* TODO(b/460861328): We will support dark mode in a media selector once we + * determine how to override the background colors as well. */ + color: black; + display: flex; + font-family: + "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; + font-size: 0.8rem; + justify-content: space-between; + padding: 8px; +} + +.bigframes-widget .footer > * { + flex: 1; +} + +.bigframes-widget .pagination { + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: center; + padding: 4px; +} + +.bigframes-widget .page-indicator { + margin: 0 8px; +} + +.bigframes-widget .row-count { + margin: 0 8px; +} + +.bigframes-widget .page-size { + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: end; +} + +.bigframes-widget .page-size label { + margin-right: 8px; +} + +.bigframes-widget table { + border-collapse: collapse; + /* TODO(b/460861328): We will support dark mode in a media selector once we + * determine how to override the background colors as well. */ + color: black; + text-align: left; +} + +.bigframes-widget th { + background-color: var(--colab-primary-surface-color, var(--jp-layout-color0)); + padding: 0; + position: sticky; + text-align: left; + top: 0; + z-index: 1; +} + +.bigframes-widget .bf-header-content { + box-sizing: border-box; + height: 100%; + overflow: auto; + padding: 0.5em; + resize: horizontal; + width: 100%; +} + +.bigframes-widget th .sort-indicator { + padding-left: 4px; + visibility: hidden; +} + +.bigframes-widget th:hover .sort-indicator { + visibility: visible; +} + +.bigframes-widget button { + cursor: pointer; + display: inline-block; + text-align: center; + text-decoration: none; + user-select: none; + vertical-align: middle; +} + +.bigframes-widget button:disabled { + opacity: 0.65; + pointer-events: none; +} + +.bigframes-widget .bigframes-error-message { + background-color: #fbe; + border: 1px solid red; + border-radius: 4px; + font-family: + "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; + font-size: 14px; + margin-bottom: 8px; + padding: 8px; +} + +.bigframes-widget .cell-align-right { + text-align: right; +} + +.bigframes-widget .cell-align-left { + text-align: left; +} + +.bigframes-widget .null-value { + color: gray; +} + +.bigframes-widget td { + padding: 0.5em; +} + +.bigframes-widget tr:hover td, +.bigframes-widget td.row-hover { + background-color: var(--colab-hover-surface-color, var(--jp-layout-color2)); +} diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js new file mode 100644 index 0000000000..ae49eaf9cf --- /dev/null +++ b/bigframes/display/table_widget.js @@ -0,0 +1,301 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const ModelProperty = { + ERROR_MESSAGE: "error_message", + ORDERABLE_COLUMNS: "orderable_columns", + PAGE: "page", + PAGE_SIZE: "page_size", + ROW_COUNT: "row_count", + SORT_ASCENDING: "sort_ascending", + SORT_COLUMN: "sort_column", + TABLE_HTML: "table_html", +}; + +const Event = { + CHANGE: "change", + CHANGE_TABLE_HTML: "change:table_html", + CLICK: "click", +}; + +/** + * Renders the interactive table widget. + * @param {{ model: any, el: !HTMLElement }} props - The widget properties. + */ +function render({ model, el }) { + // Main container with a unique class for CSS scoping + el.classList.add("bigframes-widget"); + + // Add error message container at the top + const errorContainer = document.createElement("div"); + errorContainer.classList.add("error-message"); + + const tableContainer = document.createElement("div"); + tableContainer.classList.add("table-container"); + const footer = document.createElement("footer"); + footer.classList.add("footer"); + + // Pagination controls + const paginationContainer = document.createElement("div"); + paginationContainer.classList.add("pagination"); + const prevPage = document.createElement("button"); + const pageIndicator = document.createElement("span"); + pageIndicator.classList.add("page-indicator"); + const nextPage = document.createElement("button"); + const rowCountLabel = document.createElement("span"); + rowCountLabel.classList.add("row-count"); + + // Page size controls + const pageSizeContainer = document.createElement("div"); + pageSizeContainer.classList.add("page-size"); + const pageSizeLabel = document.createElement("label"); + const pageSizeInput = document.createElement("select"); + + prevPage.textContent = "<"; + nextPage.textContent = ">"; + pageSizeLabel.textContent = "Page size:"; + + // Page size options + const pageSizes = [10, 25, 50, 100]; + for (const size of pageSizes) { + const option = document.createElement("option"); + option.value = size; + option.textContent = size; + if (size === model.get(ModelProperty.PAGE_SIZE)) { + option.selected = true; + } + pageSizeInput.appendChild(option); + } + + /** Updates the footer states and page label based on the model. */ + function updateButtonStates() { + const currentPage = model.get(ModelProperty.PAGE); + const pageSize = model.get(ModelProperty.PAGE_SIZE); + const rowCount = model.get(ModelProperty.ROW_COUNT); + + if (rowCount === null) { + // Unknown total rows + rowCountLabel.textContent = "Total rows unknown"; + pageIndicator.textContent = `Page ${( + currentPage + 1 + ).toLocaleString()} of many`; + prevPage.disabled = currentPage === 0; + nextPage.disabled = false; // Allow navigation until we hit the end + } else if (rowCount === 0) { + // Empty dataset + rowCountLabel.textContent = "0 total rows"; + pageIndicator.textContent = "Page 1 of 1"; + prevPage.disabled = true; + nextPage.disabled = true; + } else { + // Known total rows + const totalPages = Math.ceil(rowCount / pageSize); + rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; + pageIndicator.textContent = `Page ${( + currentPage + 1 + ).toLocaleString()} of ${totalPages.toLocaleString()}`; + prevPage.disabled = currentPage === 0; + nextPage.disabled = currentPage >= totalPages - 1; + } + pageSizeInput.value = pageSize; + } + + /** + * Handles page navigation. + * @param {number} direction - The direction to navigate (-1 for previous, 1 for next). + */ + function handlePageChange(direction) { + const currentPage = model.get(ModelProperty.PAGE); + model.set(ModelProperty.PAGE, currentPage + direction); + model.save_changes(); + } + + /** + * Handles page size changes. + * @param {number} newSize - The new page size. + */ + function handlePageSizeChange(newSize) { + model.set(ModelProperty.PAGE_SIZE, newSize); + model.set(ModelProperty.PAGE, 0); // Reset to first page + model.save_changes(); + } + + /** Updates the HTML in the table container and refreshes button states. */ + function handleTableHTMLChange() { + // Note: Using innerHTML is safe here because the content is generated + // by a trusted backend (DataFrame.to_html). + tableContainer.innerHTML = model.get(ModelProperty.TABLE_HTML); + + // Get sortable columns from backend + const sortableColumns = model.get(ModelProperty.ORDERABLE_COLUMNS); + const currentSortColumn = model.get(ModelProperty.SORT_COLUMN); + const currentSortAscending = model.get(ModelProperty.SORT_ASCENDING); + + // Add click handlers to column headers for sorting + const headers = tableContainer.querySelectorAll("th"); + headers.forEach((header) => { + const headerDiv = header.querySelector("div"); + const columnName = headerDiv.textContent.trim(); + + // Only add sorting UI for sortable columns + if (columnName && sortableColumns.includes(columnName)) { + header.style.cursor = "pointer"; + + // Create a span for the indicator + const indicatorSpan = document.createElement("span"); + indicatorSpan.classList.add("sort-indicator"); + indicatorSpan.style.paddingLeft = "5px"; + + // Determine sort indicator and initial visibility + let indicator = "●"; // Default: unsorted (dot) + if (currentSortColumn === columnName) { + indicator = currentSortAscending ? "▲" : "▼"; + indicatorSpan.style.visibility = "visible"; // Sorted arrows always visible + } else { + indicatorSpan.style.visibility = "hidden"; // Unsorted dot hidden by default + } + indicatorSpan.textContent = indicator; + + // Add indicator to the header, replacing the old one if it exists + const existingIndicator = headerDiv.querySelector(".sort-indicator"); + if (existingIndicator) { + headerDiv.removeChild(existingIndicator); + } + headerDiv.appendChild(indicatorSpan); + + // Add hover effects for unsorted columns only + header.addEventListener("mouseover", () => { + if (currentSortColumn !== columnName) { + indicatorSpan.style.visibility = "visible"; + } + }); + header.addEventListener("mouseout", () => { + if (currentSortColumn !== columnName) { + indicatorSpan.style.visibility = "hidden"; + } + }); + + // Add click handler for three-state toggle + header.addEventListener(Event.CLICK, () => { + if (currentSortColumn === columnName) { + if (currentSortAscending) { + // Currently ascending → switch to descending + model.set(ModelProperty.SORT_ASCENDING, false); + } else { + // Currently descending → clear sort (back to unsorted) + model.set(ModelProperty.SORT_COLUMN, ""); + model.set(ModelProperty.SORT_ASCENDING, true); + } + } else { + // Not currently sorted → sort ascending + model.set(ModelProperty.SORT_COLUMN, columnName); + model.set(ModelProperty.SORT_ASCENDING, true); + } + model.save_changes(); + }); + } + }); + + const table = tableContainer.querySelector("table"); + if (table) { + const tableBody = table.querySelector("tbody"); + + /** + * Handles row hover events. + * @param {!Event} event - The mouse event. + * @param {boolean} isHovering - True to add hover class, false to remove. + */ + function handleRowHover(event, isHovering) { + const cell = event.target.closest("td"); + if (cell) { + const row = cell.closest("tr"); + const origRowId = row.dataset.origRow; + if (origRowId) { + const allCellsInGroup = tableBody.querySelectorAll( + `tr[data-orig-row="${origRowId}"] td`, + ); + allCellsInGroup.forEach((c) => { + c.classList.toggle("row-hover", isHovering); + }); + } + } + } + + if (tableBody) { + tableBody.addEventListener("mouseover", (event) => + handleRowHover(event, true), + ); + tableBody.addEventListener("mouseout", (event) => + handleRowHover(event, false), + ); + } + } + + updateButtonStates(); + } + + // Add error message handler + function handleErrorMessageChange() { + const errorMsg = model.get(ModelProperty.ERROR_MESSAGE); + if (errorMsg) { + errorContainer.textContent = errorMsg; + errorContainer.style.display = "block"; + } else { + errorContainer.style.display = "none"; + } + } + + // Add event listeners + prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1)); + nextPage.addEventListener(Event.CLICK, () => handlePageChange(1)); + pageSizeInput.addEventListener(Event.CHANGE, (e) => { + const newSize = Number(e.target.value); + if (newSize) { + handlePageSizeChange(newSize); + } + }); + model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange); + model.on(`change:${ModelProperty.ROW_COUNT}`, updateButtonStates); + model.on(`change:${ModelProperty.ERROR_MESSAGE}`, handleErrorMessageChange); + model.on(`change:_initial_load_complete`, (val) => { + if (val) { + updateButtonStates(); + } + }); + model.on(`change:${ModelProperty.PAGE}`, updateButtonStates); + + // Assemble the DOM + paginationContainer.appendChild(prevPage); + paginationContainer.appendChild(pageIndicator); + paginationContainer.appendChild(nextPage); + + pageSizeContainer.appendChild(pageSizeLabel); + pageSizeContainer.appendChild(pageSizeInput); + + footer.appendChild(rowCountLabel); + footer.appendChild(paginationContainer); + footer.appendChild(pageSizeContainer); + + el.appendChild(errorContainer); + el.appendChild(tableContainer); + el.appendChild(footer); + + // Initial render + handleTableHTMLChange(); + handleErrorMessageChange(); +} + +export default { render }; diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index e4db904210..29e1be1ace 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -19,7 +19,8 @@ import decimal import textwrap import typing -from typing import Any, Dict, List, Literal, Union +from typing import Any, Dict, List, Literal, Sequence, Union +import warnings import bigframes_vendored.constants as constants import db_dtypes # type: ignore @@ -28,7 +29,10 @@ import numpy as np import pandas as pd import pyarrow as pa -import shapely # type: ignore +import shapely.geometry # type: ignore + +import bigframes.core.backports +import bigframes.exceptions # Type hints for Pandas dtypes supported by BigQuery DataFrame Dtype = Union[ @@ -62,7 +66,10 @@ # No arrow equivalent GEO_DTYPE = gpd.array.GeometryDtype() # JSON -JSON_DTYPE = db_dtypes.JSONDtype() +# TODO(https://github.com/pandas-dev/pandas/issues/60958): switch to +# pyarrow.json_(pyarrow.string()) when pandas 3+ and pyarrow 18+ is installed. +JSON_ARROW_TYPE = db_dtypes.JSONArrowType() +JSON_DTYPE = pd.ArrowDtype(JSON_ARROW_TYPE) OBJ_REF_DTYPE = pd.ArrowDtype( pa.struct( ( @@ -80,7 +87,7 @@ ), pa.field( "details", - db_dtypes.JSONArrowType(), + JSON_ARROW_TYPE, ), ) ) @@ -245,6 +252,7 @@ class SimpleDtypeInfo: "decimal128(38, 9)[pyarrow]", "decimal256(76, 38)[pyarrow]", "binary[pyarrow]", + "duration[us][pyarrow]", ] DTYPE_STRINGS = typing.get_args(DtypeString) @@ -286,6 +294,10 @@ def is_time_like(type_: ExpressionType) -> bool: return type_ in (DATETIME_DTYPE, TIMESTAMP_DTYPE, TIME_DTYPE) +def is_time_or_date_like(type_: ExpressionType) -> bool: + return type_ in (DATE_DTYPE, DATETIME_DTYPE, TIME_DTYPE, TIMESTAMP_DTYPE) + + def is_geo_like(type_: ExpressionType) -> bool: return type_ in (GEO_DTYPE,) @@ -301,7 +313,6 @@ def is_object_like(type_: Union[ExpressionType, str]) -> bool: return type_ in ("object", "O") or ( getattr(type_, "kind", None) == "O" and getattr(type_, "storage", None) != "pyarrow" - and getattr(type_, "name", None) != "dbjson" ) @@ -329,6 +340,12 @@ def is_struct_like(type_: ExpressionType) -> bool: ) +def is_json_arrow_type(type_: pa.DataType) -> bool: + return isinstance(type_, db_dtypes.JSONArrowType) or ( + hasattr(pa, "JsonType") and isinstance(type_, pa.JsonType) + ) + + def is_json_like(type_: ExpressionType) -> bool: return type_ == JSON_DTYPE or type_ == STRING_DTYPE # Including JSON string @@ -339,8 +356,9 @@ def is_json_encoding_type(type_: ExpressionType) -> bool: return type_ != GEO_DTYPE -def is_numeric(type_: ExpressionType) -> bool: - return type_ in NUMERIC_BIGFRAMES_TYPES_PERMISSIVE +def is_numeric(type_: ExpressionType, include_bool: bool = True) -> bool: + is_numeric = type_ in NUMERIC_BIGFRAMES_TYPES_PERMISSIVE + return is_numeric if include_bool else is_numeric and type_ != BOOL_DTYPE def is_iterable(type_: ExpressionType) -> bool: @@ -351,6 +369,41 @@ def is_comparable(type_: ExpressionType) -> bool: return (type_ is not None) and is_orderable(type_) +def can_compare(type1: ExpressionType, type2: ExpressionType) -> bool: + coerced_type = coerce_to_common(type1, type2) + return is_comparable(coerced_type) + + +def get_struct_fields(type_: ExpressionType) -> dict[str, Dtype]: + assert isinstance(type_, pd.ArrowDtype) + assert isinstance(type_.pyarrow_dtype, pa.StructType) + struct_type = type_.pyarrow_dtype + result: dict[str, Dtype] = {} + for field in bigframes.core.backports.pyarrow_struct_type_fields(struct_type): + result[field.name] = arrow_dtype_to_bigframes_dtype(field.type) + return result + + +def get_array_inner_type(type_: ExpressionType) -> Dtype: + assert isinstance(type_, pd.ArrowDtype) + assert isinstance(type_.pyarrow_dtype, pa.ListType) + list_type = type_.pyarrow_dtype + return arrow_dtype_to_bigframes_dtype(list_type.value_type) + + +def list_type(values_type: Dtype) -> Dtype: + """Create a list dtype with given value type.""" + return pd.ArrowDtype(pa.list_(bigframes_dtype_to_arrow_dtype(values_type))) + + +def struct_type(fields: Sequence[tuple[str, Dtype]]) -> Dtype: + """Create a struct dtype with give fields names and types.""" + pa_fields = [ + pa.field(str, bigframes_dtype_to_arrow_dtype(dtype)) for str, dtype in fields + ] + return pd.ArrowDtype(pa.struct(pa_fields)) + + _ORDERABLE_SIMPLE_TYPES = set( mapping.dtype for mapping in SIMPLE_TYPES if mapping.orderable ) @@ -389,6 +442,8 @@ def is_bool_coercable(type_: ExpressionType) -> bool: # special case - both "Int64" and "int64[pyarrow]" are accepted BIGFRAMES_STRING_TO_BIGFRAMES["int64[pyarrow]"] = INT_DTYPE +BIGFRAMES_STRING_TO_BIGFRAMES["duration[us][pyarrow]"] = TIMEDELTA_DTYPE + # For the purposes of dataframe.memory_usage DTYPE_BYTE_SIZES = { type_info.dtype: type_info.logical_bytes for type_info in SIMPLE_TYPES @@ -412,8 +467,35 @@ def dtype_for_etype(etype: ExpressionType) -> Dtype: if mapping.arrow_dtype is not None } +# Include types that aren't 1:1 to BigQuery but allowed to be loaded in to BigQuery: +_ARROW_TO_BIGFRAMES_LOSSLESS = { + pa.int8(): INT_DTYPE, + pa.int16(): INT_DTYPE, + pa.int32(): INT_DTYPE, + pa.uint8(): INT_DTYPE, + pa.uint16(): INT_DTYPE, + pa.uint32(): INT_DTYPE, + # uint64 is omitted because uint64 -> BigQuery INT64 is a lossy conversion. + pa.float16(): FLOAT_DTYPE, + pa.float32(): FLOAT_DTYPE, + # TODO(tswast): Can we support datetime/timestamp/time with units larger + # than microseconds? +} + + +def arrow_dtype_to_bigframes_dtype( + arrow_dtype: pa.DataType, allow_lossless_cast: bool = False +) -> Dtype: + """ + Convert an arrow type into the pandas-y type used to represent it in BigFrames. + + Args: + arrow_dtype: Arrow data type. + allow_lossless_cast: Allow lossless conversions, such as int32 to int64. + """ + if allow_lossless_cast and arrow_dtype in _ARROW_TO_BIGFRAMES_LOSSLESS: + return _ARROW_TO_BIGFRAMES_LOSSLESS[arrow_dtype] -def arrow_dtype_to_bigframes_dtype(arrow_dtype: pa.DataType) -> Dtype: if arrow_dtype in _ARROW_TO_BIGFRAMES: return _ARROW_TO_BIGFRAMES[arrow_dtype] @@ -434,6 +516,10 @@ def arrow_dtype_to_bigframes_dtype(arrow_dtype: pa.DataType) -> Dtype: if arrow_dtype == pa.null(): return DEFAULT_DTYPE + # Allow both db_dtypes.JSONArrowType() and pa.json_(pa.string()) + if is_json_arrow_type(arrow_dtype): + return JSON_DTYPE + # No other types matched. raise TypeError( f"Unexpected Arrow data type {arrow_dtype}. {constants.FEEDBACK_LINK}" @@ -455,6 +541,8 @@ def bigframes_dtype_to_arrow_dtype( if bigframes_dtype in _BIGFRAMES_TO_ARROW: return _BIGFRAMES_TO_ARROW[bigframes_dtype] if isinstance(bigframes_dtype, pd.ArrowDtype): + if pa.types.is_duration(bigframes_dtype.pyarrow_dtype): + return bigframes_dtype.pyarrow_dtype if pa.types.is_list(bigframes_dtype.pyarrow_dtype): return bigframes_dtype.pyarrow_dtype if pa.types.is_struct(bigframes_dtype.pyarrow_dtype): @@ -465,31 +553,22 @@ def bigframes_dtype_to_arrow_dtype( ) -def bigframes_dtype_to_literal( - bigframes_dtype: Dtype, -) -> Any: - """Create a representative literal value for a bigframes dtype. - - The inverse of infer_literal_type(). - """ - if isinstance(bigframes_dtype, pd.ArrowDtype): - arrow_type = bigframes_dtype.pyarrow_dtype - return arrow_type_to_literal(arrow_type) - - if isinstance(bigframes_dtype, pd.Float64Dtype): - return 1.0 - if isinstance(bigframes_dtype, pd.Int64Dtype): - return 1 - if isinstance(bigframes_dtype, pd.BooleanDtype): - return True - if isinstance(bigframes_dtype, pd.StringDtype): - return "string" - if isinstance(bigframes_dtype, gpd.array.GeometryDtype): - return shapely.Point((0, 0)) - - raise TypeError( - f"No literal conversion for {bigframes_dtype}. {constants.FEEDBACK_LINK}" - ) +def to_storage_type( + arrow_type: pa.DataType, +): + """Some pyarrow versions don't support extension types fully, such as for empty table generation.""" + if isinstance(arrow_type, pa.ExtensionType): + return arrow_type.storage_type + if pa.types.is_list(arrow_type): + assert isinstance(arrow_type, pa.ListType) + return pa.list_(to_storage_type(arrow_type.value_type)) + if pa.types.is_struct(arrow_type): + assert isinstance(arrow_type, pa.StructType) + return pa.struct( + field.with_type(to_storage_type(field.type)) + for field in bigframes.core.backports.pyarrow_struct_type_fields(arrow_type) + ) + return arrow_type def arrow_type_to_literal( @@ -500,7 +579,8 @@ def arrow_type_to_literal( return [arrow_type_to_literal(arrow_type.value_type)] if pa.types.is_struct(arrow_type): return { - field.name: arrow_type_to_literal(field.type) for field in arrow_type.fields + field.name: arrow_type_to_literal(field.type) + for field in bigframes.core.backports.pyarrow_struct_type_fields(arrow_type) } if pa.types.is_string(arrow_type): return "string" @@ -565,30 +645,32 @@ def _is_bigframes_dtype(dtype) -> bool: return False -def _infer_dtype_from_python_type(type: type) -> Dtype: - if type in (datetime.timedelta, pd.Timedelta, np.timedelta64): +def _infer_dtype_from_python_type(type_: type) -> Dtype: + if type_ in (datetime.timedelta, pd.Timedelta, np.timedelta64): # Must check timedelta type first. Otherwise other branchs will be evaluated to true # E.g. np.timedelta64 is a sublcass as np.integer return TIMEDELTA_DTYPE - if issubclass(type, (bool, np.bool_)): + if issubclass(type_, (bool, np.bool_)): return BOOL_DTYPE - if issubclass(type, (int, np.integer)): + if issubclass(type_, (int, np.integer)): return INT_DTYPE - if issubclass(type, (float, np.floating)): + if issubclass(type_, (float, np.floating)): return FLOAT_DTYPE - if issubclass(type, decimal.Decimal): + if issubclass(type_, decimal.Decimal): return NUMERIC_DTYPE - if issubclass(type, (str, np.str_)): + if issubclass(type_, (str, np.str_)): return STRING_DTYPE - if issubclass(type, (bytes, np.bytes_)): + if issubclass(type_, (bytes, np.bytes_)): return BYTES_DTYPE - if issubclass(type, datetime.date): + if issubclass(type_, datetime.date): return DATE_DTYPE - if issubclass(type, datetime.time): + if issubclass(type_, datetime.time): return TIME_DTYPE + if issubclass(type_, shapely.geometry.base.BaseGeometry): + return GEO_DTYPE else: raise TypeError( - f"No matching datatype for python type: {type}. {constants.FEEDBACK_LINK}" + f"No matching datatype for python type: {type_}. {constants.FEEDBACK_LINK}" ) @@ -597,6 +679,9 @@ def _dtype_from_string(dtype_string: str) -> typing.Optional[Dtype]: return BIGFRAMES_STRING_TO_BIGFRAMES[ typing.cast(DtypeString, str(dtype_string)) ] + if isinstance(dtype_string, str) and dtype_string.lower() == "json": + return JSON_DTYPE + raise TypeError( textwrap.dedent( f""" @@ -608,9 +693,9 @@ def _dtype_from_string(dtype_string: str) -> typing.Optional[Dtype]: The following pandas.ExtensionDtype are supported: pandas.BooleanDtype(), pandas.Float64Dtype(), pandas.Int64Dtype(), pandas.StringDtype(storage="pyarrow"), - pd.ArrowDtype(pa.date32()), pd.ArrowDtype(pa.time64("us")), - pd.ArrowDtype(pa.timestamp("us")), - pd.ArrowDtype(pa.timestamp("us", tz="UTC")). + pandas.ArrowDtype(pa.date32()), pandas.ArrowDtype(pa.time64("us")), + pandas.ArrowDtype(pa.timestamp("us")), + pandas.ArrowDtype(pa.timestamp("us", tz="UTC")). {constants.FEEDBACK_LINK} """ ) @@ -619,11 +704,12 @@ def _dtype_from_string(dtype_string: str) -> typing.Optional[Dtype]: def infer_literal_type(literal) -> typing.Optional[Dtype]: # Maybe also normalize literal to canonical python representation to remove this burden from compilers? + if isinstance(literal, pa.Scalar): + return arrow_dtype_to_bigframes_dtype(literal.type) if pd.api.types.is_list_like(literal): element_types = [infer_literal_type(i) for i in literal] common_type = lcd_type(*element_types) - as_arrow = bigframes_dtype_to_arrow_dtype(common_type) - return pd.ArrowDtype(as_arrow) + return list_type(common_type) if pd.api.types.is_dict_like(literal): fields = [] for key in literal.keys(): @@ -678,6 +764,12 @@ def convert_schema_field( pa_struct = pa.struct(fields) pa_type = pa.list_(pa_struct) if is_repeated else pa_struct return field.name, pd.ArrowDtype(pa_type) + elif ( + field.field_type == "INTEGER" + and field.description is not None + and field.description.endswith(TIMEDELTA_DESCRIPTION_TAG) + ): + return field.name, TIMEDELTA_DTYPE elif field.field_type in _TK_TO_BIGFRAMES: if is_repeated: pa_type = pa.list_( @@ -690,9 +782,10 @@ def convert_schema_field( def convert_to_schema_field( - name: str, - bigframes_dtype: Dtype, + name: str, bigframes_dtype: Dtype, overrides: dict[Dtype, str] = {} ) -> google.cloud.bigquery.SchemaField: + if bigframes_dtype in overrides: + return google.cloud.bigquery.SchemaField(name, overrides[bigframes_dtype]) if bigframes_dtype in _BIGFRAMES_TO_TK: return google.cloud.bigquery.SchemaField( name, _BIGFRAMES_TO_TK[bigframes_dtype] @@ -702,7 +795,7 @@ def convert_to_schema_field( inner_type = arrow_dtype_to_bigframes_dtype( bigframes_dtype.pyarrow_dtype.value_type ) - inner_field = convert_to_schema_field(name, inner_type) + inner_field = convert_to_schema_field(name, inner_type, overrides) return google.cloud.bigquery.SchemaField( name, inner_field.field_type, mode="REPEATED", fields=inner_field.fields ) @@ -712,14 +805,18 @@ def convert_to_schema_field( for i in range(struct_type.num_fields): field = struct_type.field(i) inner_bf_type = arrow_dtype_to_bigframes_dtype(field.type) - inner_fields.append(convert_to_schema_field(field.name, inner_bf_type)) + inner_fields.append( + convert_to_schema_field(field.name, inner_bf_type, overrides) + ) return google.cloud.bigquery.SchemaField( name, "RECORD", fields=inner_fields ) if bigframes_dtype.pyarrow_dtype == pa.duration("us"): # Timedeltas are represented as integers in microseconds. - return google.cloud.bigquery.SchemaField(name, "INTEGER") + return google.cloud.bigquery.SchemaField( + name, "INTEGER", description=TIMEDELTA_DESCRIPTION_TAG + ) raise TypeError( f"No arrow conversion for {bigframes_dtype}. {constants.FEEDBACK_LINK}" ) @@ -734,7 +831,7 @@ def bf_type_from_type_kind( def is_dtype(scalar: typing.Any, dtype: Dtype) -> bool: """Captures whether a scalar can be losslessly represented by a dtype.""" - if scalar is None: + if pd.isna(scalar): return True if pd.api.types.is_bool_dtype(dtype): return pd.api.types.is_bool(scalar) @@ -850,28 +947,41 @@ def lcd_type_or_throw(dtype1: Dtype, dtype2: Dtype) -> Dtype: return result -### Remote functions use only -# TODO: Refactor into remote function module - -# Input and output types supported by BigQuery DataFrames remote functions. -# TODO(shobs): Extend the support to all types supported by BQ remote functions -# https://cloud.google.com/bigquery/docs/remote-functions#limitations -RF_SUPPORTED_IO_PYTHON_TYPES = {bool, bytes, float, int, str} - -# Support array output types in BigQuery DataFrames remote functions even though -# it is not currently (2024-10-06) supported in BigQuery remote functions. -# https://cloud.google.com/bigquery/docs/remote-functions#limitations -# TODO(b/284515241): remove this special handling when BigQuery remote functions -# support array. -RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES = {bool, float, int, str} - -RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS = { - "BOOLEAN", - "BOOL", - "BYTES", - "FLOAT", - "FLOAT64", - "INT64", - "INTEGER", - "STRING", -} +TIMEDELTA_DESCRIPTION_TAG = "#microseconds" + + +def contains_db_dtypes_json_arrow_type(type_): + if isinstance(type_, db_dtypes.JSONArrowType): + return True + + if isinstance(type_, pa.ListType): + return contains_db_dtypes_json_arrow_type(type_.value_type) + + if isinstance(type_, pa.StructType): + return any( + contains_db_dtypes_json_arrow_type(field.type) + for field in bigframes.core.backports.pyarrow_struct_type_fields(type_) + ) + return False + + +def contains_db_dtypes_json_dtype(dtype): + if not isinstance(dtype, pd.ArrowDtype): + return False + + return contains_db_dtypes_json_arrow_type(dtype.pyarrow_dtype) + + +def warn_on_db_dtypes_json_dtype(dtypes): + """Warn that the JSON dtype is changing. + + Note: only call this function if the user is explicitly checking the + dtypes. + """ + if any(contains_db_dtypes_json_dtype(dtype) for dtype in dtypes): + msg = bigframes.exceptions.format_message( + "JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_()) " + "instead of using `db_dtypes` in the future when available in pandas " + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow." + ) + warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning) diff --git a/bigframes/enums.py b/bigframes/enums.py index fd7b5545bb..aa5e1c830f 100644 --- a/bigframes/enums.py +++ b/bigframes/enums.py @@ -21,7 +21,7 @@ class OrderingMode(enum.Enum): - """[Preview] Values used to determine the ordering mode. + """Values used to determine the ordering mode. Default is 'strict'. """ @@ -37,5 +37,6 @@ class DefaultIndexKind(enum.Enum): #: ``n - 3``, ``n - 2``, ``n - 1``, where ``n`` is the number of items in #: the index. SEQUENTIAL_INT64 = enum.auto() + # A completely null index incapable of indexing or alignment. NULL = enum.auto() diff --git a/bigframes/exceptions.py b/bigframes/exceptions.py index 3cb5f3665d..9facb40e8e 100644 --- a/bigframes/exceptions.py +++ b/bigframes/exceptions.py @@ -14,6 +14,8 @@ """Public exceptions and warnings used across BigQuery DataFrames.""" +import textwrap + # NOTE: This module should not depend on any others in the package. @@ -28,7 +30,7 @@ class UnknownLocationWarning(Warning): class CleanupFailedWarning(Warning): - """Bigframes failed to clean up a table resource.""" + """Bigframes failed to clean up a table or function resource.""" class DefaultIndexWarning(Warning): @@ -40,7 +42,10 @@ class PreviewWarning(Warning): class NullIndexPreviewWarning(PreviewWarning): - """Null index feature is in preview.""" + """Unused. Kept for backwards compatibility. + + Was used when null index feature was in preview. + """ class NullIndexError(ValueError): @@ -48,7 +53,10 @@ class NullIndexError(ValueError): class OrderingModePartialPreviewWarning(PreviewWarning): - """Ordering mode 'partial' is in preview.""" + """Unused. Kept for backwards compatibility. + + Was used when ordering mode 'partial' was in preview. + """ class OrderRequiredError(ValueError): @@ -63,12 +71,24 @@ class OperationAbortedError(RuntimeError): """Operation is aborted.""" +class MaximumResultRowsExceeded(RuntimeError): + """Maximum number of rows in the result was exceeded.""" + + class TimeTravelDisabledWarning(Warning): """A query was reattempted without time travel.""" +class TimeTravelCacheWarning(Warning): + """Reads from the same table twice in the same session pull time travel from cache.""" + + class AmbiguousWindowWarning(Warning): - """A query may produce nondeterministic results as the window may be ambiguously ordered.""" + """A query may produce nondeterministic results as the window may be ambiguously ordered. + + Deprecated. Kept for backwards compatibility for code that filters warnings + from this category. + """ class UnknownDataTypeWarning(Warning): @@ -81,3 +101,46 @@ class ApiDeprecationWarning(FutureWarning): class BadIndexerKeyWarning(Warning): """The indexer key is not used correctly.""" + + +class ObsoleteVersionWarning(Warning): + """The BigFrames version is too old.""" + + +class FunctionAxisOnePreviewWarning(PreviewWarning): + """Remote Function and Managed UDF with axis=1 preview.""" + + +class JSONDtypeWarning(PreviewWarning): + """JSON dtype will be pd.ArrowDtype(pa.json_()) in the future.""" + + +class FunctionConflictTypeHintWarning(UserWarning): + """Conflicting type hints in a BigFrames function.""" + + +class FunctionPackageVersionWarning(PreviewWarning): + """ + Warns that package versions in remote function or managed function may not + match local or specified versions, which might cause unexpected behavior. + """ + + +def format_message(message: str, fill: bool = True): + """[Private] Formats a warning message. + + :meta private: + + Args: + message: The warning message string. + fill: Whether to wrap the message text using `textwrap.fill`. + Defaults to True. Set to False to prevent wrapping, + especially if the message already contains newlines. + + Returns: + The formatted message string. If `fill` is True, the message will be wrapped + to fit the terminal width. + """ + if fill: + message = textwrap.fill(message) + return message diff --git a/bigframes/formatting_helpers.py b/bigframes/formatting_helpers.py index 63249b1a8a..3c37a3470d 100644 --- a/bigframes/formatting_helpers.py +++ b/bigframes/formatting_helpers.py @@ -13,11 +13,13 @@ # limitations under the License. """Shared helper functions for formatting jobs related info.""" -# TODO(orrbradford): cleanup up typings and documenttion in this file + +from __future__ import annotations import datetime +import html import random -from typing import Any, Optional, Union +from typing import Any, Optional, Type, TYPE_CHECKING, Union import bigframes_vendored.constants as constants import google.api_core.exceptions as api_core_exceptions @@ -25,7 +27,9 @@ import humanize import IPython import IPython.display as display -import ipywidgets as widgets + +if TYPE_CHECKING: + import bigframes.core.events GenericJob = Union[ bigquery.LoadJob, bigquery.ExtractJob, bigquery.QueryJob, bigquery.CopyJob @@ -48,132 +52,178 @@ def add_feedback_link( exception.message = exception.message + f" {constants.FEEDBACK_LINK}" -def repr_query_job_html(query_job: Optional[bigquery.QueryJob]): - """Return query job in html format. +def create_exception_with_feedback_link( + exception: Type[Exception], + arg: str = "", +): + if arg: + return exception(arg + f" {constants.FEEDBACK_LINK}") + + return exception(constants.FEEDBACK_LINK) + + +def repr_query_job(query_job: Optional[bigquery.QueryJob]): + """Return query job as a formatted string. Args: - query_job (bigquery.QueryJob, Optional): + query_job: The job representing the execution of the query on the server. Returns: - Pywidget html table. + Formatted string. """ if query_job is None: - return display.HTML("No job information available") + return "No job information available" if query_job.dry_run: - return display.HTML( - f"Computation deferred. Computation will process {get_formatted_bytes(query_job.total_bytes_processed)}" - ) - table_html = "" - table_html += "" + return f"Computation deferred. Computation will process {get_formatted_bytes(query_job.total_bytes_processed)}" + res = "Query Job Info" for key, value in query_job_prop_pairs.items(): job_val = getattr(query_job, value) if job_val is not None: + res += "\n" if key == "Job Id": # add link to job - table_html += f"""""" + res += f"""Job url: {get_job_url( + project_id=query_job.project, + location=query_job.location, + job_id=query_job.job_id, + )}""" elif key == "Slot Time": - table_html += ( - f"""""" - ) + res += f"""{key}: {get_formatted_time(job_val)}""" elif key == "Bytes Processed": - table_html += f"""""" + res += f"""{key}: {get_formatted_bytes(job_val)}""" else: - table_html += f"""""" - table_html += "
{key}{job_val}
{key}{get_formatted_time(job_val)}
{key}{get_formatted_bytes(job_val)}
{key}{job_val}
" - return widgets.HTML(table_html) + res += f"""{key}: {job_val}""" + return res -def repr_query_job(query_job: Optional[bigquery.QueryJob]): - """Return query job as a formatted string. +def repr_query_job_html(query_job: Optional[bigquery.QueryJob]): + """Return query job as a formatted html string. Args: query_job: The job representing the execution of the query on the server. Returns: - Pywidget html table. + Html string. """ if query_job is None: return "No job information available" if query_job.dry_run: return f"Computation deferred. Computation will process {get_formatted_bytes(query_job.total_bytes_processed)}" - res = "Query Job Info" + + # We can reuse the plaintext repr for now or make a nicer table. + # For deferred mode consistency, let's just wrap the text in a pre block or similar, + # but the request implies we want a distinct HTML representation if possible. + # However, existing repr_query_job returns a simple string. + # Let's format it as a simple table or list. + + res = "

Query Job Info

    " for key, value in query_job_prop_pairs.items(): job_val = getattr(query_job, value) if job_val is not None: - res += "\n" if key == "Job Id": # add link to job - res += f"""Job url: {get_job_url(query_job)}""" + url = get_job_url( + project_id=query_job.project, + location=query_job.location, + job_id=query_job.job_id, + ) + res += f'
  • Job: {query_job.job_id}
  • ' elif key == "Slot Time": - res += f"""{key}: {get_formatted_time(job_val)}""" + res += f"
  • {key}: {get_formatted_time(job_val)}
  • " elif key == "Bytes Processed": - res += f"""{key}: {get_formatted_bytes(job_val)}""" + res += f"
  • {key}: {get_formatted_bytes(job_val)}
  • " else: - res += f"""{key}: {job_val}""" + res += f"
  • {key}: {job_val}
  • " + res += "
" return res -def wait_for_query_job( - query_job: bigquery.QueryJob, - max_results: Optional[int] = None, - page_size: Optional[int] = None, - progress_bar: Optional[str] = None, -) -> bigquery.table.RowIterator: - """Return query results. Displays a progress bar while the query is running - Args: - query_job (bigquery.QueryJob, Optional): - The job representing the execution of the query on the server. - max_results (int, Optional): - The maximum number of rows the row iterator should return. - page_size (int, Optional): - The number of results to return on each results page. - progress_bar (str, Optional): - Which progress bar to show. - Returns: - A row iterator over the query results. - """ +current_display: Optional[display.HTML] = None +current_display_id: Optional[str] = None +previous_display_html: str = "" + + +def progress_callback( + event: bigframes.core.events.Event, +): + """Displays a progress bar while the query is running""" + global current_display, current_display_id, previous_display_html + + try: + import bigframes._config + import bigframes.core.events + except ImportError: + # Since this gets called from __del__, skip if the import fails to avoid + # ImportError: sys.meta_path is None, Python is likely shutting down. + # This will allow cleanup to continue. + return + + progress_bar = bigframes._config.options.display.progress_bar + if progress_bar == "auto": progress_bar = "notebook" if in_ipython() else "terminal" - try: - if progress_bar == "notebook": - display_id = str(random.random()) - loading_bar = display.HTML(get_query_job_loading_html(query_job)) - display.display(loading_bar, display_id=display_id) - query_result = query_job.result( - max_results=max_results, page_size=page_size + if progress_bar == "notebook": + if ( + isinstance(event, bigframes.core.events.ExecutionStarted) + or current_display is None + or current_display_id is None + ): + previous_display_html = "" + current_display_id = str(random.random()) + current_display = display.HTML("Starting.") + display.display( + current_display, + display_id=current_display_id, ) - query_job.reload() + + if isinstance(event, bigframes.core.events.BigQuerySentEvent): + previous_display_html = render_bqquery_sent_event_html(event) display.update_display( - display.HTML(get_query_job_loading_html(query_job)), - display_id=display_id, + display.HTML(previous_display_html), + display_id=current_display_id, ) - elif progress_bar == "terminal": - initial_loading_bar = get_query_job_loading_string(query_job) - print(initial_loading_bar) - query_result = query_job.result( - max_results=max_results, page_size=page_size + elif isinstance(event, bigframes.core.events.BigQueryRetryEvent): + previous_display_html = render_bqquery_retry_event_html(event) + display.update_display( + display.HTML(previous_display_html), + display_id=current_display_id, ) - query_job.reload() - if initial_loading_bar != get_query_job_loading_string(query_job): - print(get_query_job_loading_string(query_job)) - else: - # No progress bar. - query_result = query_job.result( - max_results=max_results, page_size=page_size + elif isinstance(event, bigframes.core.events.BigQueryReceivedEvent): + previous_display_html = render_bqquery_received_event_html(event) + display.update_display( + display.HTML(previous_display_html), + display_id=current_display_id, ) - query_job.reload() - return query_result - except api_core_exceptions.RetryError as exc: - add_feedback_link(exc) - raise - except api_core_exceptions.GoogleAPICallError as exc: - add_feedback_link(exc) - raise - except KeyboardInterrupt: - query_job.cancel() - print( - f"Requested cancellation for {query_job.job_type.capitalize()}" - f" job {query_job.job_id} in location {query_job.location}..." - ) - # begin the cancel request before immediately rethrowing - raise + elif isinstance(event, bigframes.core.events.BigQueryFinishedEvent): + previous_display_html = render_bqquery_finished_event_html(event) + display.update_display( + display.HTML(previous_display_html), + display_id=current_display_id, + ) + elif isinstance(event, bigframes.core.events.ExecutionFinished): + display.update_display( + display.HTML(f"✅ Completed. {previous_display_html}"), + display_id=current_display_id, + ) + elif isinstance(event, bigframes.core.events.SessionClosed): + display.update_display( + display.HTML(f"Session {event.session_id} closed."), + display_id=current_display_id, + ) + elif progress_bar == "terminal": + if isinstance(event, bigframes.core.events.ExecutionStarted): + print("Starting execution.") + elif isinstance(event, bigframes.core.events.BigQuerySentEvent): + message = render_bqquery_sent_event_plaintext(event) + print(message) + elif isinstance(event, bigframes.core.events.BigQueryRetryEvent): + message = render_bqquery_retry_event_plaintext(event) + print(message) + elif isinstance(event, bigframes.core.events.BigQueryReceivedEvent): + message = render_bqquery_received_event_plaintext(event) + print(message) + elif isinstance(event, bigframes.core.events.BigQueryFinishedEvent): + message = render_bqquery_finished_event_plaintext(event) + print(message) + elif isinstance(event, bigframes.core.events.ExecutionFinished): + print("Execution done.") def wait_for_job(job: GenericJob, progress_bar: Optional[str] = None): @@ -224,24 +274,74 @@ def wait_for_job(job: GenericJob, progress_bar: Optional[str] = None): raise -def get_job_url(query_job: GenericJob): +def render_query_references( + *, + project_id: Optional[str], + location: Optional[str], + job_id: Optional[str], + request_id: Optional[str], +) -> str: + query_id = "" + if request_id and not job_id: + query_id = f" with request ID {project_id}:{location}.{request_id}" + return query_id + + +def render_job_link_html( + *, + project_id: Optional[str], + location: Optional[str], + job_id: Optional[str], +) -> str: + job_url = get_job_url( + project_id=project_id, + location=location, + job_id=job_id, + ) + if job_url: + job_link = f' [Job {project_id}:{location}.{job_id} details]' + else: + job_link = "" + return job_link + + +def render_job_link_plaintext( + *, + project_id: Optional[str], + location: Optional[str], + job_id: Optional[str], +) -> str: + job_url = get_job_url( + project_id=project_id, + location=location, + job_id=job_id, + ) + if job_url: + job_link = f" Job {project_id}:{location}.{job_id} details: {job_url}" + else: + job_link = "" + return job_link + + +def get_job_url( + *, + project_id: Optional[str], + location: Optional[str], + job_id: Optional[str], +): """Return url to the query job in cloud console. - Args: - query_job (GenericJob): - The job representing the execution of the query on the server. + Returns: String url. """ - if ( - query_job.project is None - or query_job.location is None - or query_job.job_id is None - ): + if project_id is None or location is None or job_id is None: return None - return f"""https://console.cloud.google.com/bigquery?project={query_job.project}&j=bq:{query_job.location}:{query_job.job_id}&page=queryresults""" + return f"""https://console.cloud. google.com/bigquery?project={project_id}&j=bq:{location}:{job_id}&page=queryresults""" -def get_query_job_loading_html(query_job: bigquery.QueryJob): +def render_bqquery_sent_event_html( + event: bigframes.core.events.BigQuerySentEvent, +) -> str: """Return progress bar html string Args: query_job (bigquery.QueryJob): @@ -249,18 +349,195 @@ def get_query_job_loading_html(query_job: bigquery.QueryJob): Returns: Html string. """ - return f"""Query job {query_job.job_id} is {query_job.state}. {get_bytes_processed_string(query_job.total_bytes_processed)}Open Job""" + job_link = render_job_link_html( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + query_text_details = f"
SQL
{html.escape(event.query)}
" + + return f""" + Query started{query_id}.{job_link}{query_text_details} + """ -def get_query_job_loading_string(query_job: bigquery.QueryJob): - """Return progress bar string + +def render_bqquery_sent_event_plaintext( + event: bigframes.core.events.BigQuerySentEvent, +) -> str: + """Return progress bar html string Args: query_job (bigquery.QueryJob): The job representing the execution of the query on the server. Returns: - String + Html string. + """ + + job_link = render_job_link_plaintext( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + + return f"Query started{query_id}.{job_link}" + + +def render_bqquery_retry_event_html( + event: bigframes.core.events.BigQueryRetryEvent, +) -> str: + """Return progress bar html string for retry event.""" + + job_link = render_job_link_html( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + query_text_details = f"
SQL
{html.escape(event.query)}
" + + return f""" + Retrying query{query_id}.{job_link}{query_text_details} """ - return f"""Query job {query_job.job_id} is {query_job.state}.{get_bytes_processed_string(query_job.total_bytes_processed)} \n{get_job_url(query_job)}""" + + +def render_bqquery_retry_event_plaintext( + event: bigframes.core.events.BigQueryRetryEvent, +) -> str: + """Return progress bar plaintext string for retry event.""" + + job_link = render_job_link_plaintext( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + return f"Retrying query{query_id}.{job_link}" + + +def render_bqquery_received_event_html( + event: bigframes.core.events.BigQueryReceivedEvent, +) -> str: + """Return progress bar html string for received event.""" + + job_link = render_job_link_html( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=None, + ) + + query_plan_details = "" + if event.query_plan: + plan_str = "\n".join([str(entry) for entry in event.query_plan]) + query_plan_details = f"
Query Plan
{html.escape(plan_str)}
" + + return f""" + Query{query_id} is {event.state}.{job_link}{query_plan_details} + """ + + +def render_bqquery_received_event_plaintext( + event: bigframes.core.events.BigQueryReceivedEvent, +) -> str: + """Return progress bar plaintext string for received event.""" + + job_link = render_job_link_plaintext( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=None, + ) + return f"Query{query_id} is {event.state}.{job_link}" + + +def render_bqquery_finished_event_html( + event: bigframes.core.events.BigQueryFinishedEvent, +) -> str: + """Return progress bar html string for finished event.""" + + bytes_str = "" + if event.total_bytes_processed is not None: + bytes_str = f" {humanize.naturalsize(event.total_bytes_processed)}" + + slot_time_str = "" + if event.slot_millis is not None: + slot_time = datetime.timedelta(milliseconds=event.slot_millis) + slot_time_str = f" in {humanize.naturaldelta(slot_time)} of slot time" + + job_link = render_job_link_html( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=None, + ) + return f""" + Query processed{bytes_str}{slot_time_str}{query_id}.{job_link} + """ + + +def render_bqquery_finished_event_plaintext( + event: bigframes.core.events.BigQueryFinishedEvent, +) -> str: + """Return progress bar plaintext string for finished event.""" + + bytes_str = "" + if event.total_bytes_processed is not None: + bytes_str = f" {humanize.naturalsize(event.total_bytes_processed)} processed." + + slot_time_str = "" + if event.slot_millis is not None: + slot_time = datetime.timedelta(milliseconds=event.slot_millis) + slot_time_str = f" Slot time: {humanize.naturaldelta(slot_time)}." + + job_link = render_job_link_plaintext( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=None, + ) + return f"Query{query_id} finished.{bytes_str}{slot_time_str}{job_link}" def get_base_job_loading_html(job: GenericJob): @@ -271,7 +548,11 @@ def get_base_job_loading_html(job: GenericJob): Returns: Html string. """ - return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. Open Job""" + return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. Open Job""" def get_base_job_loading_string(job: GenericJob): @@ -282,7 +563,11 @@ def get_base_job_loading_string(job: GenericJob): Returns: String """ - return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. \n{get_job_url(job)}""" + return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. \n{get_job_url( + project_id=job.job_id, + location=job.location, + job_id=job.job_id, + )}""" def get_formatted_time(val): diff --git a/bigframes/functions/__init__.py b/bigframes/functions/__init__.py index 6d5e14bcf4..5f87956a61 100644 --- a/bigframes/functions/__init__.py +++ b/bigframes/functions/__init__.py @@ -11,3 +11,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from bigframes.functions.function import ( + BigqueryCallableRoutine, + BigqueryCallableRowRoutine, +) + +__all__ = [ + "BigqueryCallableRoutine", + "BigqueryCallableRowRoutine", +] diff --git a/bigframes/functions/_function_client.py b/bigframes/functions/_function_client.py index f5001ff909..8a88a14040 100644 --- a/bigframes/functions/_function_client.py +++ b/bigframes/functions/_function_client.py @@ -21,14 +21,16 @@ import random import shutil import string -import sys import tempfile +import textwrap import types -from typing import cast, Tuple, TYPE_CHECKING +from typing import Any, cast, Optional, Sequence, Tuple, TYPE_CHECKING +import warnings -from bigframes_vendored import constants import requests +import bigframes.exceptions as bfe +import bigframes.formatting_helpers as bf_formatting import bigframes.functions.function_template as bff_template if TYPE_CHECKING: @@ -38,8 +40,6 @@ import google.api_core.retry from google.cloud import bigquery, functions_v2 -import bigframes.session._io.bigquery - from . import _utils logger = logging.getLogger(__name__) @@ -53,9 +53,21 @@ } ) +# https://cloud.google.com/functions/docs/reference/rest/v2/projects.locations.functions#vpconnectoregresssettings +_VPC_EGRESS_SETTINGS_MAP = types.MappingProxyType( + { + "all": functions_v2.ServiceConfig.VpcConnectorEgressSettings.ALL_TRAFFIC, + "private-ranges-only": functions_v2.ServiceConfig.VpcConnectorEgressSettings.PRIVATE_RANGES_ONLY, + "unspecified": functions_v2.ServiceConfig.VpcConnectorEgressSettings.VPC_CONNECTOR_EGRESS_SETTINGS_UNSPECIFIED, + } +) + +# BQ managed functions (@udf) currently only support Python 3.11. +_MANAGED_FUNC_PYTHON_VERSION = "python-3.11" + class FunctionClient: - # Wait time (in seconds) for an IAM binding to take effect after creation + # Wait time (in seconds) for an IAM binding to take effect after creation. _iam_wait_seconds = 120 # TODO(b/392707725): Convert all necessary parameters for cloud function @@ -63,44 +75,37 @@ class FunctionClient: def __init__( self, gcp_project_id, - cloud_function_region, - cloud_functions_client, bq_location, bq_dataset, bq_client, bq_connection_id, bq_connection_manager, - cloud_function_service_account, - cloud_function_kms_key_name, - cloud_function_docker_repository, + cloud_function_region=None, + cloud_functions_client=None, + cloud_function_service_account=None, + cloud_function_kms_key_name=None, + cloud_function_docker_repository=None, + cloud_build_service_account=None, *, session: Session, ): self._gcp_project_id = gcp_project_id - self._cloud_function_region = cloud_function_region - self._cloud_functions_client = cloud_functions_client self._bq_location = bq_location self._bq_dataset = bq_dataset self._bq_client = bq_client self._bq_connection_id = bq_connection_id self._bq_connection_manager = bq_connection_manager + self._session = session + + # Optional attributes only for remote functions. + self._cloud_function_region = cloud_function_region + self._cloud_functions_client = cloud_functions_client self._cloud_function_service_account = cloud_function_service_account self._cloud_function_kms_key_name = cloud_function_kms_key_name self._cloud_function_docker_repository = cloud_function_docker_repository - self._session = session + self._cloud_build_service_account = cloud_build_service_account - def create_bq_remote_function( - self, - input_args, - input_types, - output_type, - endpoint, - bq_function_name, - max_batching_rows, - metadata, - ): - """Create a BigQuery remote function given the artifacts of a user defined - function and the http endpoint of a corresponding cloud function.""" + def _create_bq_connection(self) -> None: if self._bq_connection_manager: self._bq_connection_manager.create_bq_connection( self._gcp_project_id, @@ -109,6 +114,64 @@ def create_bq_remote_function( "run.invoker", ) + def _ensure_dataset_exists(self) -> None: + # Make sure the dataset exists, i.e. if it doesn't exist, go ahead and + # create it. + dataset = bigquery.Dataset( + bigquery.DatasetReference.from_string( + self._bq_dataset, default_project=self._gcp_project_id + ) + ) + dataset.location = self._bq_location + try: + # This check does not require bigquery.datasets.create IAM + # permission. So, if the data set already exists, then user can work + # without having that permission. + self._bq_client.get_dataset(dataset) + except google.api_core.exceptions.NotFound: + # This requires bigquery.datasets.create IAM permission. + self._bq_client.create_dataset(dataset, exists_ok=True) + + def _create_bq_function(self, create_function_ddl: str) -> None: + # TODO(swast): plumb through the original, user-facing api_name. + import bigframes.session._io.bigquery + + _, query_job = bigframes.session._io.bigquery.start_query_with_client( + cast(bigquery.Client, self._session.bqclient), + create_function_ddl, + job_config=bigquery.QueryJobConfig(), + location=None, + project=None, + timeout=None, + metrics=None, + query_with_job=True, + publisher=self._session._publisher, + ) + logger.info(f"Created bigframes function {query_job.ddl_target_routine}") + + def _format_function_options(self, function_options: dict) -> str: + return ", ".join( + [ + f"{key}='{val}'" if isinstance(val, str) else f"{key}={val}" + for key, val in function_options.items() + if val is not None + ] + ) + + def create_bq_remote_function( + self, + input_args: Sequence[str], + input_types: Sequence[str], + output_type: str, + endpoint: str, + bq_function_name: str, + max_batching_rows: int, + metadata: str, + ): + """Create a BigQuery remote function given the artifacts of a user defined + function and the http endpoint of a corresponding cloud function.""" + self._create_bq_connection() + # Create BQ function # https://cloud.google.com/bigquery/docs/reference/standard-sql/remote-functions#create_a_remote_function_2 bq_function_args = [] @@ -128,12 +191,8 @@ def create_bq_remote_function( # bigframes specific metadata for the lack of a better option remote_function_options["description"] = metadata - remote_function_options_str = ", ".join( - [ - f"{key}='{val}'" if isinstance(val, str) else f"{key}={val}" - for key, val in remote_function_options.items() - if val is not None - ] + remote_function_options_str = self._format_function_options( + remote_function_options ) create_function_ddl = f""" @@ -144,31 +203,121 @@ def create_bq_remote_function( logger.info(f"Creating BQ remote function: {create_function_ddl}") - # Make sure the dataset exists. I.e. if it doesn't exist, go ahead and - # create it - dataset = bigquery.Dataset( - bigquery.DatasetReference.from_string( - self._bq_dataset, default_project=self._gcp_project_id + self._ensure_dataset_exists() + self._create_bq_function(create_function_ddl) + + def provision_bq_managed_function( + self, + func, + input_types: Sequence[str], + output_type: str, + name: Optional[str], + packages: Optional[Sequence[str]], + max_batching_rows: Optional[int], + container_cpu: Optional[float], + container_memory: Optional[str], + is_row_processor: bool, + bq_connection_id, + *, + capture_references: bool = False, + ): + """Create a BigQuery managed function.""" + + # TODO(b/406283812): Expose the capability to pass down + # capture_references=True in the public udf API. + if ( + capture_references + and (python_version := _utils.get_python_version()) + != _MANAGED_FUNC_PYTHON_VERSION + ): + raise bf_formatting.create_exception_with_feedback_link( + NotImplementedError, + f"Capturing references for udf is currently supported only in Python version {_MANAGED_FUNC_PYTHON_VERSION}, you are running {python_version}.", ) + + # Create BQ managed function. + bq_function_args = [] + bq_function_return_type = output_type + + input_args = inspect.getargs(func.__code__).args + # We expect the input type annotations to be 1:1 with the input args. + for name_, type_ in zip(input_args, input_types): + bq_function_args.append(f"{name_} {type_}") + + managed_function_options: dict[str, Any] = { + "runtime_version": _MANAGED_FUNC_PYTHON_VERSION, + "entry_point": "bigframes_handler", + } + if max_batching_rows: + managed_function_options["max_batching_rows"] = max_batching_rows + if container_cpu: + managed_function_options["container_cpu"] = container_cpu + if container_memory: + managed_function_options["container_memory"] = container_memory + + # Augment user package requirements with any internal package + # requirements. + packages = _utils.get_updated_package_requirements( + packages, is_row_processor, capture_references, ignore_package_version=True + ) + if packages: + managed_function_options["packages"] = packages + managed_function_options_str = self._format_function_options( + managed_function_options ) - dataset.location = self._bq_location - try: - # This check does not require bigquery.datasets.create IAM - # permission. So, if the data set already exists, then user can work - # without having that permission. - self._bq_client.get_dataset(dataset) - except google.api_core.exceptions.NotFound: - # This requires bigquery.datasets.create IAM permission - self._bq_client.create_dataset(dataset, exists_ok=True) - # TODO(swast): plumb through the original, user-facing api_name. - _, query_job = bigframes.session._io.bigquery.start_query_with_client( - self._session.bqclient, - create_function_ddl, - job_config=bigquery.QueryJobConfig(), + session_id = None if name else self._session.session_id + bq_function_name = name + if not bq_function_name: + # Compute a unique hash representing the user code. + function_hash = _utils.get_hash(func, packages) + bq_function_name = _utils.get_bigframes_function_name( + function_hash, + session_id, + ) + + persistent_func_id = ( + f"`{self._gcp_project_id}.{self._bq_dataset}`.{bq_function_name}" + ) + + udf_name = func.__name__ + + with_connection_clause = ( + ( + f"WITH CONNECTION `{self._gcp_project_id}.{self._bq_location}.{self._bq_connection_id}`" + ) + if bq_connection_id + else "" + ) + + # Generate the complete Python code block for the managed Python UDF, + # including the user's function, necessary imports, and the BigQuery + # handler wrapper. + python_code_block = bff_template.generate_managed_function_code( + func, udf_name, is_row_processor, capture_references + ) + + create_function_ddl = ( + textwrap.dedent( + f""" + CREATE OR REPLACE FUNCTION {persistent_func_id}({','.join(bq_function_args)}) + RETURNS {bq_function_return_type} + LANGUAGE python + {with_connection_clause} + OPTIONS ({managed_function_options_str}) + AS r''' + __UDF_PLACE_HOLDER__ + ''' + """ + ) + .strip() + .replace("__UDF_PLACE_HOLDER__", python_code_block) ) - logger.info(f"Created remote function {query_job.ddl_target_routine}") + self._ensure_dataset_exists() + self._create_bq_function(create_function_ddl) + + return bq_function_name def get_cloud_function_fully_qualified_parent(self): "Get the fully qualilfied parent for a cloud function." @@ -229,8 +378,8 @@ def generate_cloud_function_code( def create_cloud_function( self, def_, - cf_name, *, + random_name, input_types: Tuple[str], output_type: str, package_requirements=None, @@ -238,8 +387,9 @@ def create_cloud_function( max_instance_count=None, is_row_processor=False, vpc_connector=None, + vpc_connector_egress_settings="private-ranges-only", memory_mib=1024, - ingress_settings="all", + ingress_settings="internal-only", ): """Create a cloud function from the given user defined function.""" @@ -262,9 +412,7 @@ def create_cloud_function( # TODO(shobs): Figure out how to achieve version compatibility, specially # when pickle (internally used by cloudpickle) guarantees that: # https://docs.python.org/3/library/pickle.html#:~:text=The%20pickle%20serialization%20format%20is,unique%20breaking%20change%20language%20boundary. - python_version = "python{}{}".format( - sys.version_info.major, sys.version_info.minor - ) + python_version = _utils.get_python_version(is_compat=True) # Determine an upload URL for user code upload_url_request = functions_v2.GenerateUploadUrlRequest( @@ -283,10 +431,9 @@ def create_cloud_function( headers={"content-type": "application/zip"}, ) if response.status_code != 200: - raise RuntimeError( - "Failed to upload user code. code={}, reason={}, text={}".format( - response.status_code, response.reason, response.text - ) + raise bf_formatting.create_exception_with_feedback_link( + RuntimeError, + f"Failed to upload user code. code={response.status_code}, reason={response.reason}, text={response.text}", ) # Deploy Cloud Function @@ -294,9 +441,9 @@ def create_cloud_function( create_function_request.parent = ( self.get_cloud_function_fully_qualified_parent() ) - create_function_request.function_id = cf_name + create_function_request.function_id = random_name function = functions_v2.Function() - function.name = self.get_cloud_function_fully_qualified_name(cf_name) + function.name = self.get_cloud_function_fully_qualified_name(random_name) function.build_config = functions_v2.BuildConfig() function.build_config.runtime = python_version function.build_config.entry_point = entry_point @@ -311,29 +458,55 @@ def create_cloud_function( function.build_config.docker_repository = ( self._cloud_function_docker_repository ) + + if self._cloud_build_service_account: + canonical_cloud_build_service_account = ( + self._cloud_build_service_account + if "/" in self._cloud_build_service_account + else f"projects/{self._gcp_project_id}/serviceAccounts/{self._cloud_build_service_account}" + ) + function.build_config.service_account = ( + canonical_cloud_build_service_account + ) + function.service_config = functions_v2.ServiceConfig() if memory_mib is not None: function.service_config.available_memory = f"{memory_mib}Mi" if timeout_seconds is not None: if timeout_seconds > 1200: - raise ValueError( + raise bf_formatting.create_exception_with_feedback_link( + ValueError, "BigQuery remote function can wait only up to 20 minutes" ", see for more details " - "https://cloud.google.com/bigquery/quotas#remote_function_limits." + "https://cloud.google.com/bigquery/quotas#remote_function_limits.", ) function.service_config.timeout_seconds = timeout_seconds if max_instance_count is not None: function.service_config.max_instance_count = max_instance_count if vpc_connector is not None: function.service_config.vpc_connector = vpc_connector + if vpc_connector_egress_settings is None: + msg = bfe.format_message( + "The 'vpc_connector_egress_settings' was not specified. Defaulting to 'private-ranges-only'.", + ) + warnings.warn(msg, category=UserWarning) + vpc_connector_egress_settings = "private-ranges-only" + if vpc_connector_egress_settings not in _VPC_EGRESS_SETTINGS_MAP: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + f"'{vpc_connector_egress_settings}' is not one of the supported vpc egress settings values: {list(_VPC_EGRESS_SETTINGS_MAP)}", + ) + function.service_config.vpc_connector_egress_settings = cast( + functions_v2.ServiceConfig.VpcConnectorEgressSettings, + _VPC_EGRESS_SETTINGS_MAP[vpc_connector_egress_settings], + ) function.service_config.service_account_email = ( self._cloud_function_service_account ) if ingress_settings not in _INGRESS_SETTINGS_MAP: - raise ValueError( - "'{}' not one of the supported ingress settings values: {}".format( - ingress_settings, list(_INGRESS_SETTINGS_MAP) - ) + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + f"'{ingress_settings}' not one of the supported ingress settings values: {list(_INGRESS_SETTINGS_MAP)}", ) function.service_config.ingress_settings = cast( functions_v2.ServiceConfig.IngressSettings, @@ -352,24 +525,25 @@ def create_cloud_function( # Cleanup os.remove(archive_path) except google.api_core.exceptions.AlreadyExists: - # If a cloud function with the same name already exists, let's - # update it - update_function_request = functions_v2.UpdateFunctionRequest() - update_function_request.function = function - operation = self._cloud_functions_client.update_function( - request=update_function_request - ) - operation.result() + # b/437124912: The most likely scenario is that + # `create_function` had a retry due to a network issue. The + # retried request then fails because the first call actually + # succeeded, but we didn't get the successful response back. + # + # Since the function name was randomly chosen to avoid + # conflicts, we know the AlreadyExist can only happen because + # we created it. This error is safe to ignore. + pass # Fetch the endpoint of the just created function - endpoint = self.get_cloud_function_endpoint(cf_name) + endpoint = self.get_cloud_function_endpoint(random_name) if not endpoint: - raise ValueError( - f"Couldn't fetch the http endpoint. {constants.FEEDBACK_LINK}" + raise bf_formatting.create_exception_with_feedback_link( + ValueError, "Couldn't fetch the http endpoint." ) logger.info( - f"Successfully created cloud function {cf_name} with uri ({endpoint})" + f"Successfully created cloud function {random_name} with uri ({endpoint})" ) return endpoint @@ -386,6 +560,7 @@ def provision_bq_remote_function( cloud_function_max_instance_count, is_row_processor, cloud_function_vpc_connector, + cloud_function_vpc_connector_egress_settings, cloud_function_memory_mib, cloud_function_ingress_settings, bq_metadata, @@ -393,12 +568,12 @@ def provision_bq_remote_function( """Provision a BigQuery remote function.""" # Augment user package requirements with any internal package # requirements - package_requirements = _utils._get_updated_package_requirements( + package_requirements = _utils.get_updated_package_requirements( package_requirements, is_row_processor ) # Compute a unique hash representing the user code - function_hash = _utils._get_hash(def_, package_requirements) + function_hash = _utils.get_hash(def_, package_requirements) # If reuse of any existing function with the same name (indicated by the # same hash of its source code) is not intended, then attach a unique @@ -426,7 +601,7 @@ def provision_bq_remote_function( if not cf_endpoint: cf_endpoint = self.create_cloud_function( def_, - cloud_function_name, + random_name=cloud_function_name, input_types=input_types, output_type=output_type, package_requirements=package_requirements, @@ -434,6 +609,7 @@ def provision_bq_remote_function( max_instance_count=cloud_function_max_instance_count, is_row_processor=is_row_processor, vpc_connector=cloud_function_vpc_connector, + vpc_connector_egress_settings=cloud_function_vpc_connector_egress_settings, memory_mib=cloud_function_memory_mib, ingress_settings=cloud_function_ingress_settings, ) @@ -443,7 +619,7 @@ def provision_bq_remote_function( # Derive the name of the remote function remote_function_name = name if not remote_function_name: - remote_function_name = _utils.get_remote_function_name( + remote_function_name = _utils.get_bigframes_function_name( function_hash, self._session.session_id, uniq_suffix ) rf_endpoint, rf_conn = self.get_remote_function_specs(remote_function_name) @@ -458,8 +634,9 @@ def provision_bq_remote_function( ): input_args = inspect.getargs(def_.__code__).args if len(input_args) != len(input_types): - raise ValueError( - "Exactly one type should be provided for every input arg." + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "Exactly one type should be provided for every input arg.", ) self.create_bq_remote_function( input_args, diff --git a/bigframes/functions/_function_session.py b/bigframes/functions/_function_session.py index a0518978a3..a456f05417 100644 --- a/bigframes/functions/_function_session.py +++ b/bigframes/functions/_function_session.py @@ -16,6 +16,7 @@ from __future__ import annotations import collections.abc +import functools import inspect import sys import threading @@ -23,6 +24,7 @@ Any, cast, Dict, + get_origin, Literal, Mapping, Optional, @@ -32,11 +34,6 @@ ) import warnings -import bigframes_vendored.constants as constants -import bigframes_vendored.ibis.backends.bigquery.datatypes as third_party_ibis_bqtypes -import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes -import bigframes_vendored.ibis.expr.operations.udf as ibis_udf -import cloudpickle import google.api_core.exceptions from google.cloud import ( bigquery, @@ -46,13 +43,17 @@ ) from bigframes import clients +import bigframes.exceptions as bfe +import bigframes.formatting_helpers as bf_formatting +from bigframes.functions import function as bq_functions +from bigframes.functions import udf_def if TYPE_CHECKING: from bigframes.session import Session import pandas -from . import _function_client, _utils +from bigframes.functions import _function_client, _utils class FunctionSession: @@ -65,6 +66,129 @@ def __init__(self): # Lock to synchronize the update of the session artifacts self._artifacts_lock = threading.Lock() + def _resolve_session(self, session: Optional[Session]) -> Session: + """Resolves the BigFrames session.""" + import bigframes.pandas as bpd + import bigframes.session + + # Using the global session if none is provided. + return cast(bigframes.session.Session, session or bpd.get_global_session()) + + def _resolve_bigquery_client( + self, session: Session, bigquery_client: Optional[bigquery.Client] + ) -> bigquery.Client: + """Resolves the BigQuery client.""" + if not bigquery_client: + bigquery_client = session.bqclient + if not bigquery_client: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "A bigquery client must be provided, either directly or via " + "session.", + ) + return bigquery_client + + def _resolve_bigquery_connection_client( + self, + session: Session, + bigquery_connection_client: Optional[ + bigquery_connection_v1.ConnectionServiceClient + ], + ) -> bigquery_connection_v1.ConnectionServiceClient: + """Resolves the BigQuery connection client.""" + if not bigquery_connection_client: + bigquery_connection_client = session.bqconnectionclient + if not bigquery_connection_client: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "A bigquery connection client must be provided, either " + "directly or via session.", + ) + return bigquery_connection_client + + def _resolve_resource_manager_client( + self, + session: Session, + resource_manager_client: Optional[resourcemanager_v3.ProjectsClient], + ) -> resourcemanager_v3.ProjectsClient: + """Resolves the resource manager client.""" + if not resource_manager_client: + resource_manager_client = session.resourcemanagerclient + if not resource_manager_client: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "A resource manager client must be provided, either directly " + "or via session.", + ) + return resource_manager_client + + def _resolve_dataset_reference( + self, + session: Session, + bigquery_client: bigquery.Client, + dataset: Optional[str], + ) -> bigquery.DatasetReference: + """Resolves the dataset reference for the bigframes function.""" + if dataset: + dataset_ref = bigquery.DatasetReference.from_string( + dataset, default_project=bigquery_client.project + ) + else: + dataset_ref = session._anonymous_dataset + return dataset_ref + + def _resolve_cloud_functions_client( + self, + session: Session, + cloud_functions_client: Optional[functions_v2.FunctionServiceClient], + ) -> Optional[functions_v2.FunctionServiceClient]: + """Resolves the Cloud Functions client.""" + if not cloud_functions_client: + cloud_functions_client = session.cloudfunctionsclient + if not cloud_functions_client: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "A cloud functions client must be provided, either directly " + "or via session.", + ) + return cloud_functions_client + + def _resolve_bigquery_connection_id( + self, + session: Session, + dataset_ref: bigquery.DatasetReference, + bq_location: str, + bigquery_connection: Optional[str] = None, + ) -> str: + """Resolves BigQuery connection id.""" + if not bigquery_connection: + bigquery_connection = session._bq_connection # type: ignore + + bigquery_connection = clients.get_canonical_bq_connection_id( + bigquery_connection, + default_project=dataset_ref.project, + default_location=bq_location, + ) + # Guaranteed to be the form of .. + ( + gcp_project_id, + bq_connection_location, + bq_connection_id, + ) = bigquery_connection.split(".") + if gcp_project_id.casefold() != dataset_ref.project.casefold(): + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "The project_id does not match BigQuery connection " + f"gcp_project_id: {dataset_ref.project}.", + ) + if bq_connection_location.casefold() != bq_location.casefold(): + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "The location does not match BigQuery connection location: " + f"{bq_location}.", + ) + return bq_connection_id + def _update_temp_artifacts(self, bqrf_routine: str, gcf_path: str): """Update function artifacts in the current session.""" with self._artifacts_lock: @@ -83,12 +207,13 @@ def clean_up( # deleted directly by the user bqclient.delete_routine(bqrf_routine, not_found_ok=True) - # Let's accept the possibility that the cloud function may have - # been deleted directly by the user - try: - gcfclient.delete_function(name=gcf_path) - except google.api_core.exceptions.NotFound: - pass + if gcf_path: + # Let's accept the possibility that the cloud function may + # have been deleted directly by the user + try: + gcfclient.delete_function(name=gcf_path) + except google.api_core.exceptions.NotFound: + pass self._temp_artifacts.clear() @@ -98,6 +223,7 @@ def clean_up( # https://github.com/ibis-project/ibis/blob/master/ibis/backends/bigquery/udf/__init__.py def remote_function( self, + *, input_types: Union[None, type, Sequence[type]] = None, output_type: Optional[type] = None, session: Optional[Session] = None, @@ -112,23 +238,34 @@ def remote_function( reuse: bool = True, name: Optional[str] = None, packages: Optional[Sequence[str]] = None, - cloud_function_service_account: Optional[str] = None, + cloud_function_service_account: str, cloud_function_kms_key_name: Optional[str] = None, cloud_function_docker_repository: Optional[str] = None, max_batching_rows: Optional[int] = 1000, cloud_function_timeout: Optional[int] = 600, cloud_function_max_instances: Optional[int] = None, cloud_function_vpc_connector: Optional[str] = None, + cloud_function_vpc_connector_egress_settings: Optional[ + Literal["all", "private-ranges-only", "unspecified"] + ] = None, cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" - ] = "all", + ] = "internal-only", + cloud_build_service_account: Optional[str] = None, ): """Decorator to turn a user defined function into a BigQuery remote function. .. deprecated:: 0.0.1 This is an internal method. Please use :func:`bigframes.pandas.remote_function` instead. + .. warning:: + To use remote functions with Bigframes 2.0 and onwards, please (preferred) + set an explicit user-managed ``cloud_function_service_account`` or (discouraged) + set ``cloud_function_service_account`` to use the Compute Engine service account + by setting it to `"default"`. + See, https://cloud.google.com/functions/docs/securing/function-identity. + .. note:: Please make sure following is setup before using this API: @@ -238,8 +375,8 @@ def remote_function( Explicit name of the external package dependencies. Each dependency is added to the `requirements.txt` as is, and can be of the form supported in https://pip.pypa.io/en/stable/reference/requirements-file-format/. - cloud_function_service_account (str, Optional): - Service account to use for the cloud functions. If not provided then + cloud_function_service_account (str): + Service account to use for the cloud functions. If "default" provided then the default service account would be used. See https://cloud.google.com/functions/docs/securing/function-identity for more details. Please make sure the service account has the @@ -291,6 +428,13 @@ def remote_function( function. This is useful if your code needs access to data or service(s) that are on a VPC network. See for more details https://cloud.google.com/functions/docs/networking/connecting-vpc. + cloud_function_vpc_connector_egress_settings (str, Optional): + Egress settings for the VPC connector, controlling what outbound + traffic is routed through the VPC connector. + Options are: `all`, `private-ranges-only`, or `unspecified`. + If not specified, `private-ranges-only` is used by default. + See for more details + https://cloud.google.com/run/docs/configuring/vpc-connectors#egress-job. cloud_function_memory_mib (int, Optional): The amounts of memory (in mebibytes) to allocate for the cloud function (2nd gen) created. This also dictates a corresponding @@ -302,111 +446,100 @@ def remote_function( https://cloud.google.com/functions/docs/configuring/memory. cloud_function_ingress_settings (str, Optional): Ingress settings controls dictating what traffic can reach the - function. By default `all` will be used. It must be one of: - `all`, `internal-only`, `internal-and-gclb`. See for more details + function. Options are: `all`, `internal-only`, or `internal-and-gclb`. + If no setting is provided, `internal-only` will be used by default. + See for more details https://cloud.google.com/functions/docs/networking/network-settings#ingress_settings. + cloud_build_service_account (str, Optional): + Service account in the fully qualified format + `projects/PROJECT_ID/serviceAccounts/SERVICE_ACCOUNT_EMAIL`, or + just the SERVICE_ACCOUNT_EMAIL. The latter would be interpreted + as belonging to the BigQuery DataFrames session project. This is + to be used by Cloud Build to build the function source code into + a deployable artifact. If not provided, the default Cloud Build + service account is used. See + https://cloud.google.com/build/docs/cloud-build-service-account + for more details. """ - # Some defaults may be used from the session if not provided otherwise - import bigframes.exceptions as bfe - import bigframes.pandas as bpd - import bigframes.series as bf_series - import bigframes.session + # Some defaults may be used from the session if not provided otherwise. + session = self._resolve_session(session) - session = cast(bigframes.session.Session, session or bpd.get_global_session()) - - # A BigQuery client is required to perform BQ operations - if not bigquery_client: - bigquery_client = session.bqclient - if not bigquery_client: + # If the user forces the cloud function service argument to None, throw + # an exception + if cloud_function_service_account is None: raise ValueError( - "A bigquery client must be provided, either directly or via session. " - f"{constants.FEEDBACK_LINK}" + 'You must provide a user managed cloud_function_service_account, or "default" if you would like to let the default service account be used.' ) - # A BigQuery connection client is required to perform BQ connection operations - if not bigquery_connection_client: - bigquery_connection_client = session.bqconnectionclient - if not bigquery_connection_client: - raise ValueError( - "A bigquery connection client must be provided, either directly or via session. " - f"{constants.FEEDBACK_LINK}" - ) + # A BigQuery client is required to perform BQ operations. + bigquery_client = self._resolve_bigquery_client(session, bigquery_client) - # A cloud functions client is required to perform cloud functions operations - if not cloud_functions_client: - cloud_functions_client = session.cloudfunctionsclient - if not cloud_functions_client: - raise ValueError( - "A cloud functions client must be provided, either directly or via session. " - f"{constants.FEEDBACK_LINK}" - ) + # A BigQuery connection client is required for BQ connection operations. + bigquery_connection_client = self._resolve_bigquery_connection_client( + session, bigquery_connection_client + ) - # A resource manager client is required to get/set IAM operations - if not resource_manager_client: - resource_manager_client = session.resourcemanagerclient - if not resource_manager_client: - raise ValueError( - "A resource manager client must be provided, either directly or via session. " - f"{constants.FEEDBACK_LINK}" - ) + # A resource manager client is required to get/set IAM operations. + resource_manager_client = self._resolve_resource_manager_client( + session, resource_manager_client + ) - # BQ remote function must be persisted, for which we need a dataset + # BQ remote function must be persisted, for which we need a dataset. # https://cloud.google.com/bigquery/docs/reference/standard-sql/remote-functions#:~:text=You%20cannot%20create%20temporary%20remote%20functions. - if dataset: - dataset_ref = bigquery.DatasetReference.from_string( - dataset, default_project=bigquery_client.project - ) - else: - dataset_ref = session._anonymous_dataset + dataset_ref = self._resolve_dataset_reference(session, bigquery_client, dataset) + + # A cloud functions client is required for cloud functions operations. + cloud_functions_client = self._resolve_cloud_functions_client( + session, cloud_functions_client + ) bq_location, cloud_function_region = _utils.get_remote_function_locations( bigquery_client.location ) - # A connection is required for BQ remote function + # A connection is required for BQ remote function. # https://cloud.google.com/bigquery/docs/reference/standard-sql/remote-functions#create_a_remote_function - if not bigquery_connection: - bigquery_connection = session._bq_connection # type: ignore - - bigquery_connection = clients.resolve_full_bq_connection_name( - bigquery_connection, - default_project=dataset_ref.project, - default_location=bq_location, + bq_connection_id = self._resolve_bigquery_connection_id( + session, dataset_ref, bq_location, bigquery_connection ) - # Guaranteed to be the form of .. - ( - gcp_project_id, - bq_connection_location, - bq_connection_id, - ) = bigquery_connection.split(".") - if gcp_project_id.casefold() != dataset_ref.project.casefold(): - raise ValueError( - "The project_id does not match BigQuery connection gcp_project_id: " - f"{dataset_ref.project}." - ) - if bq_connection_location.casefold() != bq_location.casefold(): - raise ValueError( - "The location does not match BigQuery connection location: " - f"{bq_location}." - ) - # If any CMEK is intended then check that a docker repository is also specified + # If any CMEK is intended then check that a docker repository is also specified. if ( cloud_function_kms_key_name is not None and cloud_function_docker_repository is None ): - raise ValueError( + raise bf_formatting.create_exception_with_feedback_link( + ValueError, "cloud_function_docker_repository must be specified with cloud_function_kms_key_name." - " For more details see https://cloud.google.com/functions/docs/securing/cmek#before_you_begin" + " For more details see https://cloud.google.com/functions/docs/securing/cmek#before_you_begin.", ) + # A VPC connector is required to specify VPC egress settings. + if ( + cloud_function_vpc_connector_egress_settings is not None + and cloud_function_vpc_connector is None + ): + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "cloud_function_vpc_connector must be specified before cloud_function_vpc_connector_egress_settings.", + ) + + if cloud_function_ingress_settings is None: + cloud_function_ingress_settings = "internal-only" + msg = bfe.format_message( + "The `cloud_function_ingress_settings` is being set to 'internal-only' by default." + ) + warnings.warn(msg, category=UserWarning, stacklevel=2) + bq_connection_manager = session.bqconnectionmanager def wrapper(func): nonlocal input_types, output_type if not callable(func): - raise TypeError("f must be callable, got {}".format(func)) + raise bf_formatting.create_exception_with_feedback_link( + TypeError, f"func must be a callable, got {func}" + ) if sys.version_info >= (3, 10): # Add `eval_str = True` so that deferred annotations are turned into their @@ -416,111 +549,76 @@ def wrapper(func): else: signature_kwargs = {} # type: ignore - signature = inspect.signature( + py_sig = inspect.signature( func, **signature_kwargs, ) - - # Try to get input types via type annotations. - if input_types is None: - input_types = [] - for parameter in signature.parameters.values(): - if (param_type := parameter.annotation) is inspect.Signature.empty: - raise ValueError( - "'input_types' was not set and parameter " - f"'{parameter.name}' is missing a type annotation. " - "Types are required to use @remote_function." - ) - input_types.append(param_type) - elif not isinstance(input_types, collections.abc.Sequence): - input_types = [input_types] - - if output_type is None: - if ( - output_type := signature.return_annotation - ) is inspect.Signature.empty: - raise ValueError( - "'output_type' was not set and function is missing a " - "return type annotation. Types are required to use " - "@remote_function." + if input_types is not None: + if not isinstance(input_types, collections.abc.Sequence): + input_types = [input_types] + if _utils.has_conflict_input_type(py_sig, input_types): + msg = bfe.format_message( + "Conflicting input types detected, using the one from the decorator." ) + warnings.warn(msg, category=bfe.FunctionConflictTypeHintWarning) + py_sig = py_sig.replace( + parameters=[ + par.replace(annotation=itype) + for par, itype in zip(py_sig.parameters.values(), input_types) + ] + ) + if output_type: + if _utils.has_conflict_output_type(py_sig, output_type): + msg = bfe.format_message( + "Conflicting return type detected, using the one from the decorator." + ) + warnings.warn(msg, category=bfe.FunctionConflictTypeHintWarning) + py_sig = py_sig.replace(return_annotation=output_type) - # The function will actually be receiving a pandas Series, but allow both - # BigQuery DataFrames and pandas object types for compatibility. + # The function will actually be receiving a pandas Series, but allow + # both BigQuery DataFrames and pandas object types for compatibility. is_row_processor = False - if len(input_types) == 1 and ( - (input_type := input_types[0]) == bf_series.Series - or input_type == pandas.Series - ): - msg = "input_types=Series is in preview." - warnings.warn(msg, stacklevel=1, category=bfe.PreviewWarning) - - # we will model the row as a json serialized string containing the data - # and the metadata representing the row - input_types = [str] + if new_sig := _convert_row_processor_sig(py_sig): + py_sig = new_sig is_row_processor = True - elif isinstance(input_types, type): - input_types = [input_types] - - # TODO(b/340898611): fix type error - ibis_signature = _utils.ibis_signature_from_python_signature( - signature, input_types, output_type # type: ignore - ) remote_function_client = _function_client.FunctionClient( dataset_ref.project, - cloud_function_region, - cloud_functions_client, bq_location, dataset_ref.dataset_id, bigquery_client, bq_connection_id, bq_connection_manager, - cloud_function_service_account, + cloud_function_region, + cloud_functions_client, + None + if cloud_function_service_account == "default" + else cloud_function_service_account, cloud_function_kms_key_name, cloud_function_docker_repository, + cloud_build_service_account=cloud_build_service_account, session=session, # type: ignore ) - # To respect the user code/environment let's use a copy of the - # original udf, especially since we would be setting some properties - # on it - func = cloudpickle.loads(cloudpickle.dumps(func)) - - # In the unlikely case where the user is trying to re-deploy the same - # function, cleanup the attributes we add below, first. This prevents - # the pickle from having dependencies that might not otherwise be - # present such as ibis or pandas. - def try_delattr(attr): - try: - delattr(func, attr) - except AttributeError: - pass - - try_delattr("bigframes_cloud_function") - try_delattr("bigframes_remote_function") - try_delattr("input_dtypes") - try_delattr("output_dtype") - try_delattr("is_row_processor") - try_delattr("ibis_node") - # resolve the output type that can be supported in the bigframes, - # ibis, BQ remote functions and cloud functions integration - ibis_output_type_for_bqrf = ibis_signature.output_type + # ibis, BQ remote functions and cloud functions integration. bqrf_metadata = None - if isinstance(ibis_signature.output_type, ibis_dtypes.Array): + post_process_routine = None + if get_origin(py_sig.return_annotation) is list: # TODO(b/284515241): remove this special handling to support # array output types once BQ remote functions support ARRAY. # Until then, use json serialized strings at the cloud function # and BQ level, and parse that to the intended output type at # the bigframes level. - ibis_output_type_for_bqrf = ibis_dtypes.String() bqrf_metadata = _utils.get_bigframes_metadata( - python_output_type=output_type + python_output_type=py_sig.return_annotation ) - bqrf_output_type = third_party_ibis_bqtypes.BigQueryType.from_ibis( - ibis_output_type_for_bqrf - ) + post_process_routine = _utils.build_unnest_post_routine( + py_sig.return_annotation + ) + py_sig = py_sig.replace(return_annotation=str) + + udf_sig = udf_def.UdfSignature.from_py_signature(py_sig) ( rf_name, @@ -528,12 +626,8 @@ def try_delattr(attr): created_new, ) = remote_function_client.provision_bq_remote_function( func, - input_types=tuple( - third_party_ibis_bqtypes.BigQueryType.from_ibis(type_) - for type_ in ibis_signature.input_types - if type_ is not None - ), - output_type=bqrf_output_type, + input_types=udf_sig.sql_input_types, + output_type=udf_sig.sql_output_type, reuse=reuse, name=name, package_requirements=packages, @@ -542,55 +636,20 @@ def try_delattr(attr): cloud_function_max_instance_count=cloud_function_max_instances, is_row_processor=is_row_processor, cloud_function_vpc_connector=cloud_function_vpc_connector, + cloud_function_vpc_connector_egress_settings=cloud_function_vpc_connector_egress_settings, cloud_function_memory_mib=cloud_function_memory_mib, cloud_function_ingress_settings=cloud_function_ingress_settings, bq_metadata=bqrf_metadata, ) - # TODO(shobs): Find a better way to support udfs with param named "name". - # This causes an issue in the ibis compilation. - func.__signature__ = inspect.signature(func).replace( # type: ignore - parameters=[ - inspect.Parameter( - f"bigframes_{param.name}", - param.kind, - ) - for param in inspect.signature(func).parameters.values() - ] - ) - - # TODO: Move ibis logic to compiler step - node = ibis_udf.scalar.builtin( - func, - name=rf_name, - catalog=dataset_ref.project, - database=dataset_ref.dataset_id, - signature=(ibis_signature.input_types, ibis_output_type_for_bqrf), - ) # type: ignore - func.bigframes_cloud_function = ( + bigframes_cloud_function = ( remote_function_client.get_cloud_function_fully_qualified_name(cf_name) ) - func.bigframes_remote_function = ( + bigframes_bigquery_function = ( remote_function_client.get_remote_function_fully_qualilfied_name( rf_name ) ) - func.input_dtypes = tuple( - [ - bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( - input_type - ) - for input_type in ibis_signature.input_types - if input_type is not None - ] - ) - func.output_dtype = ( - bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( - ibis_signature.output_type - ) - ) - func.is_row_processor = is_row_processor - func.ibis_node = node # If a new remote function was created, update the cloud artifacts # created in the session. This would be used to clean up any @@ -601,8 +660,346 @@ def try_delattr(attr): # with that name and would directly manage their lifecycle. if created_new and (not name): self._update_temp_artifacts( - func.bigframes_remote_function, func.bigframes_cloud_function + bigframes_bigquery_function, bigframes_cloud_function + ) + + udf_definition = udf_def.BigqueryUdf( + routine_ref=bigquery.RoutineReference.from_string( + bigframes_bigquery_function + ), + signature=udf_sig, + ) + decorator = functools.wraps(func) + if is_row_processor: + return decorator( + bq_functions.BigqueryCallableRowRoutine( + udf_definition, + session, + post_routine=post_process_routine, + cloud_function_ref=bigframes_cloud_function, + local_func=func, + is_managed=False, + ) + ) + else: + return decorator( + bq_functions.BigqueryCallableRoutine( + udf_definition, + session, + post_routine=post_process_routine, + cloud_function_ref=bigframes_cloud_function, + local_func=func, + is_managed=False, + ) + ) + + return wrapper + + def deploy_remote_function( + self, + func, + **kwargs, + ): + """Orchestrates the creation of a BigQuery remote function that deploys immediately. + + This method ensures that the remote function is created and available for + use in BigQuery as soon as this call is made. + + Args: + kwargs: + All arguments are passed directly to + :meth:`~bigframes.session.Session.remote_function`. Please see + its docstring for parameter details. + + Returns: + A wrapped remote function, usable in + :meth:`~bigframes.series.Series.apply`. + """ + # TODO(tswast): If we update remote_function to defer deployment, update + # this method to deploy immediately. + return self.remote_function(**kwargs)(func) + + def udf( + self, + input_types: Union[None, type, Sequence[type]] = None, + output_type: Optional[type] = None, + session: Optional[Session] = None, + bigquery_client: Optional[bigquery.Client] = None, + dataset: Optional[str] = None, + bigquery_connection: Optional[str] = None, + name: Optional[str] = None, + packages: Optional[Sequence[str]] = None, + max_batching_rows: Optional[int] = None, + container_cpu: Optional[float] = None, + container_memory: Optional[str] = None, + ): + """Decorator to turn a Python user defined function (udf) into a + BigQuery managed function. + + .. note:: + This feature is in preview. The code in the udf must be + (1) self-contained, i.e. it must not contain any + references to an import or variable defined outside the function + body, and + (2) Python 3.11 compatible, as that is the environment + in which the code is executed in the cloud. + + .. note:: + Please have following IAM roles enabled for you: + + * BigQuery Data Editor (roles/bigquery.dataEditor) + + Args: + input_types (type or sequence(type), Optional): + For scalar user defined function it should be the input type or + sequence of input types. The supported scalar input types are + `bool`, `bytes`, `float`, `int`, `str`. + output_type (type, Optional): + Data type of the output in the user defined function. If the + user defined function returns an array, then `list[type]` should + be specified. The supported output types are `bool`, `bytes`, + `float`, `int`, `str`, `list[bool]`, `list[float]`, `list[int]` + and `list[str]`. + session (bigframes.Session, Optional): + BigQuery DataFrames session to use for getting default project, + dataset and BigQuery connection. + bigquery_client (google.cloud.bigquery.Client, Optional): + Client to use for BigQuery operations. If this param is not + provided, then bigquery client from the session would be used. + dataset (str, Optional): + Dataset in which to create a BigQuery managed function. It + should be in `.` or `` + format. If this parameter is not provided then session dataset + id is used. + bigquery_connection (str, Optional): + Name of the BigQuery connection. It is used to provide an + identity to the serverless instances running the user code. It + helps BigQuery manage and track the resources used by the udf. + This connection is required for internet access and for + interacting with other GCP services. To access GCP services, the + appropriate IAM permissions must also be granted to the + connection's Service Account. When it defaults to None, the udf + will be created without any connection. A udf without a + connection has no internet access and no access to other GCP + services. + name (str, Optional): + Explicit name of the persisted BigQuery managed function. Use it + with caution, because more than one users working in the same + project and dataset could overwrite each other's managed + functions if they use the same persistent name. When an explicit + name is provided, any session specific clean up ( + ``bigframes.session.Session.close``/ + ``bigframes.pandas.close_session``/ + ``bigframes.pandas.reset_session``/ + ``bigframes.pandas.clean_up_by_session_id``) does not clean up + the function, and leaves it for the user to manage the function + directly. + packages (str[], Optional): + Explicit name of the external package dependencies. Each + dependency is added to the `requirements.txt` as is, and can be + of the form supported in + https://pip.pypa.io/en/stable/reference/requirements-file-format/. + max_batching_rows (int, Optional): + The maximum number of rows in each batch. If you specify + max_batching_rows, BigQuery determines the number of rows in a + batch, up to the max_batching_rows limit. If max_batching_rows + is not specified, the number of rows to batch is determined + automatically. + container_cpu (float, Optional): + The CPU limits for containers that run Python UDFs. By default, + the CPU allocated is 0.33 vCPU. See details at + https://cloud.google.com/bigquery/docs/user-defined-functions-python#configure-container-limits. + container_memory (str, Optional): + The memory limits for containers that run Python UDFs. By + default, the memory allocated to each container instance is + 512 MiB. See details at + https://cloud.google.com/bigquery/docs/user-defined-functions-python#configure-container-limits. + """ + + warnings.warn("udf is in preview.", category=bfe.PreviewWarning, stacklevel=5) + + # Some defaults may be used from the session if not provided otherwise. + session = self._resolve_session(session) + + # A BigQuery client is required to perform BQ operations. + bigquery_client = self._resolve_bigquery_client(session, bigquery_client) + + # BQ managed function must be persisted, for which we need a dataset. + dataset_ref = self._resolve_dataset_reference(session, bigquery_client, dataset) + + bq_location, _ = _utils.get_remote_function_locations(bigquery_client.location) + + # A connection is optional for BQ managed function. + bq_connection_id = ( + self._resolve_bigquery_connection_id( + session, dataset_ref, bq_location, bigquery_connection + ) + if bigquery_connection + else None + ) + + bq_connection_manager = session.bqconnectionmanager + + # TODO(b/399129906): Write a method for the repeated part in the wrapper + # for both managed function and remote function. + def wrapper(func): + nonlocal input_types, output_type + + if not callable(func): + raise bf_formatting.create_exception_with_feedback_link( + TypeError, f"func must be a callable, got {func}" + ) + + if sys.version_info >= (3, 10): + # Add `eval_str = True` so that deferred annotations are turned into their + # corresponding type objects. Need Python 3.10 for eval_str parameter. + # https://docs.python.org/3/library/inspect.html#inspect.signature + signature_kwargs: Mapping[str, Any] = {"eval_str": True} + else: + signature_kwargs = {} # type: ignore + + py_sig = inspect.signature( + func, + **signature_kwargs, + ) + if input_types is not None: + if not isinstance(input_types, collections.abc.Sequence): + input_types = [input_types] + if _utils.has_conflict_input_type(py_sig, input_types): + msg = bfe.format_message( + "Conflicting input types detected, using the one from the decorator." + ) + warnings.warn(msg, category=bfe.FunctionConflictTypeHintWarning) + py_sig = py_sig.replace( + parameters=[ + par.replace(annotation=itype) + for par, itype in zip(py_sig.parameters.values(), input_types) + ] + ) + if output_type: + if _utils.has_conflict_output_type(py_sig, output_type): + msg = bfe.format_message( + "Conflicting return type detected, using the one from the decorator." + ) + warnings.warn(msg, category=bfe.FunctionConflictTypeHintWarning) + py_sig = py_sig.replace(return_annotation=output_type) + + # The function will actually be receiving a pandas Series, but allow + # both BigQuery DataFrames and pandas object types for compatibility. + is_row_processor = False + if new_sig := _convert_row_processor_sig(py_sig): + py_sig = new_sig + is_row_processor = True + + udf_sig = udf_def.UdfSignature.from_py_signature(py_sig) + + managed_function_client = _function_client.FunctionClient( + dataset_ref.project, + bq_location, + dataset_ref.dataset_id, + bigquery_client, + bq_connection_id, + bq_connection_manager, + session=session, # type: ignore + ) + + bq_function_name = managed_function_client.provision_bq_managed_function( + func=func, + input_types=udf_sig.sql_input_types, + output_type=udf_sig.sql_output_type, + name=name, + packages=packages, + max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, + is_row_processor=is_row_processor, + bq_connection_id=bq_connection_id, + ) + full_rf_name = ( + managed_function_client.get_remote_function_fully_qualilfied_name( + bq_function_name + ) + ) + + udf_definition = udf_def.BigqueryUdf( + routine_ref=bigquery.RoutineReference.from_string(full_rf_name), + signature=udf_sig, + ) + + if not name: + self._update_temp_artifacts(full_rf_name, "") + + decorator = functools.wraps(func) + if is_row_processor: + return decorator( + bq_functions.BigqueryCallableRowRoutine( + udf_definition, session, local_func=func, is_managed=True + ) + ) + else: + return decorator( + bq_functions.BigqueryCallableRoutine( + udf_definition, + session, + local_func=func, + is_managed=True, + ) ) - return func return wrapper + + def deploy_udf( + self, + func, + **kwargs, + ): + """Orchestrates the creation of a BigQuery UDF that deploys immediately. + + This method ensures that the UDF is created and available for + use in BigQuery as soon as this call is made. + + Args: + func: + Function to deploy. + kwargs: + All arguments are passed directly to + :meth:`~bigframes.session.Session.udf`. Please see + its docstring for parameter details. + + Returns: + A wrapped Python user defined function, usable in + :meth:`~bigframes.series.Series.apply`. + """ + # TODO(tswast): If we update udf to defer deployment, update this method + # to deploy immediately. + return self.udf(**kwargs)(func) + + +def _convert_row_processor_sig( + signature: inspect.Signature, +) -> Optional[inspect.Signature]: + import bigframes.series as bf_series + + if len(signature.parameters) >= 1: + first_param = next(iter(signature.parameters.values())) + param_type = first_param.annotation + # Type hints for Series inputs should use pandas.Series because the + # underlying serialization process converts the input to a string + # representation of a pandas Series (not bigframes Series). Using + # bigframes Series will lead to TypeError when creating the function + # remotely. See more from b/445182819. + if param_type == bf_series.Series: + raise bf_formatting.create_exception_with_feedback_link( + TypeError, + "Argument type hint must be Pandas Series, not BigFrames Series.", + ) + if param_type == pandas.Series: + msg = bfe.format_message("input_types=Series is in preview.") + warnings.warn(msg, stacklevel=1, category=bfe.PreviewWarning) + return signature.replace( + parameters=[ + p.replace(annotation=str) if i == 0 else p + for i, p in enumerate(signature.parameters.values()) + ] + ) + return None diff --git a/bigframes/functions/_utils.py b/bigframes/functions/_utils.py index f1f8c97e7f..b6dedeac50 100644 --- a/bigframes/functions/_utils.py +++ b/bigframes/functions/_utils.py @@ -16,19 +16,22 @@ import hashlib import inspect import json +import sys import typing -from typing import cast, List, NamedTuple, Optional, Sequence, Set +from typing import Any, cast, Optional, Sequence, Set +import warnings -import bigframes_vendored.ibis.expr.datatypes.core as ibis_dtypes import cloudpickle import google.api_core.exceptions from google.cloud import bigquery, functions_v2 import numpy +from packaging.requirements import Requirement import pandas import pyarrow -import bigframes.core.compile.ibis_types -import bigframes.dtypes +import bigframes.exceptions as bfe +import bigframes.formatting_helpers as bf_formatting +from bigframes.functions import function_typing # Naming convention for the function artifacts _BIGFRAMES_FUNCTION_PREFIX = "bigframes" @@ -61,27 +64,62 @@ def get_remote_function_locations(bq_location): return bq_location, cloud_function_region -def _get_updated_package_requirements( - package_requirements=None, is_row_processor=False -): - requirements = [f"cloudpickle=={cloudpickle.__version__}"] - if is_row_processor: - # bigframes function will send an entire row of data as json, which - # would be converted to a pandas series and processed Ensure numpy - # versions match to avoid unpickling problems. See internal issue - # b/347934471. - requirements.append(f"numpy=={numpy.__version__}") - requirements.append(f"pandas=={pandas.__version__}") - requirements.append(f"pyarrow=={pyarrow.__version__}") +def _package_existed(package_requirements: list[str], package: str) -> bool: + """Checks if a package (regardless of version) exists in a given list.""" + if not package_requirements: + return False - if package_requirements: - requirements.extend(package_requirements) + return Requirement(package).name in { + Requirement(req).name for req in package_requirements + } - requirements = sorted(requirements) - return requirements +def get_updated_package_requirements( + package_requirements=None, + is_row_processor=False, + capture_references=True, + ignore_package_version=False, +): + requirements = [] + if capture_references: + requirements.append(f"cloudpickle=={cloudpickle.__version__}") -def _clean_up_by_session_id( + if is_row_processor: + if ignore_package_version: + # TODO(jialuo): Add back the version after b/410924784 is resolved. + # Due to current limitations on the packages version in Python UDFs, + # we use `ignore_package_version` to optionally omit the version for + # managed functions only. + msg = bfe.format_message( + "numpy, pandas, and pyarrow versions in the function execution" + " environment may not precisely match your local environment." + ) + warnings.warn(msg, category=bfe.FunctionPackageVersionWarning) + requirements.append("pandas") + requirements.append("pyarrow") + requirements.append("numpy") + else: + # bigframes function will send an entire row of data as json, which + # would be converted to a pandas series and processed Ensure numpy + # versions match to avoid unpickling problems. See internal issue + # b/347934471. + requirements.append(f"pandas=={pandas.__version__}") + requirements.append(f"pyarrow=={pyarrow.__version__}") + requirements.append(f"numpy=={numpy.__version__}") + + if not requirements: + return package_requirements + + if not package_requirements: + package_requirements = [] + for package in requirements: + if not _package_existed(package_requirements, package): + package_requirements.append(package) + + return sorted(package_requirements) + + +def clean_up_by_session_id( bqclient: bigquery.Client, gcfclient: functions_v2.FunctionServiceClient, dataset: bigquery.DatasetReference, @@ -145,7 +183,7 @@ def _clean_up_by_session_id( pass -def _get_hash(def_, package_requirements=None): +def get_hash(def_, package_requirements=None): "Get hash (32 digits alphanumeric) of a function." # There is a known cell-id sensitivity of the cloudpickle serialization in # notebooks https://github.com/cloudpipe/cloudpickle/issues/538. Because of @@ -185,50 +223,14 @@ def get_cloud_function_name(function_hash, session_id=None, uniq_suffix=None): return _GCF_FUNCTION_NAME_SEPERATOR.join(parts) -def get_remote_function_name(function_hash, session_id, uniq_suffix=None): - "Get a name for the remote function for the given user defined function." +def get_bigframes_function_name(function_hash, session_id, uniq_suffix=None): + "Get a name for the bigframes function for the given user defined function." parts = [_BIGFRAMES_FUNCTION_PREFIX, session_id, function_hash] if uniq_suffix: parts.append(uniq_suffix) return _BQ_FUNCTION_NAME_SEPERATOR.join(parts) -class IbisSignature(NamedTuple): - parameter_names: List[str] - input_types: List[Optional[ibis_dtypes.DataType]] - output_type: ibis_dtypes.DataType - output_type_override: Optional[ibis_dtypes.DataType] = None - - -def ibis_signature_from_python_signature( - signature: inspect.Signature, - input_types: Sequence[type], - output_type: type, -) -> IbisSignature: - - ibis_input_types: List[Optional[ibis_dtypes.DataType]] = [ - bigframes.core.compile.ibis_types.ibis_type_from_python_type(t) - for t in input_types - ] - - if typing.get_origin(output_type) is list: - ibis_output_type = ( - bigframes.core.compile.ibis_types.ibis_array_output_type_from_python_type( - output_type - ) - ) - else: - ibis_output_type = bigframes.core.compile.ibis_types.ibis_type_from_python_type( - output_type - ) - - return IbisSignature( - parameter_names=list(signature.parameters.keys()), - input_types=ibis_input_types, - output_type=ibis_output_type, - ) - - def get_python_output_type_from_bigframes_metadata( metadata_text: str, ) -> Optional[type]: @@ -244,7 +246,7 @@ def get_python_output_type_from_bigframes_metadata( for ( python_output_array_type - ) in bigframes.dtypes.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES: + ) in function_typing.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES: if python_output_array_type.__name__ == output_type: return list[python_output_array_type] # type: ignore @@ -261,7 +263,7 @@ def get_bigframes_metadata(*, python_output_type: Optional[type] = None) -> str: python_output_array_type = typing.get_args(python_output_type)[0] if ( python_output_array_type - in bigframes.dtypes.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES + in function_typing.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES ): inner_metadata[ "python_array_output_type" @@ -275,8 +277,64 @@ def get_bigframes_metadata(*, python_output_type: Optional[type] = None) -> str: get_python_output_type_from_bigframes_metadata(metadata_ser) != python_output_type ): - raise ValueError( - f"python_output_type {python_output_type} is not serializable." + raise bf_formatting.create_exception_with_feedback_link( + ValueError, f"python_output_type {python_output_type} is not serializable." ) return metadata_ser + + +def get_python_version(is_compat: bool = False) -> str: + # Cloud Run functions use the 'compat' format (e.g., python311, see more + # from https://cloud.google.com/functions/docs/runtime-support#python), + # while managed functions use the standard format (e.g., python-3.11). + major = sys.version_info.major + minor = sys.version_info.minor + return f"python{major}{minor}" if is_compat else f"python-{major}.{minor}" + + +def build_unnest_post_routine(py_list_type: type[list]): + sdk_type = function_typing.sdk_array_output_type_from_python_type(py_list_type) + assert sdk_type.array_element_type is not None + inner_sdk_type = sdk_type.array_element_type + result_dtype = function_typing.sdk_type_to_bf_type(inner_sdk_type) + + def post_process(input): + import bigframes.bigquery as bbq + + return bbq.json_extract_string_array(input, value_dtype=result_dtype) + + return post_process + + +def has_conflict_input_type( + signature: inspect.Signature, + input_types: Sequence[Any], +) -> bool: + """Checks if the parameters have any conflict with the input_types.""" + params = list(signature.parameters.values()) + + if len(params) != len(input_types): + return True + + # Check for conflicts type hints. + for i, param in enumerate(params): + if param.annotation is not inspect.Parameter.empty: + if param.annotation != input_types[i]: + return True + + # No conflicts were found after checking all parameters. + return False + + +def has_conflict_output_type( + signature: inspect.Signature, + output_type: Any, +) -> bool: + """Checks if the return type annotation conflicts with the output_type.""" + return_annotation = signature.return_annotation + + if return_annotation is inspect.Parameter.empty: + return False + + return return_annotation != output_type diff --git a/bigframes/functions/function.py b/bigframes/functions/function.py index ef2c81a953..242daf7525 100644 --- a/bigframes/functions/function.py +++ b/bigframes/functions/function.py @@ -14,31 +14,19 @@ from __future__ import annotations -import inspect import logging -import typing -from typing import cast, Optional, TYPE_CHECKING -import warnings - -import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes -import bigframes_vendored.ibis.expr.operations.udf as ibis_udf +from typing import Callable, cast, get_origin, Optional, TYPE_CHECKING if TYPE_CHECKING: from bigframes.session import Session + import bigframes.series -import bigframes_vendored.constants as constants import google.api_core.exceptions -import google.api_core.retry from google.cloud import bigquery -import google.iam.v1 - -import bigframes.core.compile.ibis_types -import bigframes.dtypes -import bigframes.exceptions as bfe -import bigframes.functions.function_template -from . import _function_session as bff_session -from . import _utils +import bigframes.formatting_helpers as bf_formatting +from bigframes.functions import _function_session as bff_session +from bigframes.functions import _utils, function_typing, udf_def logger = logging.getLogger(__name__) @@ -49,51 +37,6 @@ def __init__(self, type_, supported_types): self.supported_types = supported_types -class ReturnTypeMissingError(ValueError): - pass - - -# TODO: Move this to compile folder -def ibis_signature_from_routine(routine: bigquery.Routine) -> _utils.IbisSignature: - if routine.return_type: - ibis_output_type = bigframes.core.compile.ibis_types.ibis_type_from_type_kind( - routine.return_type.type_kind - ) - else: - raise ReturnTypeMissingError - - ibis_output_type_override: Optional[ibis_dtypes.DataType] = None - if python_output_type := _utils.get_python_output_type_from_bigframes_metadata( - routine.description - ): - if not isinstance(ibis_output_type, ibis_dtypes.String): - raise TypeError( - "An explicit output_type should be provided only for a BigQuery function with STRING output." - ) - if typing.get_origin(python_output_type) is list: - ibis_output_type_override = bigframes.core.compile.ibis_types.ibis_array_output_type_from_python_type( - cast(type, python_output_type) - ) - else: - raise TypeError( - "Currently only list of a type is supported as python output type." - ) - - return _utils.IbisSignature( - parameter_names=[arg.name for arg in routine.arguments], - input_types=[ - bigframes.core.compile.ibis_types.ibis_type_from_type_kind( - arg.data_type.type_kind - ) - if arg.data_type - else None - for arg in routine.arguments - ], - output_type=ibis_output_type, - output_type_override=ibis_output_type_override, - ) - - class DatasetMissingError(ValueError): pass @@ -120,13 +63,94 @@ def get_routine_reference( def remote_function(*args, **kwargs): - remote_function_session = bff_session.FunctionSession() - return remote_function_session.remote_function(*args, **kwargs) + function_session = bff_session.FunctionSession() + return function_session.remote_function(*args, **kwargs) remote_function.__doc__ = bff_session.FunctionSession.remote_function.__doc__ +def udf(*args, **kwargs): + function_session = bff_session.FunctionSession() + return function_session.udf(*args, **kwargs) + + +udf.__doc__ = bff_session.FunctionSession.udf.__doc__ + + +def _try_import_routine( + routine: bigquery.Routine, session: bigframes.Session +) -> BigqueryCallableRoutine: + udf_def = _routine_as_udf_def(routine) + override_type = _get_output_type_override(routine) + is_remote = ( + hasattr(routine, "remote_function_options") and routine.remote_function_options + ) + if override_type is not None: + return BigqueryCallableRoutine( + udf_def, + session, + post_routine=_utils.build_unnest_post_routine(override_type), + ) + return BigqueryCallableRoutine(udf_def, session, is_managed=not is_remote) + + +def _try_import_row_routine( + routine: bigquery.Routine, session: bigframes.Session +) -> BigqueryCallableRowRoutine: + udf_def = _routine_as_udf_def(routine) + override_type = _get_output_type_override(routine) + is_remote = ( + hasattr(routine, "remote_function_options") and routine.remote_function_options + ) + if override_type is not None: + return BigqueryCallableRowRoutine( + udf_def, + session, + post_routine=_utils.build_unnest_post_routine(override_type), + ) + return BigqueryCallableRowRoutine(udf_def, session, is_managed=not is_remote) + + +def _routine_as_udf_def(routine: bigquery.Routine) -> udf_def.BigqueryUdf: + try: + return udf_def.BigqueryUdf.from_routine(routine) + except udf_def.ReturnTypeMissingError: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, "Function return type must be specified." + ) + except function_typing.UnsupportedTypeError as e: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + f"Type {e.type} not supported, supported types are {e.supported_types}.", + ) + + +def _get_output_type_override(routine: bigquery.Routine) -> Optional[type[list]]: + if routine.description is not None and isinstance(routine.description, str): + if python_output_type := _utils.get_python_output_type_from_bigframes_metadata( + routine.description + ): + bq_return_type = cast(bigquery.StandardSqlDataType, routine.return_type) + + if bq_return_type is None or bq_return_type.type_kind != "STRING": + raise bf_formatting.create_exception_with_feedback_link( + TypeError, + "An explicit output_type should be provided only for a BigQuery function with STRING output.", + ) + if get_origin(python_output_type) is list: + return python_output_type + else: + raise bf_formatting.create_exception_with_feedback_link( + TypeError, + "Currently only list of " + "a type is supported as python output type.", + ) + + return None + + +# TODO(b/399894805): Support managed function. def read_gbq_function( function_name: str, *, @@ -137,102 +161,192 @@ def read_gbq_function( Read an existing BigQuery function and prepare it for use in future queries. """ bigquery_client = session.bqclient - ibis_client = session.ibis_client try: routine_ref = get_routine_reference(function_name, bigquery_client, session) except DatasetMissingError: - raise ValueError( - "Project and dataset must be provided, either directly or via session. " - f"{constants.FEEDBACK_LINK}" + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "Project and dataset must be provided, either directly or via session.", ) # Find the routine and get its arguments. try: routine = bigquery_client.get_routine(routine_ref) except google.api_core.exceptions.NotFound: - raise ValueError(f"Unknown function '{routine_ref}'. {constants.FEEDBACK_LINK}") - - if is_row_processor and len(routine.arguments) > 1: - raise ValueError( - "A multi-input function cannot be a row processor. A row processor function " - "takes in a single input representing the row." + raise bf_formatting.create_exception_with_feedback_link( + ValueError, f"Unknown function '{routine_ref}'." ) - try: - ibis_signature = ibis_signature_from_routine(routine) - except ReturnTypeMissingError: - raise ValueError( - f"Function return type must be specified. {constants.FEEDBACK_LINK}" - ) - except bigframes.core.compile.ibis_types.UnsupportedTypeError as e: - raise ValueError( - f"Type {e.type} not supported, supported types are {e.supported_types}. " - f"{constants.FEEDBACK_LINK}" - ) + if is_row_processor: + return _try_import_row_routine(routine, session) + else: + return _try_import_routine(routine, session) - # The name "args" conflicts with the Ibis operator, so we use - # non-standard names for the arguments here. - def func(*bigframes_args, **bigframes_kwargs): - f"""Bigframes function {str(routine_ref)}.""" - nonlocal node # type: ignore - - expr = node(*bigframes_args, **bigframes_kwargs) # type: ignore - return ibis_client.execute(expr) - - func.__signature__ = inspect.signature(func).replace( # type: ignore - parameters=[ - # TODO(shobs): Find a better way to support functions with param - # named "name". This causes an issue in the ibis compilation. - inspect.Parameter( - f"bigframes_{name}", - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - for name in ibis_signature.parameter_names - ] - ) - # TODO: Move ibis logic to compiler step - - func.__name__ = routine_ref.routine_id - - node = ibis_udf.scalar.builtin( - func, - name=routine_ref.routine_id, - catalog=routine_ref.project, - database=routine_ref.dataset_id, - signature=(ibis_signature.input_types, ibis_signature.output_type), - ) # type: ignore - func.bigframes_remote_function = str(routine_ref) # type: ignore - - # set input bigframes data types - has_unknown_dtypes = False - function_input_dtypes = [] - for ibis_type in ibis_signature.input_types: - input_dtype = cast(bigframes.dtypes.Dtype, bigframes.dtypes.DEFAULT_DTYPE) - if ibis_type is None: - has_unknown_dtypes = True - else: - input_dtype = ( - bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( - ibis_type - ) - ) - function_input_dtypes.append(input_dtype) - if has_unknown_dtypes: - msg = ( - "The function has one or more missing input data types. BigQuery DataFrames " - f"will assume default data type {bigframes.dtypes.DEFAULT_DTYPE} for them." - ) - warnings.warn(msg, category=bfe.UnknownDataTypeWarning) - func.input_dtypes = tuple(function_input_dtypes) # type: ignore +class BigqueryCallableRoutine: + """ + A reference to a routine in the context of a session. - func.output_dtype = bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( # type: ignore - ibis_signature.output_type_override - if ibis_signature.output_type_override - else ibis_signature.output_type - ) + Can be used both directly as a callable, or as an input to dataframe ops that take a callable. + """ - func.is_row_processor = is_row_processor # type: ignore - func.ibis_node = node # type: ignore - return func + def __init__( + self, + udf_def: udf_def.BigqueryUdf, + session: bigframes.Session, + *, + local_func: Optional[Callable] = None, + cloud_function_ref: Optional[str] = None, + post_routine: Optional[ + Callable[[bigframes.series.Series], bigframes.series.Series] + ] = None, + is_managed: bool = False, + ): + self._udf_def = udf_def + self._session = session + self._post_routine = post_routine + self._local_fun = local_func + self._cloud_function = cloud_function_ref + self._is_managed = is_managed + + def __call__(self, *args, **kwargs): + if self._local_fun: + return self._local_fun(*args, **kwargs) + # avoid circular imports + import bigframes.core.sql as bf_sql + import bigframes.session._io.bigquery as bf_io_bigquery + + args_string = ", ".join(map(bf_sql.simple_literal, args)) + sql = f"SELECT `{str(self._udf_def.routine_ref)}`({args_string})" + iter, job = bf_io_bigquery.start_query_with_client( + self._session.bqclient, + sql=sql, + query_with_job=True, + job_config=bigquery.QueryJobConfig(), + publisher=self._session._publisher, + ) # type: ignore + return list(iter.to_arrow().to_pydict().values())[0][0] + + @property + def bigframes_bigquery_function(self) -> str: + return str(self._udf_def.routine_ref) + + @property + def bigframes_remote_function(self): + return None if self._is_managed else str(self._udf_def.routine_ref) + + @property + def is_row_processor(self) -> bool: + return False + + @property + def udf_def(self) -> udf_def.BigqueryUdf: + return self._udf_def + + @property + def bigframes_cloud_function(self) -> Optional[str]: + return self._cloud_function + + @property + def input_dtypes(self): + return self.udf_def.signature.bf_input_types + + @property + def output_dtype(self): + return self.udf_def.signature.bf_output_type + + @property + def bigframes_bigquery_function_output_dtype(self): + return self.output_dtype + + def _post_process_series( + self, series: bigframes.series.Series + ) -> bigframes.series.Series: + if self._post_routine is not None: + return self._post_routine(series) + return series + + +class BigqueryCallableRowRoutine: + """ + A reference to a routine in the context of a session. + + Can be used both directly as a callable, or as an input to dataframe ops that take a callable. + """ + + def __init__( + self, + udf_def: udf_def.BigqueryUdf, + session: bigframes.Session, + *, + local_func: Optional[Callable] = None, + cloud_function_ref: Optional[str] = None, + post_routine: Optional[ + Callable[[bigframes.series.Series], bigframes.series.Series] + ] = None, + is_managed: bool = False, + ): + self._udf_def = udf_def + self._session = session + self._post_routine = post_routine + self._local_fun = local_func + self._cloud_function = cloud_function_ref + self._is_managed = is_managed + + def __call__(self, *args, **kwargs): + if self._local_fun: + return self._local_fun(*args, **kwargs) + # avoid circular imports + import bigframes.core.sql as bf_sql + import bigframes.session._io.bigquery as bf_io_bigquery + + args_string = ", ".join(map(bf_sql.simple_literal, args)) + sql = f"SELECT `{str(self._udf_def.routine_ref)}`({args_string})" + iter, job = bf_io_bigquery.start_query_with_client( + self._session.bqclient, + sql=sql, + query_with_job=True, + job_config=bigquery.QueryJobConfig(), + publisher=self._session._publisher, + ) # type: ignore + return list(iter.to_arrow().to_pydict().values())[0][0] + + @property + def bigframes_bigquery_function(self) -> str: + return str(self._udf_def.routine_ref) + + @property + def bigframes_remote_function(self): + return None if self._is_managed else str(self._udf_def.routine_ref) + + @property + def is_row_processor(self) -> bool: + return True + + @property + def udf_def(self) -> udf_def.BigqueryUdf: + return self._udf_def + + @property + def bigframes_cloud_function(self) -> Optional[str]: + return self._cloud_function + + @property + def input_dtypes(self): + return self.udf_def.signature.bf_input_types + + @property + def output_dtype(self): + return self.udf_def.signature.bf_output_type + + @property + def bigframes_bigquery_function_output_dtype(self): + return self.output_dtype + + def _post_process_series( + self, series: bigframes.series.Series + ) -> bigframes.series.Series: + if self._post_routine is not None: + return self._post_routine(series) + return series diff --git a/bigframes/functions/function_template.py b/bigframes/functions/function_template.py index 0809baf5cc..a3680a7a88 100644 --- a/bigframes/functions/function_template.py +++ b/bigframes/functions/function_template.py @@ -17,6 +17,7 @@ import inspect import logging import os +import re import textwrap from typing import Tuple @@ -194,7 +195,9 @@ def udf_http_row_processor(request): calls = request_json["calls"] replies = [] for call in calls: - reply = convert_to_bq_json(output_type, udf(get_pd_series(call[0]))) + reply = convert_to_bq_json( + output_type, udf(get_pd_series(call[0]), *call[1:]) + ) if type(reply) is list: # Since the BQ remote function does not support array yet, # return a json serialized version of the reply. @@ -291,3 +294,85 @@ def generate_cloud_function_main_code( logger.debug(f"Wrote {os.path.abspath(main_py)}:\n{open(main_py).read()}") return handler_func_name + + +def generate_managed_function_code( + def_, + udf_name: str, + is_row_processor: bool, + capture_references: bool, +) -> str: + """Generates the Python code block for managed Python UDF.""" + + if capture_references: + # This code path ensures that if the udf body contains any + # references to variables and/or imports outside the body, they are + # captured as well. + import cloudpickle + + pickled = cloudpickle.dumps(def_) + func_code = textwrap.dedent( + f""" + import cloudpickle + {udf_name} = cloudpickle.loads({pickled}) + """ + ) + else: + # This code path ensures that if the udf body is self contained, + # i.e. there are no references to variables or imports outside the + # body. + func_code = textwrap.dedent(inspect.getsource(def_)) + match = re.search(r"^def ", func_code, flags=re.MULTILINE) + if match is None: + raise ValueError("The UDF is not defined correctly.") + func_code = func_code[match.start() :] + + if is_row_processor: + udf_code = textwrap.dedent(inspect.getsource(get_pd_series)) + udf_code = udf_code[udf_code.index("def") :] + bigframes_handler_code = textwrap.dedent( + f"""def bigframes_handler(str_arg): + return {udf_name}({get_pd_series.__name__}(str_arg))""" + ) + + sig = inspect.signature(def_) + params = list(sig.parameters.values()) + additional_params = params[1:] + + # Build the parameter list for the new handler function definition. + # e.g., "str_arg, y: bool, z" + handler_def_parts = ["str_arg"] + handler_def_parts.extend(str(p) for p in additional_params) + handler_def_str = ", ".join(handler_def_parts) + + # Build the argument list for the call to the original UDF. + # e.g., "get_pd_series(str_arg), y, z" + udf_call_parts = [f"{get_pd_series.__name__}(str_arg)"] + udf_call_parts.extend(p.name for p in additional_params) + udf_call_str = ", ".join(udf_call_parts) + + bigframes_handler_code = textwrap.dedent( + f"""def bigframes_handler({handler_def_str}): + return {udf_name}({udf_call_str})""" + ) + + else: + udf_code = "" + bigframes_handler_code = textwrap.dedent( + f"""def bigframes_handler(*args): + return {udf_name}(*args)""" + ) + + udf_code_block = [] + if not capture_references and is_row_processor: + # Enable postponed evaluation of type annotations. This converts all + # type hints to strings at runtime, which is necessary for correctly + # handling the type annotation of pandas.Series after the UDF code is + # serialized for remote execution. See more from b/445182819. + udf_code_block.append("from __future__ import annotations") + + udf_code_block.append(udf_code) + udf_code_block.append(func_code) + udf_code_block.append(bigframes_handler_code) + + return textwrap.dedent("\n".join(udf_code_block)) diff --git a/bigframes/functions/function_typing.py b/bigframes/functions/function_typing.py new file mode 100644 index 0000000000..30804f317c --- /dev/null +++ b/bigframes/functions/function_typing.py @@ -0,0 +1,137 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, get_args, get_origin, Type + +from google.cloud import bigquery + +import bigframes.dtypes + +# Input and output types supported by BigQuery DataFrames remote functions. +# TODO(shobs): Extend the support to all types supported by BQ remote functions +# https://cloud.google.com/bigquery/docs/remote-functions#limitations +RF_SUPPORTED_IO_PYTHON_TYPES = { + bool: bigquery.StandardSqlDataType(type_kind=bigquery.StandardSqlTypeNames.BOOL), + bytes: bigquery.StandardSqlDataType(type_kind=bigquery.StandardSqlTypeNames.BYTES), + float: bigquery.StandardSqlDataType( + type_kind=bigquery.StandardSqlTypeNames.FLOAT64 + ), + int: bigquery.StandardSqlDataType(type_kind=bigquery.StandardSqlTypeNames.INT64), + str: bigquery.StandardSqlDataType(type_kind=bigquery.StandardSqlTypeNames.STRING), +} + +# Support array output types in BigQuery DataFrames remote functions even though +# it is not currently (2024-10-06) supported in BigQuery remote functions. +# https://cloud.google.com/bigquery/docs/remote-functions#limitations +# TODO(b/284515241): remove this special handling when BigQuery remote functions +# support array. +RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES = {bool, float, int, str} + +DEFAULT_RF_TYPE = RF_SUPPORTED_IO_PYTHON_TYPES[float] + +RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS = { + "BOOLEAN", + "BOOL", + "BYTES", + "FLOAT", + "FLOAT64", + "INT64", + "INTEGER", + "STRING", + "ARRAY", +} + + +TIMEDELTA_DESCRIPTION_TAG = "#microseconds" + + +class UnsupportedTypeError(ValueError): + def __init__(self, type_, supported_types): + self.type = type_ + self.supported_types = supported_types + + types_to_format = supported_types + if isinstance(supported_types, dict): + types_to_format = supported_types.keys() + + supported_types_str = ", ".join( + sorted( + [ + getattr(supported, "__name__", supported) + for supported in types_to_format + ] + ) + ) + + super().__init__( + f"'{getattr(type_, '__name__', type_)}' must be one of the supported types ({supported_types_str}) " + "or a list of one of those types." + ) + + +def sdk_type_from_python_type( + t: type, allow_lists: bool = False +) -> bigquery.StandardSqlDataType: + if (get_origin(t) is list) and allow_lists: + return sdk_array_output_type_from_python_type(t) + if t not in RF_SUPPORTED_IO_PYTHON_TYPES: + raise UnsupportedTypeError(t, RF_SUPPORTED_IO_PYTHON_TYPES) + return RF_SUPPORTED_IO_PYTHON_TYPES[t] + + +def sdk_array_output_type_from_python_type(t: type) -> bigquery.StandardSqlDataType: + array_of = get_args(t)[0] + if array_of not in RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES: + raise UnsupportedTypeError(array_of, RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES) + inner_type = RF_SUPPORTED_IO_PYTHON_TYPES[array_of] + return bigquery.StandardSqlDataType( + type_kind=bigquery.StandardSqlTypeNames.ARRAY, array_element_type=inner_type + ) + + +def sdk_type_to_bf_type( + sdk_type: bigquery.StandardSqlDataType, +) -> bigframes.dtypes.Dtype: + if sdk_type.array_element_type is not None: + return bigframes.dtypes.list_type( + sdk_type_to_bf_type(sdk_type.array_element_type) + ) + if sdk_type.struct_type is not None: + raise ValueError("Cannot handle struct types in remote function") + assert sdk_type.type_kind is not None + return bigframes.dtypes._TK_TO_BIGFRAMES[sdk_type.type_kind.name] + + +def sdk_type_to_py_type( + sdk_type: bigquery.StandardSqlDataType, +) -> Type[Any]: + if sdk_type.array_element_type is not None: + return list[sdk_type_to_py_type(sdk_type.array_element_type)] # type: ignore + if sdk_type.struct_type is not None: + raise ValueError("Cannot handle struct types in remote function") + for key, value in RF_SUPPORTED_IO_PYTHON_TYPES.items(): + if value == sdk_type: + return key + raise ValueError(f"Cannot handle {sdk_type} in remote function") + + +def sdk_type_to_sql_string( + sdk_type: bigquery.StandardSqlDataType, +) -> str: + if sdk_type.array_element_type is not None: + return f"ARRAY<{sdk_type_to_sql_string(sdk_type.array_element_type)}>" + if sdk_type.struct_type is not None: + raise ValueError("Cannot handle struct types in remote function") + assert sdk_type.type_kind is not None + return sdk_type.type_kind.name diff --git a/bigframes/functions/udf_def.py b/bigframes/functions/udf_def.py new file mode 100644 index 0000000000..078e45f32d --- /dev/null +++ b/bigframes/functions/udf_def.py @@ -0,0 +1,173 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import dataclasses +import inspect +from typing import cast, Optional +import warnings + +from google.cloud import bigquery + +import bigframes.dtypes +import bigframes.exceptions as bfe +import bigframes.formatting_helpers as bf_formatting +from bigframes.functions import function_typing + + +class ReturnTypeMissingError(ValueError): + pass + + +@dataclasses.dataclass(frozen=True) +class UdfField: + name: str = dataclasses.field() + dtype: bigquery.StandardSqlDataType = dataclasses.field(hash=False, compare=False) + + @classmethod + def from_sdk(cls, arg: bigquery.RoutineArgument) -> UdfField: + assert arg.name is not None + assert arg.data_type is not None + return cls(arg.name, arg.data_type) + + +@dataclasses.dataclass(frozen=True) +class UdfSignature: + input_types: tuple[UdfField, ...] = dataclasses.field() + output_bq_type: bigquery.StandardSqlDataType = dataclasses.field( + hash=False, compare=False + ) + + @property + def bf_input_types(self) -> tuple[bigframes.dtypes.Dtype, ...]: + return tuple( + function_typing.sdk_type_to_bf_type(arg.dtype) for arg in self.input_types + ) + + @property + def bf_output_type(self) -> bigframes.dtypes.Dtype: + return function_typing.sdk_type_to_bf_type(self.output_bq_type) + + @property + def py_input_types(self) -> tuple[type, ...]: + return tuple( + function_typing.sdk_type_to_py_type(arg.dtype) for arg in self.input_types + ) + + @property + def py_output_type(self) -> type: + return function_typing.sdk_type_to_py_type(self.output_bq_type) + + @property + def sql_input_types(self) -> tuple[str, ...]: + return tuple( + function_typing.sdk_type_to_sql_string(arg.dtype) + for arg in self.input_types + ) + + @property + def sql_output_type(self) -> str: + return function_typing.sdk_type_to_sql_string(self.output_bq_type) + + @classmethod + def from_routine(cls, routine: bigquery.Routine) -> UdfSignature: + if routine.return_type is None: + raise ReturnTypeMissingError + bq_return_type = cast(bigquery.StandardSqlDataType, routine.return_type) + + if ( + bq_return_type.type_kind is None + or bq_return_type.type_kind + not in function_typing.RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS + ): + raise ValueError( + f"Remote function must have one of the following supported output types: {function_typing.RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS}" + ) + + udf_fields = [] + for argument in routine.arguments: + if argument.data_type is None: + msg = bfe.format_message( + "The function has one or more missing input data types. BigQuery DataFrames " + f"will assume default data type {function_typing.DEFAULT_RF_TYPE} for them." + ) + warnings.warn(msg, category=bfe.UnknownDataTypeWarning) + assert argument.name is not None + udf_fields.append( + UdfField(argument.name, function_typing.DEFAULT_RF_TYPE) + ) + else: + udf_fields.append(UdfField.from_sdk(argument)) + + return cls( + input_types=tuple(udf_fields), + output_bq_type=bq_return_type, + ) + + @classmethod + def from_py_signature(cls, signature: inspect.Signature): + input_types: list[UdfField] = [] + for parameter in signature.parameters.values(): + if parameter.annotation is inspect.Signature.empty: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "'input_types' was not set and parameter " + f"'{parameter.name}' is missing a type annotation. " + "Types are required to use @remote_function.", + ) + bq_type = function_typing.sdk_type_from_python_type(parameter.annotation) + input_types.append(UdfField(parameter.name, bq_type)) + + if signature.return_annotation is inspect.Signature.empty: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "'output_type' was not set and function is missing a " + "return type annotation. Types are required to use " + "@remote_function.", + ) + output_bq_type = function_typing.sdk_type_from_python_type( + signature.return_annotation, + allow_lists=True, + ) + return cls(tuple(input_types), output_bq_type) + + +@dataclasses.dataclass(frozen=True) +class BigqueryUdf: + routine_ref: bigquery.RoutineReference = dataclasses.field() + signature: UdfSignature + # Used to provide alternative interpretations of output bq type, eg interpret int as timestamp + output_type_override: Optional[bigframes.dtypes.Dtype] = dataclasses.field( + default=None + ) + + @property + def bigframes_output_type(self) -> bigframes.dtypes.Dtype: + return self.output_type_override or function_typing.sdk_type_to_bf_type( + self.signature.output_bq_type + ) + + @classmethod + def from_routine(cls, routine: bigquery.Routine) -> BigqueryUdf: + signature = UdfSignature.from_routine(routine) + + if ( + signature.output_bq_type.type_kind is None + or signature.output_bq_type.type_kind + not in function_typing.RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS + ): + raise ValueError( + f"Remote function must have one of the following supported output types: {function_typing.RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS}" + ) + return cls(routine.reference, signature=signature) diff --git a/bigframes/geopandas/geoseries.py b/bigframes/geopandas/geoseries.py index ce9a59f26a..660f1939a9 100644 --- a/bigframes/geopandas/geoseries.py +++ b/bigframes/geopandas/geoseries.py @@ -13,13 +13,15 @@ # limitations under the License. from __future__ import annotations +from typing import Optional + import bigframes_vendored.constants as constants import bigframes_vendored.geopandas.geoseries as vendored_geoseries import geopandas.array # type: ignore -import bigframes.geopandas import bigframes.operations as ops import bigframes.series +import bigframes.session class GeoSeries(vendored_geoseries.GeoSeries, bigframes.series.Series): @@ -30,6 +32,12 @@ def __init__(self, data=None, index=None, **kwargs): data=data, index=index, dtype=geopandas.array.GeometryDtype(), **kwargs ) + @property + def length(self): + raise NotImplementedError( + "GeoSeries.length is not yet implemented. Please use bigframes.bigquery.st_length(geoseries) instead." + ) + @property def x(self) -> bigframes.series.Series: series = self._apply_unary_op(ops.geo_x_op) @@ -47,30 +55,34 @@ def y(self) -> bigframes.series.Series: # we can. @property def area(self, crs=None) -> bigframes.series.Series: # type: ignore - """Returns a Series containing the area of each geometry in the GeoSeries - expressed in the units of the CRS. - - Args: - crs (optional): - Coordinate Reference System of the geometry objects. Can be - anything accepted by pyproj.CRS.from_user_input(), such as an - authority string (eg “EPSG:4326”) or a WKT string. - - Returns: - bigframes.pandas.Series: - Series of float representing the areas. - - Raises: - NotImplementedError: - GeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), insetead. - """ raise NotImplementedError( f"GeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), instead. {constants.FEEDBACK_LINK}" ) + @property + def boundary(self) -> bigframes.series.Series: # type: ignore + series = self._apply_unary_op(ops.geo_st_boundary_op) + series.name = None + return series + + @property + def is_closed(self) -> bigframes.series.Series: + # TODO(tswast): GeoPandas doesn't treat Point as closed. Use ST_LENGTH + # when available to filter out "closed" shapes that return false in + # GeoPandas. + raise NotImplementedError( + f"GeoSeries.is_closed is not supported. Use bigframes.bigquery.st_isclosed(series), instead. {constants.FEEDBACK_LINK}" + ) + @classmethod - def from_wkt(cls, data, index=None) -> GeoSeries: - series = bigframes.series.Series(data, index=index) + def from_wkt( + cls, + data, + index=None, + *, + session: Optional[bigframes.session.Session] = None, + ) -> GeoSeries: + series = bigframes.series.Series(data, index=index, session=session) return cls(series._apply_unary_op(ops.geo_st_geogfromtext_op)) @@ -87,3 +99,32 @@ def to_wkt(self: GeoSeries) -> bigframes.series.Series: series = self._apply_unary_op(ops.geo_st_astext_op) series.name = None return series + + def buffer(self: GeoSeries, distance: float) -> bigframes.series.Series: # type: ignore + raise NotImplementedError( + f"GeoSeries.buffer is not supported. Use bigframes.bigquery.st_buffer(series, distance), instead. {constants.FEEDBACK_LINK}" + ) + + @property + def centroid(self: GeoSeries) -> bigframes.series.Series: # type: ignore + return self._apply_unary_op(ops.geo_st_centroid_op) + + @property + def convex_hull(self: GeoSeries) -> bigframes.series.Series: # type: ignore + return self._apply_unary_op(ops.geo_st_convexhull_op) + + def difference(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: # type: ignore + return self._apply_binary_op(other, ops.geo_st_difference_op) + + def distance(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: # type: ignore + raise NotImplementedError( + f"GeoSeries.distance is not supported. Use bigframes.bigquery.st_distance(series, other), instead. {constants.FEEDBACK_LINK}" + ) + + def intersection(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: # type: ignore + return self._apply_binary_op(other, ops.geo_st_intersection_op) + + def simplify(self, tolerance, preserve_topology=True): + raise NotImplementedError( + f"GeoSeries.simplify is not supported. Use bigframes.bigquery.st_simplify(series, tolerance_meters), instead. {constants.FEEDBACK_LINK}" + ) diff --git a/bigframes/ml/__init__.py b/bigframes/ml/__init__.py index b2c62ff961..368d272e7b 100644 --- a/bigframes/ml/__init__.py +++ b/bigframes/ml/__init__.py @@ -12,19 +12,82 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""BigQuery DataFrames ML provides a SKLearn-like API on the BigQuery engine.""" +"""BigQuery DataFrames ML provides a SKLearn-like API on the BigQuery engine. + +.. code:: python + + from bigframes.ml.linear_model import LinearRegression + model = LinearRegression() + model.fit(feature_columns, label_columns) + model.predict(feature_columns_from_test_data) + +You can also save your fit parameters to BigQuery for later use. + +.. code:: python + + import bigframes.pandas as bpd + model.to_gbq( + your_model_id, # For example: "bqml_tutorial.penguins_model" + replace=True, + ) + saved_model = bpd.read_gbq_model(your_model_id) + saved_model.predict(feature_columns_from_test_data) + +See the `BigQuery ML linear regression tutorial +`_ for a +detailed example. + +See also the references for ``bigframes.ml`` sub-modules: + +* :mod:`bigframes.ml.cluster` +* :mod:`bigframes.ml.compose` +* :mod:`bigframes.ml.decomposition` +* :mod:`bigframes.ml.ensemble` +* :mod:`bigframes.ml.forecasting` +* :mod:`bigframes.ml.imported` +* :mod:`bigframes.ml.impute` +* :mod:`bigframes.ml.linear_model` +* :mod:`bigframes.ml.llm` +* :mod:`bigframes.ml.metrics` +* :mod:`bigframes.ml.model_selection` +* :mod:`bigframes.ml.pipeline` +* :mod:`bigframes.ml.preprocessing` +* :mod:`bigframes.ml.remote` + +Alternatively, check out mod:`bigframes.bigquery.ml` for an interface that is +more similar to the BigQuery ML SQL syntax. +""" + +from bigframes.ml import ( + cluster, + compose, + decomposition, + ensemble, + forecasting, + imported, + impute, + linear_model, + llm, + metrics, + model_selection, + pipeline, + preprocessing, + remote, +) __all__ = [ "cluster", "compose", "decomposition", + "ensemble", + "forecasting", + "imported", + "impute", "linear_model", + "llm", "metrics", "model_selection", "pipeline", "preprocessing", - "llm", - "forecasting", - "imported", "remote", ] diff --git a/bigframes/ml/base.py b/bigframes/ml/base.py index c353e47f3a..9b38702cce 100644 --- a/bigframes/ml/base.py +++ b/bigframes/ml/base.py @@ -15,18 +15,21 @@ """ Wraps primitives for machine learning with BQML -This library is an evolving attempt to -- implement BigQuery DataFrames API for BQML -- follow as close as possible the API design of SKLearn +This library is an evolving attempt to: + +* implement BigQuery DataFrames API for BQML +* follow as close as possible the API design of SKLearn https://arxiv.org/pdf/1309.0238.pdf + """ import abc -from typing import Callable, cast, Mapping, Optional, TypeVar +from typing import cast, Optional, TypeVar, Union import warnings import bigframes_vendored.sklearn.base +import bigframes.exceptions as bfe from bigframes.ml import core import bigframes.ml.utils as utils import bigframes.pandas as bpd @@ -45,12 +48,16 @@ class BaseEstimator(bigframes_vendored.sklearn.base.BaseEstimator, abc.ABC): assumed to be the list of hyperparameters. All descendents of this class should implement: + + .. code-block:: python + def __init__(self, hyperparameter_1=default_1, hyperparameter_2=default_2, hyperparameter3, ...): '''Set hyperparameters''' self.hyperparameter_1 = hyperparameter_1 self.hyperparameter_2 = hyperparameter_2 self.hyperparameter3 = hyperparameter3 ... + Note: the object variable names must be exactly the same with parameter names. In order to utilize __repr__. fit(X, y) method is optional. @@ -241,55 +248,47 @@ def fit( ) -> _T: return self._fit(X, y) + def fit_predict( + self: _T, + X: utils.ArrayType, + y: Optional[utils.ArrayType] = None, + ) -> _T: + return self.fit(X).predict(X) -class RetriableRemotePredictor(BaseEstimator): - @property - @abc.abstractmethod - def _predict_func(self) -> Callable[[bpd.DataFrame, Mapping], bpd.DataFrame]: - pass - - @property - @abc.abstractmethod - def _status_col(self) -> str: - pass +class RetriableRemotePredictor(BaseEstimator): def _predict_and_retry( - self, X: bpd.DataFrame, options: Mapping, max_retries: int + self, + bqml_model_predict_tvf: core.BqmlModel.TvfDef, + X: bpd.DataFrame, + options: dict, + max_retries: int, ) -> bpd.DataFrame: assert self._bqml_model is not None - df_result = bpd.DataFrame(session=self._bqml_model.session) # placeholder - df_fail = X - for _ in range(max_retries + 1): - df = self._predict_func(df_fail, options) + df_result: Union[bpd.DataFrame, None] = None # placeholder + df_succ = df_fail = X + for i in range(max_retries + 1): + if i > 0 and df_fail.empty: + break + if i > 0 and df_succ.empty: + msg = bfe.format_message("Can't make any progress, stop retrying.") + warnings.warn(msg, category=RuntimeWarning) + break + + df = bqml_model_predict_tvf.tvf(self._bqml_model, df_fail, options) - success = df[self._status_col].str.len() == 0 + success = df[bqml_model_predict_tvf.status_col].str.len() == 0 df_succ = df[success] df_fail = df[~success] - if df_succ.empty: - if max_retries > 0: - msg = "Can't make any progress, stop retrying." - warnings.warn(msg, category=RuntimeWarning) - break - df_result = ( - bpd.concat([df_result, df_succ]) if not df_result.empty else df_succ - ) - - if df_fail.empty: - break - - if not df_fail.empty: - msg = ( - f"Some predictions failed. Check column {self._status_col} for detailed " - "status. You may want to filter the failed rows and retry." + bpd.concat([df_result, df_succ]) if df_result is not None else df_succ ) - warnings.warn(msg, category=RuntimeWarning) df_result = cast( bpd.DataFrame, - bpd.concat([df_result, df_fail]) if not df_result.empty else df_fail, + bpd.concat([df_result, df_fail]) if df_result is not None else df_fail, ) return df_result diff --git a/bigframes/ml/cluster.py b/bigframes/ml/cluster.py index a03dc937dc..9ce4649c5e 100644 --- a/bigframes/ml/cluster.py +++ b/bigframes/ml/cluster.py @@ -59,7 +59,8 @@ def __init__( warm_start: bool = False, ): self.n_clusters = n_clusters - self.init = init + # allow the alias to be compatible with sklearn + self.init = "kmeans++" if init == "k-means++" else init self.init_col = init_col self.distance_type = distance_type self.max_iter = max_iter diff --git a/bigframes/ml/compose.py b/bigframes/ml/compose.py index 46d40d5fc8..54ce7066cb 100644 --- a/bigframes/ml/compose.py +++ b/bigframes/ml/compose.py @@ -29,6 +29,7 @@ from bigframes.core import log_adapter import bigframes.core.compile.googlesql as sql_utils +import bigframes.core.utils as core_utils from bigframes.ml import base, core, globals, impute, preprocessing, utils import bigframes.pandas as bpd @@ -68,7 +69,6 @@ class SQLScalarColumnTransformer: >>> from bigframes.ml.compose import ColumnTransformer, SQLScalarColumnTransformer >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'name': ["James", None, "Mary"], 'city': ["New York", "Boston", None]}) >>> col_trans = ColumnTransformer([ @@ -103,13 +103,12 @@ def __init__(self, sql: str, target_column: str = "transformed_{0}"): # TODO: More robust unescaping self._target_column = target_column.replace("`", "") - PLAIN_COLNAME_RX = re.compile("^[a-z][a-z0-9_]*$", re.IGNORECASE) - def _compile_to_sql( self, X: bpd.DataFrame, columns: Optional[Iterable[str]] = None ) -> List[str]: if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) result = [] for column in columns: current_sql = self._sql.format(sql_utils.identifier(column)) diff --git a/bigframes/ml/core.py b/bigframes/ml/core.py index ad00ed3f2c..4dbc1a5fa3 100644 --- a/bigframes/ml/core.py +++ b/bigframes/ml/core.py @@ -16,6 +16,7 @@ from __future__ import annotations +import dataclasses import datetime from typing import Callable, cast, Iterable, Mapping, Optional, Union import uuid @@ -34,7 +35,21 @@ class BaseBqml: def __init__(self, session: bigframes.session.Session): self._session = session - self._base_sql_generator = ml_sql.BaseSqlGenerator() + self._sql_generator = ml_sql.BaseSqlGenerator() + + def ai_forecast( + self, + input_data: bpd.DataFrame, + options: Mapping[str, Union[str, int, float, Iterable[str]]], + ) -> bpd.DataFrame: + result_sql = self._sql_generator.ai_forecast( + source_sql=input_data.sql, options=options + ) + + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(result_sql, allow_large_results=True) class BqmlModel(BaseBqml): @@ -44,13 +59,18 @@ class BqmlModel(BaseBqml): BigQuery DataFrames ML. """ + @dataclasses.dataclass + class TvfDef: + tvf: Callable[[BqmlModel, bpd.DataFrame, dict], bpd.DataFrame] + status_col: str + def __init__(self, session: bigframes.Session, model: bigquery.Model): self._session = session self._model = model model_ref = self._model.reference assert model_ref is not None - self._model_manipulation_sql_generator = ml_sql.ModelManipulationSqlGenerator( - model_ref + self._sql_generator: ml_sql.ModelManipulationSqlGenerator = ( + ml_sql.ModelManipulationSqlGenerator(model_ref) ) def _apply_ml_tvf( @@ -79,7 +99,17 @@ def _apply_ml_tvf( ) result_sql = apply_sql_tvf(input_sql) - df = self._session.read_gbq(result_sql, index_col=index_col_ids) + df = self._session.read_gbq_query( + result_sql, + index_col=index_col_ids, + # Many ML methods use nested JSON, which isn't yet compatible with + # joining local results. Also, there is a chance that the results + # are greater than 10 GB. + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + allow_large_results=True, + ) if df._has_index: df.index.names = index_labels # Restore column labels @@ -117,10 +147,16 @@ def model(self) -> bigquery.Model: """Get the BQML model associated with this wrapper""" return self._model + def recommend(self, input_data: bpd.DataFrame) -> bpd.DataFrame: + return self._apply_ml_tvf( + input_data, + self._sql_generator.ml_recommend, + ) + def predict(self, input_data: bpd.DataFrame) -> bpd.DataFrame: return self._apply_ml_tvf( input_data, - self._model_manipulation_sql_generator.ml_predict, + self._sql_generator.ml_predict, ) def explain_predict( @@ -128,44 +164,76 @@ def explain_predict( ) -> bpd.DataFrame: return self._apply_ml_tvf( input_data, - lambda source_sql: self._model_manipulation_sql_generator.ml_explain_predict( + lambda source_sql: self._sql_generator.ml_explain_predict( source_sql=source_sql, struct_options=options, ), ) + def global_explain(self, options: Mapping[str, bool]) -> bpd.DataFrame: + sql = self._sql_generator.ml_global_explain(struct_options=options) + return ( + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + self._session.read_gbq_query(sql, allow_large_results=True) + .sort_values(by="attribution", ascending=False) + .set_index("feature") + ) + def transform(self, input_data: bpd.DataFrame) -> bpd.DataFrame: return self._apply_ml_tvf( input_data, - self._model_manipulation_sql_generator.ml_transform, + self._sql_generator.ml_transform, ) def generate_text( self, input_data: bpd.DataFrame, - options: Mapping[str, int | float], + options: dict[str, Union[int, float, bool]], ) -> bpd.DataFrame: + options["flatten_json_output"] = True return self._apply_ml_tvf( input_data, - lambda source_sql: self._model_manipulation_sql_generator.ml_generate_text( + lambda source_sql: self._sql_generator.ml_generate_text( source_sql=source_sql, struct_options=options, ), ) + generate_text_tvf = TvfDef(generate_text, "ml_generate_text_status") + def generate_embedding( self, input_data: bpd.DataFrame, - options: Mapping[str, int | float], + options: dict[str, Union[int, float, bool]], + ) -> bpd.DataFrame: + options["flatten_json_output"] = True + return self._apply_ml_tvf( + input_data, + lambda source_sql: self._sql_generator.ml_generate_embedding( + source_sql=source_sql, + struct_options=options, + ), + ) + + generate_embedding_tvf = TvfDef(generate_embedding, "ml_generate_embedding_status") + + def generate_table( + self, + input_data: bpd.DataFrame, + options: dict[str, Union[int, float, bool, Mapping]], ) -> bpd.DataFrame: return self._apply_ml_tvf( input_data, - lambda source_sql: self._model_manipulation_sql_generator.ml_generate_embedding( + lambda source_sql: self._sql_generator.ai_generate_table( source_sql=source_sql, struct_options=options, ), ) + generate_table_tvf = TvfDef(generate_table, "status") + def detect_anomalies( self, input_data: bpd.DataFrame, options: Mapping[str, int | float] ) -> bpd.DataFrame: @@ -173,86 +241,123 @@ def detect_anomalies( return self._apply_ml_tvf( input_data, - lambda source_sql: self._model_manipulation_sql_generator.ml_detect_anomalies( + lambda source_sql: self._sql_generator.ml_detect_anomalies( source_sql=source_sql, struct_options=options, ), ) def forecast(self, options: Mapping[str, int | float]) -> bpd.DataFrame: - sql = self._model_manipulation_sql_generator.ml_forecast(struct_options=options) + sql = self._sql_generator.ml_forecast(struct_options=options) timestamp_col_name = "forecast_timestamp" index_cols = [timestamp_col_name] - first_col_name = self._session.read_gbq(sql).columns.values[0] + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + first_col_name = self._session.read_gbq_query( + sql, allow_large_results=True + ).columns.values[0] if timestamp_col_name != first_col_name: index_cols.append(first_col_name) - return self._session.read_gbq(sql, index_col=index_cols).reset_index() + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query( + sql, index_col=index_cols, allow_large_results=True + ).reset_index() def explain_forecast(self, options: Mapping[str, int | float]) -> bpd.DataFrame: - sql = self._model_manipulation_sql_generator.ml_explain_forecast( - struct_options=options - ) + sql = self._sql_generator.ml_explain_forecast(struct_options=options) timestamp_col_name = "time_series_timestamp" index_cols = [timestamp_col_name] - first_col_name = self._session.read_gbq(sql).columns.values[0] + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + first_col_name = self._session.read_gbq_query( + sql, allow_large_results=True + ).columns.values[0] if timestamp_col_name != first_col_name: index_cols.append(first_col_name) - return self._session.read_gbq(sql, index_col=index_cols).reset_index() + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query( + sql, index_col=index_cols, allow_large_results=True + ).reset_index() def evaluate(self, input_data: Optional[bpd.DataFrame] = None): - sql = self._model_manipulation_sql_generator.ml_evaluate( + sql = self._sql_generator.ml_evaluate( input_data.sql if (input_data is not None) else None ) - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def llm_evaluate( self, input_data: bpd.DataFrame, task_type: Optional[str] = None, ): - sql = self._model_manipulation_sql_generator.ml_llm_evaluate( - input_data.sql, task_type - ) + sql = self._sql_generator.ml_llm_evaluate(input_data.sql, task_type) - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def arima_evaluate(self, show_all_candidate_models: bool = False): - sql = self._model_manipulation_sql_generator.ml_arima_evaluate( - show_all_candidate_models - ) + sql = self._sql_generator.ml_arima_evaluate(show_all_candidate_models) - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def arima_coefficients(self) -> bpd.DataFrame: - sql = self._model_manipulation_sql_generator.ml_arima_coefficients() + sql = self._sql_generator.ml_arima_coefficients() - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def centroids(self) -> bpd.DataFrame: assert self._model.model_type == "KMEANS" - sql = self._model_manipulation_sql_generator.ml_centroids() + sql = self._sql_generator.ml_centroids() - return self._session.read_gbq( - sql, index_col=["centroid_id", "feature"] + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query( + sql, index_col=["centroid_id", "feature"], allow_large_results=True ).reset_index() def principal_components(self) -> bpd.DataFrame: assert self._model.model_type == "PCA" - sql = self._model_manipulation_sql_generator.ml_principal_components() + sql = self._sql_generator.ml_principal_components() - return self._session.read_gbq( - sql, index_col=["principal_component_id", "feature"] + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query( + sql, + index_col=["principal_component_id", "feature"], + allow_large_results=True, ).reset_index() def principal_component_info(self) -> bpd.DataFrame: assert self._model.model_type == "PCA" - sql = self._model_manipulation_sql_generator.ml_principal_component_info() + sql = self._sql_generator.ml_principal_component_info() - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def copy(self, new_model_name: str, replace: bool = False) -> BqmlModel: job_config = self._session._prepare_copy_job_config() @@ -276,7 +381,7 @@ def register(self, vertex_ai_model_id: Optional[str] = None) -> BqmlModel: # truncate as Vertex ID only accepts 63 characters, easily exceeding the limit for temp models. # The possibility of conflicts should be low. vertex_ai_model_id = vertex_ai_model_id[:63] - sql = self._model_manipulation_sql_generator.alter_model( + sql = self._sql_generator.alter_model( options={"vertex_ai_model_id": vertex_ai_model_id} ) # Register the model and wait it to finish @@ -331,8 +436,9 @@ def create_model( Returns: a BqmlModel, wrapping a trained model in BigQuery """ options = dict(options) - # Cache dataframes to make sure base table is not a snapshot - # cached dataframe creates a full copy, never uses snapshot + # Cache dataframes to make sure base table is not a snapshot. + # Cached dataframe creates a full copy, never uses snapshot. + # This is a workaround for internal issue b/310266666. if y_train is None: input_data = X_train.reset_index(drop=True).cache() else: diff --git a/bigframes/ml/decomposition.py b/bigframes/ml/decomposition.py index c98e18322a..3ff32d2433 100644 --- a/bigframes/ml/decomposition.py +++ b/bigframes/ml/decomposition.py @@ -19,6 +19,7 @@ from typing import List, Literal, Optional, Union +import bigframes_vendored.sklearn.decomposition._mf import bigframes_vendored.sklearn.decomposition._pca from google.cloud import bigquery @@ -27,7 +28,15 @@ import bigframes.pandas as bpd import bigframes.session -_BQML_PARAMS_MAPPING = {"svd_solver": "pcaSolver"} +_BQML_PARAMS_MAPPING = { + "svd_solver": "pcaSolver", + "feedback_type": "feedbackType", + "num_factors": "numFactors", + "user_col": "userColumn", + "item_col": "itemColumn", + "_input_label_columns": "inputLabelColumns", + "l2_reg": "l2Regularization", +} @log_adapter.class_logger @@ -197,3 +206,166 @@ def score( # TODO(b/291973741): X param is ignored. Update BQML supports input in ML.EVALUATE. return self._bqml_model.evaluate() + + +@log_adapter.class_logger +class MatrixFactorization( + base.UnsupervisedTrainablePredictor, + bigframes_vendored.sklearn.decomposition._mf.MatrixFactorization, +): + __doc__ = bigframes_vendored.sklearn.decomposition._mf.MatrixFactorization.__doc__ + + def __init__( + self, + *, + feedback_type: Literal["explicit", "implicit"] = "explicit", + num_factors: int, + user_col: str, + item_col: str, + rating_col: str = "rating", + # TODO: Add support for hyperparameter tuning. + l2_reg: float = 1.0, + ): + + feedback_type = feedback_type.lower() # type: ignore + if feedback_type not in ("explicit", "implicit"): + raise ValueError("Expected feedback_type to be `explicit` or `implicit`.") + + self.feedback_type = feedback_type + + if not isinstance(num_factors, int): + raise TypeError( + f"Expected num_factors to be an int, but got {type(num_factors)}." + ) + + if num_factors < 0: + raise ValueError( + f"Expected num_factors to be a positive integer, but got {num_factors}." + ) + + self.num_factors = num_factors + + if not isinstance(user_col, str): + raise TypeError(f"Expected user_col to be a str, but got {type(user_col)}.") + + self.user_col = user_col + + if not isinstance(item_col, str): + raise TypeError(f"Expected item_col to be STR, but got {type(item_col)}.") + + self.item_col = item_col + + if not isinstance(rating_col, str): + raise TypeError( + f"Expected rating_col to be a str, but got {type(rating_col)}." + ) + + self._input_label_columns = [rating_col] + + if not isinstance(l2_reg, (float, int)): + raise TypeError( + f"Expected l2_reg to be a float or int, but got {type(l2_reg)}." + ) + + self.l2_reg = l2_reg + self._bqml_model: Optional[core.BqmlModel] = None + self._bqml_model_factory = globals.bqml_model_factory() + + @property + def rating_col(self) -> str: + """str: The rating column name. Defaults to 'rating'.""" + return self._input_label_columns[0] + + @classmethod + def _from_bq( + cls, session: bigframes.session.Session, bq_model: bigquery.Model + ) -> MatrixFactorization: + assert bq_model.model_type == "MATRIX_FACTORIZATION" + + kwargs = utils.retrieve_params_from_bq_model( + cls, bq_model, _BQML_PARAMS_MAPPING + ) + + model = cls(**kwargs) + model._bqml_model = core.BqmlModel(session, bq_model) + return model + + @property + def _bqml_options(self) -> dict: + """The model options as they will be set for BQML""" + options: dict = { + "model_type": "matrix_factorization", + "feedback_type": self.feedback_type, + "user_col": self.user_col, + "item_col": self.item_col, + "rating_col": self.rating_col, + "l2_reg": self.l2_reg, + } + + if self.num_factors is not None: + options["num_factors"] = self.num_factors + + return options + + def _fit( + self, + X: utils.ArrayType, + y=None, + transforms: Optional[List[str]] = None, + ) -> MatrixFactorization: + if y is not None: + raise ValueError( + "Label column not supported for Matrix Factorization model but y was not `None`" + ) + + (X,) = utils.batch_convert_to_dataframe(X) + + self._bqml_model = self._bqml_model_factory.create_model( + X_train=X, + transforms=transforms, + options=self._bqml_options, + ) + return self + + def predict(self, X: utils.ArrayType) -> bpd.DataFrame: + if not self._bqml_model: + raise RuntimeError("A model must be fitted before recommend") + + (X,) = utils.batch_convert_to_dataframe(X, session=self._bqml_model.session) + + return self._bqml_model.recommend(X) + + def to_gbq(self, model_name: str, replace: bool = False) -> MatrixFactorization: + """Save the model to BigQuery. + + Args: + model_name (str): + The name of the model. + replace (bool, default False): + Determine whether to replace if the model already exists. Default to False. + + Returns: + MatrixFactorization: Saved model.""" + if not self._bqml_model: + raise RuntimeError("A model must be fitted before it can be saved") + + new_model = self._bqml_model.copy(model_name, replace) + return new_model.session.read_gbq_model(model_name) + + def score( + self, + X=None, + y=None, + ) -> bpd.DataFrame: + if not self._bqml_model: + raise RuntimeError("A model must be fitted before score") + + if X is not None and y is not None: + X, y = utils.batch_convert_to_dataframe( + X, y, session=self._bqml_model.session + ) + input_data = X.join(y, how="outer") + else: + input_data = X + + return self._bqml_model.evaluate(input_data) diff --git a/bigframes/ml/forecasting.py b/bigframes/ml/forecasting.py index 7aa8ba5a5f..d26abdfa71 100644 --- a/bigframes/ml/forecasting.py +++ b/bigframes/ml/forecasting.py @@ -36,6 +36,8 @@ "holiday_region": "holidayRegion", "clean_spikes_and_dips": "cleanSpikesAndDips", "adjust_step_changes": "adjustStepChanges", + "forecast_limit_upper_bound": "forecastLimitUpperBound", + "forecast_limit_lower_bound": "forecastLimitLowerBound", "time_series_length_fraction": "timeSeriesLengthFraction", "min_time_series_length": "minTimeSeriesLength", "max_time_series_length": "maxTimeSeriesLength", @@ -78,6 +80,17 @@ class ARIMAPlus(base.SupervisedTrainableWithIdColPredictor): adjust_step_changes (bool, default True): Determines whether or not to perform automatic step change detection and adjustment in the model training pipeline. + forecast_limit_upper_bound (float or None, default None): + The upper bound of the forecasting values. When you specify the ``forecast_limit_upper_bound`` option, all of the forecast values must be less than the specified value. + For example, if you set ``forecast_limit_upper_bound`` to 100, then all of the forecast values are less than 100. + Also, all values greater than or equal to the ``forecast_limit_upper_bound`` value are excluded from modelling. + The forecasting limit ensures that forecasts stay within limits. + + forecast_limit_lower_bound (float or None, default None): + The lower bound of the forecasting values where the minimum value allowed is 0. When you specify the ``forecast_limit_lower_bound`` option, all of the forecast values must be greater than the specified value. + For example, if you set ``forecast_limit_lower_bound`` to 0, then all of the forecast values are larger than 0. Also, all values less than or equal to the ``forecast_limit_lower_bound`` value are excluded from modelling. + The forecasting limit ensures that forecasts stay within limits. + time_series_length_fraction (float or None, default None): The fraction of the interpolated length of the time series that's used to model the time series trend component. All of the time points of the time series are used to model the non-trend component. @@ -106,6 +119,8 @@ def __init__( holiday_region: Optional[str] = None, clean_spikes_and_dips: bool = True, adjust_step_changes: bool = True, + forecast_limit_lower_bound: Optional[float] = None, + forecast_limit_upper_bound: Optional[float] = None, time_series_length_fraction: Optional[float] = None, min_time_series_length: Optional[int] = None, max_time_series_length: Optional[int] = None, @@ -121,6 +136,8 @@ def __init__( self.holiday_region = holiday_region self.clean_spikes_and_dips = clean_spikes_and_dips self.adjust_step_changes = adjust_step_changes + self.forecast_limit_upper_bound = forecast_limit_upper_bound + self.forecast_limit_lower_bound = forecast_limit_lower_bound self.time_series_length_fraction = time_series_length_fraction self.min_time_series_length = min_time_series_length self.max_time_series_length = max_time_series_length @@ -175,6 +192,10 @@ def _bqml_options(self) -> dict: if self.include_drift: options["include_drift"] = True + if self.forecast_limit_upper_bound is not None: + options["forecast_limit_upper_bound"] = self.forecast_limit_upper_bound + if self.forecast_limit_lower_bound is not None: + options["forecast_limit_lower_bound"] = self.forecast_limit_lower_bound return options @@ -190,7 +211,7 @@ def _fit( Args: X (bigframes.dataframe.DataFrame or bigframes.series.Series, or pandas.core.frame.DataFrame or pandas.core.series.Series): - A dataframe or series of trainging timestamp. + A dataframe or series of training timestamp. y (bigframes.dataframe.DataFrame, or bigframes.series.Series, or pandas.core.frame.DataFrame, or pandas.core.series.Series): Target values for training. diff --git a/bigframes/ml/globals.py b/bigframes/ml/globals.py index 44e9463727..62cfdbef72 100644 --- a/bigframes/ml/globals.py +++ b/bigframes/ml/globals.py @@ -19,7 +19,7 @@ _BASE_SQL_GENERATOR = sql.BaseSqlGenerator() _BQML_MODEL_FACTORY = core.BqmlModelFactory() -_SUPPORTED_DTYPES = ( +_REMOTE_MODEL_SUPPORTED_DTYPES = ( "bool", "string", "int64", diff --git a/bigframes/ml/imported.py b/bigframes/ml/imported.py index 93152a6b99..a73ee352d0 100644 --- a/bigframes/ml/imported.py +++ b/bigframes/ml/imported.py @@ -216,8 +216,8 @@ def __init__( self, model_path: str, *, - input: Mapping[str, str] = {}, - output: Mapping[str, str] = {}, + input: Optional[Mapping[str, str]] = None, + output: Optional[Mapping[str, str]] = None, session: Optional[bigframes.session.Session] = None, ): self.session = session or bpd.get_global_session() @@ -234,20 +234,23 @@ def _create_bqml_model(self): return self._bqml_model_factory.create_imported_model( session=self.session, options=options ) - else: - for io in (self.input, self.output): - for v in io.values(): - if v not in globals._SUPPORTED_DTYPES: - raise ValueError( - f"field_type {v} is not supported. We only support {', '.join(globals._SUPPORTED_DTYPES)}." - ) - - return self._bqml_model_factory.create_xgboost_imported_model( - session=self.session, - input=self.input, - output=self.output, - options=options, - ) + if not self.input or not self.output: + raise ValueError("input and output must both or neigher be set.") + self.input = { + k: utils.standardize_type(v, globals._REMOTE_MODEL_SUPPORTED_DTYPES) + for k, v in self.input.items() + } + self.output = { + k: utils.standardize_type(v, globals._REMOTE_MODEL_SUPPORTED_DTYPES) + for k, v in self.output.items() + } + + return self._bqml_model_factory.create_xgboost_imported_model( + session=self.session, + input=self.input, + output=self.output, + options=options, + ) @classmethod def _from_bq( diff --git a/bigframes/ml/impute.py b/bigframes/ml/impute.py index f19c8e2cd3..818151a4f9 100644 --- a/bigframes/ml/impute.py +++ b/bigframes/ml/impute.py @@ -23,6 +23,7 @@ import bigframes_vendored.sklearn.impute._base from bigframes.core import log_adapter +import bigframes.core.utils as core_utils from bigframes.ml import base, core, globals, utils import bigframes.pandas as bpd @@ -62,6 +63,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) return [ self._base_sql_generator.ml_imputer( column, self.strategy, f"imputer_{column}" diff --git a/bigframes/ml/linear_model.py b/bigframes/ml/linear_model.py index 46c5744a42..3774a62c0c 100644 --- a/bigframes/ml/linear_model.py +++ b/bigframes/ml/linear_model.py @@ -203,6 +203,26 @@ def predict_explain( X, options={"top_k_features": top_k_features} ) + def global_explain( + self, + ) -> bpd.DataFrame: + """ + Provide explanations for an entire linear regression model. + + .. note:: + Output matches that of the BigQuery ML.GLOBAL_EXPLAIN function. + See: https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-global-explain + + Returns: + bigframes.pandas.DataFrame: + Dataframes containing feature importance values and corresponding attributions, designed to provide a global explanation of feature influence. + """ + + if not self._bqml_model: + raise RuntimeError("A model must be fitted before predict") + + return self._bqml_model.global_explain({}) + def score( self, X: utils.ArrayType, diff --git a/bigframes/ml/llm.py b/bigframes/ml/llm.py index 72c49e124b..b670cabaea 100644 --- a/bigframes/ml/llm.py +++ b/bigframes/ml/llm.py @@ -16,14 +16,13 @@ from __future__ import annotations -from typing import Callable, cast, Iterable, Literal, Mapping, Optional, Union +from typing import cast, Iterable, Literal, Mapping, Optional, Union import warnings import bigframes_vendored.constants as constants from google.cloud import bigquery -import typing_extensions -from bigframes import clients, dtypes, exceptions +from bigframes import dtypes, exceptions import bigframes.bigquery as bbq from bigframes.core import blocks, global_session, log_adapter import bigframes.dataframe @@ -34,20 +33,6 @@ "max_iterations": "maxIterations", } -_TEXT_GENERATOR_BISON_ENDPOINT = "text-bison" -_TEXT_GENERATOR_BISON_32K_ENDPOINT = "text-bison-32k" -_TEXT_GENERATOR_ENDPOINTS = ( - _TEXT_GENERATOR_BISON_ENDPOINT, - _TEXT_GENERATOR_BISON_32K_ENDPOINT, -) - -_EMBEDDING_GENERATOR_GECKO_ENDPOINT = "textembedding-gecko" -_EMBEDDING_GENERATOR_GECKO_MULTILINGUAL_ENDPOINT = "textembedding-gecko-multilingual" -_PALM2_EMBEDDING_GENERATOR_ENDPOINTS = ( - _EMBEDDING_GENERATOR_GECKO_ENDPOINT, - _EMBEDDING_GENERATOR_GECKO_MULTILINGUAL_ENDPOINT, -) - _TEXT_EMBEDDING_005_ENDPOINT = "text-embedding-005" _TEXT_EMBEDDING_004_ENDPOINT = "text-embedding-004" _TEXT_MULTILINGUAL_EMBEDDING_002_ENDPOINT = "text-multilingual-embedding-002" @@ -59,7 +44,6 @@ _MULTIMODAL_EMBEDDING_001_ENDPOINT = "multimodalembedding@001" -_GEMINI_PRO_ENDPOINT = "gemini-pro" _GEMINI_1P5_PRO_PREVIEW_ENDPOINT = "gemini-1.5-pro-preview-0514" _GEMINI_1P5_PRO_FLASH_PREVIEW_ENDPOINT = "gemini-1.5-flash-preview-0514" _GEMINI_1P5_PRO_001_ENDPOINT = "gemini-1.5-pro-001" @@ -67,8 +51,14 @@ _GEMINI_1P5_FLASH_001_ENDPOINT = "gemini-1.5-flash-001" _GEMINI_1P5_FLASH_002_ENDPOINT = "gemini-1.5-flash-002" _GEMINI_2_FLASH_EXP_ENDPOINT = "gemini-2.0-flash-exp" +_GEMINI_2_FLASH_001_ENDPOINT = "gemini-2.0-flash-001" +_GEMINI_2_FLASH_LITE_001_ENDPOINT = "gemini-2.0-flash-lite-001" +_GEMINI_2P5_PRO_PREVIEW_ENDPOINT = "gemini-2.5-pro-preview-05-06" +_GEMINI_2P5_PRO_ENDPOINT = "gemini-2.5-pro" +_GEMINI_2P5_FLASH_ENDPOINT = "gemini-2.5-flash" +_GEMINI_2P5_FLASH_LITE_ENDPOINT = "gemini-2.5-flash-lite" + _GEMINI_ENDPOINTS = ( - _GEMINI_PRO_ENDPOINT, _GEMINI_1P5_PRO_PREVIEW_ENDPOINT, _GEMINI_1P5_PRO_FLASH_PREVIEW_ENDPOINT, _GEMINI_1P5_PRO_001_ENDPOINT, @@ -76,6 +66,11 @@ _GEMINI_1P5_FLASH_001_ENDPOINT, _GEMINI_1P5_FLASH_002_ENDPOINT, _GEMINI_2_FLASH_EXP_ENDPOINT, + _GEMINI_2_FLASH_001_ENDPOINT, + _GEMINI_2_FLASH_LITE_001_ENDPOINT, + _GEMINI_2P5_PRO_ENDPOINT, + _GEMINI_2P5_FLASH_ENDPOINT, + _GEMINI_2P5_FLASH_LITE_ENDPOINT, ) _GEMINI_PREVIEW_ENDPOINTS = ( _GEMINI_1P5_PRO_PREVIEW_ENDPOINT, @@ -83,9 +78,10 @@ _GEMINI_2_FLASH_EXP_ENDPOINT, ) _GEMINI_FINE_TUNE_SCORE_ENDPOINTS = ( - _GEMINI_PRO_ENDPOINT, _GEMINI_1P5_PRO_002_ENDPOINT, _GEMINI_1P5_FLASH_002_ENDPOINT, + _GEMINI_2_FLASH_001_ENDPOINT, + _GEMINI_2_FLASH_LITE_001_ENDPOINT, ) _GEMINI_MULTIMODAL_ENDPOINTS = ( _GEMINI_1P5_PRO_001_ENDPOINT, @@ -93,6 +89,11 @@ _GEMINI_1P5_FLASH_001_ENDPOINT, _GEMINI_1P5_FLASH_002_ENDPOINT, _GEMINI_2_FLASH_EXP_ENDPOINT, + _GEMINI_2_FLASH_001_ENDPOINT, + _GEMINI_2_FLASH_LITE_001_ENDPOINT, + _GEMINI_2P5_PRO_ENDPOINT, + _GEMINI_2P5_FLASH_ENDPOINT, + _GEMINI_2P5_FLASH_LITE_ENDPOINT, ) _CLAUDE_3_SONNET_ENDPOINT = "claude-3-sonnet" @@ -106,11 +107,6 @@ _CLAUDE_3_OPUS_ENDPOINT, ) - -_ML_GENERATE_TEXT_STATUS = "ml_generate_text_status" -_ML_EMBED_TEXT_STATUS = "ml_embed_text_status" -_ML_GENERATE_EMBEDDING_STATUS = "ml_generate_embedding_status" - _MODEL_NOT_SUPPORTED_WARNING = ( "Model name '{model_name}' is not supported. " "We are currently aware of the following models: {known_models}. " @@ -118,522 +114,33 @@ "You should use this model name only if you are sure that it is supported in BigQuery." ) +_REMOVE_DEFAULT_MODEL_WARNING = "Since upgrading the default model can cause unintended breakages, the default model will be removed in BigFrames 3.0. Please supply an explicit model to avoid this message." -@typing_extensions.deprecated( - "PaLM2TextGenerator is going to be deprecated. Use GeminiTextGenerator(https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) instead. ", - category=exceptions.ApiDeprecationWarning, +_GEMINI_MULTIMODAL_MODEL_NOT_SUPPORTED_WARNING = ( + "The model '{model_name}' may not be fully supported by GeminiTextGenerator for Multimodal prompts. " + "GeminiTextGenerator is known to support the following models for Multimodal prompts: {known_models}. " + "If you proceed with '{model_name}', it might not work as expected or could lead to errors with multimodal inputs." ) -@log_adapter.class_logger -class PaLM2TextGenerator(base.BaseEstimator): - """PaLM2 text generator LLM model. - - .. note:: - PaLM2TextGenerator is going to be deprecated. Use GeminiTextGenerator(https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) instead. - - Args: - model_name (str, Default to "text-bison"): - The model for natural language tasks. “text-bison” returns model fine-tuned to follow natural language instructions - and is suitable for a variety of language tasks. "text-bison-32k" supports up to 32k tokens per request. - Default to "text-bison". - session (bigframes.Session or None): - BQ session to create the model. If None, use the global default session. - connection_name (str or None): - Connection to connect with remote service. str of the format ... - If None, use default connection in session context. BigQuery DataFrame will try to create the connection and attach - permission if the connection isn't fully set up. - max_iterations (Optional[int], Default to 300): - The number of steps to run when performing supervised tuning. - """ - - def __init__( - self, - *, - model_name: Literal["text-bison", "text-bison-32k"] = "text-bison", - session: Optional[bigframes.Session] = None, - connection_name: Optional[str] = None, - max_iterations: int = 300, - ): - self.model_name = model_name - self.session = session or global_session.get_global_session() - self.max_iterations = max_iterations - self._bq_connection_manager = self.session.bqconnectionmanager - - connection_name = connection_name or self.session._bq_connection - self.connection_name = clients.resolve_full_bq_connection_name( - connection_name, - default_project=self.session._project, - default_location=self.session._location, - ) - - self._bqml_model_factory = globals.bqml_model_factory() - self._bqml_model: core.BqmlModel = self._create_bqml_model() - - def _create_bqml_model(self): - # Parse and create connection if needed. - if not self.connection_name: - raise ValueError( - "Must provide connection_name, either in constructor or through session options." - ) - - if self._bq_connection_manager: - connection_name_parts = self.connection_name.split(".") - if len(connection_name_parts) != 3: - raise ValueError( - f"connection_name must be of the format .., got {self.connection_name}." - ) - self._bq_connection_manager.create_bq_connection( - project_id=connection_name_parts[0], - location=connection_name_parts[1], - connection_id=connection_name_parts[2], - iam_role="aiplatform.user", - ) - - if self.model_name not in _TEXT_GENERATOR_ENDPOINTS: - msg = _MODEL_NOT_SUPPORTED_WARNING.format( - model_name=self.model_name, - known_models=", ".join(_TEXT_GENERATOR_ENDPOINTS), - ) - warnings.warn(msg) - - options = { - "endpoint": self.model_name, - } - - return self._bqml_model_factory.create_remote_model( - session=self.session, connection_name=self.connection_name, options=options - ) - - @classmethod - def _from_bq( - cls, session: bigframes.Session, bq_model: bigquery.Model - ) -> PaLM2TextGenerator: - assert bq_model.model_type == "MODEL_TYPE_UNSPECIFIED" - assert "remoteModelInfo" in bq_model._properties - assert "endpoint" in bq_model._properties["remoteModelInfo"] - assert "connection" in bq_model._properties["remoteModelInfo"] - - # Parse the remote model endpoint - bqml_endpoint = bq_model._properties["remoteModelInfo"]["endpoint"] - model_connection = bq_model._properties["remoteModelInfo"]["connection"] - model_endpoint = bqml_endpoint.split("/")[-1] - - kwargs = utils.retrieve_params_from_bq_model( - cls, bq_model, _BQML_PARAMS_MAPPING - ) - - model = cls( - **kwargs, - session=session, - model_name=model_endpoint, - connection_name=model_connection, - ) - model._bqml_model = core.BqmlModel(session, bq_model) - return model - - @property - def _bqml_options(self) -> dict: - """The model options as they will be set for BQML""" - options = { - "max_iterations": self.max_iterations, - "data_split_method": "NO_SPLIT", - } - return options - - def fit( - self, - X: utils.ArrayType, - y: utils.ArrayType, - ) -> PaLM2TextGenerator: - """Fine tune PaLM2TextGenerator model. - - .. note:: - - This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the - Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" - and might have limited support. For more information, see the launch stage descriptions - (https://cloud.google.com/products#product-launch-stages). - - Args: - X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): - DataFrame of shape (n_samples, n_features). Training data. - y (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): - Training labels. - - Returns: - PaLM2TextGenerator: Fitted estimator. - """ - X, y = utils.batch_convert_to_dataframe(X, y) - - options = self._bqml_options - options["endpoint"] = self.model_name + "@001" - options["prompt_col"] = X.columns.tolist()[0] - - self._bqml_model = self._bqml_model_factory.create_llm_remote_model( - X, - y, - options=options, - connection_name=self.connection_name, - ) - return self - - def predict( - self, - X: utils.ArrayType, - *, - temperature: float = 0.0, - max_output_tokens: int = 128, - top_k: int = 40, - top_p: float = 0.95, - ) -> bigframes.dataframe.DataFrame: - """Predict the result from input DataFrame. - - Args: - X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): - Input DataFrame or Series, can contain one or more columns. If multiple columns are in the DataFrame, it must contain a "prompt" column for prediction. - Prompts can include preamble, questions, suggestions, instructions, or examples. - - temperature (float, default 0.0): - The temperature is used for sampling during the response generation, which occurs when topP and topK are applied. - Temperature controls the degree of randomness in token selection. Lower temperatures are good for prompts that expect a true or correct response, - while higher temperatures can lead to more diverse or unexpected results. A temperature of 0 is deterministic: - the highest probability token is always selected. For most use cases, try starting with a temperature of 0.2. - Default 0. Possible values [0.0, 1.0]. - - max_output_tokens (int, default 128): - Maximum number of tokens that can be generated in the response. Specify a lower value for shorter responses and a higher value for longer responses. - A token may be smaller than a word. A token is approximately four characters. 100 tokens correspond to roughly 60-80 words. - Default 128. For the 'text-bison' model, possible values are in the range [1, 1024]. For the 'text-bison-32k' model, possible values are in the range [1, 8192]. - Please ensure that the specified value for max_output_tokens is within the appropriate range for the model being used. - - top_k (int, default 40): - Top-k changes how the model selects tokens for output. A top-k of 1 means the selected token is the most probable among all tokens - in the model's vocabulary (also called greedy decoding), while a top-k of 3 means that the next token is selected from among the 3 most probable tokens (using temperature). - For each token selection step, the top K tokens with the highest probabilities are sampled. Then tokens are further filtered based on topP with the final token selected using temperature sampling. - Specify a lower value for less random responses and a higher value for more random responses. - Default 40. Possible values [1, 40]. - - top_p (float, default 0.95):: - Top-p changes how the model selects tokens for output. Tokens are selected from most K (see topK parameter) probable to least until the sum of their probabilities equals the top-p value. - For example, if tokens A, B, and C have a probability of 0.3, 0.2, and 0.1 and the top-p value is 0.5, then the model will select either A or B as the next token (using temperature) - and not consider C at all. - Specify a lower value for less random responses and a higher value for more random responses. - Default 0.95. Possible values [0.0, 1.0]. - - - Returns: - bigframes.dataframe.DataFrame: DataFrame of shape (n_samples, n_input_columns + n_prediction_columns). Returns predicted values. - """ - - # Params reference: https://cloud.google.com/vertex-ai/docs/generative-ai/learn/models - if temperature < 0.0 or temperature > 1.0: - raise ValueError(f"temperature must be [0.0, 1.0], but is {temperature}.") - if ( - self.model_name == _TEXT_GENERATOR_BISON_ENDPOINT - and max_output_tokens not in range(1, 1025) - ): - raise ValueError( - f"max_output_token must be [1, 1024] for TextBison model, but is {max_output_tokens}." - ) - - if ( - self.model_name == _TEXT_GENERATOR_BISON_32K_ENDPOINT - and max_output_tokens not in range(1, 8193) - ): - raise ValueError( - f"max_output_token must be [1, 8192] for TextBison 32k model, but is {max_output_tokens}." - ) - - if top_k not in range(1, 41): - raise ValueError(f"top_k must be [1, 40], but is {top_k}.") - - if top_p < 0.0 or top_p > 1.0: - raise ValueError(f"top_p must be [0.0, 1.0], but is {top_p}.") - - (X,) = utils.batch_convert_to_dataframe(X, session=self._bqml_model.session) - - if len(X.columns) == 1: - # BQML identified the column by name - col_label = cast(blocks.Label, X.columns[0]) - X = X.rename(columns={col_label: "prompt"}) - - options = { - "temperature": temperature, - "max_output_tokens": max_output_tokens, - "top_k": top_k, - "top_p": top_p, - "flatten_json_output": True, - } - - df = self._bqml_model.generate_text(X, options) - - if (df[_ML_GENERATE_TEXT_STATUS] != "").any(): - msg = ( - f"Some predictions failed. Check column {_ML_GENERATE_TEXT_STATUS} for " - "detailed status. You may want to filter the failed rows and retry." - ) - warnings.warn(msg, category=RuntimeWarning) - - return df - - def score( - self, - X: utils.ArrayType, - y: utils.ArrayType, - task_type: Literal[ - "text_generation", "classification", "summarization", "question_answering" - ] = "text_generation", - ) -> bigframes.dataframe.DataFrame: - """Calculate evaluation metrics of the model. - - .. note:: - - This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the - Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" - and might have limited support. For more information, see the launch stage descriptions - (https://cloud.google.com/products#product-launch-stages). - - .. note:: - - Output matches that of the BigQuery ML.EVALUATE function. - See: https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-evaluate#remote-model-llm - for the outputs relevant to this model type. - - Args: - X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): - A BigQuery DataFrame as evaluation data, which contains only one column of input_text - that contains the prompt text to use when evaluating the model. - y (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): - A BigQuery DataFrame as evaluation labels, which contains only one column of output_text - that you would expect to be returned by the model. - task_type (str): - The type of the task for LLM model. Default to "text_generation". - Possible values: "text_generation", "classification", "summarization", and "question_answering". - - Returns: - bigframes.dataframe.DataFrame: The DataFrame as evaluation result. - """ - if not self._bqml_model: - raise RuntimeError("A model must be fitted before score") - - X, y = utils.batch_convert_to_dataframe(X, y, session=self._bqml_model.session) - - if len(X.columns) != 1 or len(y.columns) != 1: - raise ValueError( - f"Only support one column as input for X and y. {constants.FEEDBACK_LINK}" - ) - - # BQML identified the column by name - X_col_label = cast(blocks.Label, X.columns[0]) - y_col_label = cast(blocks.Label, y.columns[0]) - X = X.rename(columns={X_col_label: "input_text"}) - y = y.rename(columns={y_col_label: "output_text"}) - - input_data = X.join(y, how="outer") - - return self._bqml_model.llm_evaluate(input_data, task_type) - - def to_gbq(self, model_name: str, replace: bool = False) -> PaLM2TextGenerator: - """Save the model to BigQuery. - - Args: - model_name (str): - The name of the model. - replace (bool, default False): - Determine whether to replace if the model already exists. Default to False. - - Returns: - PaLM2TextGenerator: Saved model.""" - - new_model = self._bqml_model.copy(model_name, replace) - return new_model.session.read_gbq_model(model_name) - - -@typing_extensions.deprecated( - "PaLM2TextEmbeddingGenerator has been deprecated. Use TextEmbeddingGenerator(https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.TextEmbeddingGenerator) instead. ", - category=exceptions.ApiDeprecationWarning, +_MODEL_DEPRECATE_WARNING = ( + "'{model_name}' is going to be deprecated. Use '{new_model_name}' ({link}) instead." ) -@log_adapter.class_logger -class PaLM2TextEmbeddingGenerator(base.BaseEstimator): - """PaLM2 text embedding generator LLM model. - - .. note:: - PaLM2TextEmbeddingGenerator has been deprecated. Use TextEmbeddingGenerator(https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.TextEmbeddingGenerator) instead. - - - Args: - model_name (str, Default to "textembedding-gecko"): - The model for text embedding. “textembedding-gecko” returns model embeddings for text inputs. - "textembedding-gecko-multilingual" returns model embeddings for text inputs which support over 100 languages. - Default to "textembedding-gecko". - version (str or None): - Model version. Accepted values are "001", "002", "003", "latest" etc. Will use the default version if unset. - See https://cloud.google.com/vertex-ai/docs/generative-ai/learn/model-versioning for details. - session (bigframes.Session or None): - BQ session to create the model. If None, use the global default session. - connection_name (str or None): - Connection to connect with remote service. str of the format ... - If None, use default connection in session context. - """ - - def __init__( - self, - *, - model_name: Literal[ - "textembedding-gecko", "textembedding-gecko-multilingual" - ] = "textembedding-gecko", - version: Optional[str] = None, - session: Optional[bigframes.Session] = None, - connection_name: Optional[str] = None, - ): - self.model_name = model_name - self.version = version - self.session = session or global_session.get_global_session() - self._bq_connection_manager = self.session.bqconnectionmanager - - connection_name = connection_name or self.session._bq_connection - self.connection_name = clients.resolve_full_bq_connection_name( - connection_name, - default_project=self.session._project, - default_location=self.session._location, - ) - - self._bqml_model_factory = globals.bqml_model_factory() - self._bqml_model: core.BqmlModel = self._create_bqml_model() - - def _create_bqml_model(self): - # Parse and create connection if needed. - if not self.connection_name: - raise ValueError( - "Must provide connection_name, either in constructor or through session options." - ) - - if self._bq_connection_manager: - connection_name_parts = self.connection_name.split(".") - if len(connection_name_parts) != 3: - raise ValueError( - f"connection_name must be of the format .., got {self.connection_name}." - ) - self._bq_connection_manager.create_bq_connection( - project_id=connection_name_parts[0], - location=connection_name_parts[1], - connection_id=connection_name_parts[2], - iam_role="aiplatform.user", - ) - - if self.model_name not in _PALM2_EMBEDDING_GENERATOR_ENDPOINTS: - msg = _MODEL_NOT_SUPPORTED_WARNING.format( - model_name=self.model_name, - known_models=", ".join(_PALM2_EMBEDDING_GENERATOR_ENDPOINTS), - ) - warnings.warn(msg) - - endpoint = ( - self.model_name + "@" + self.version if self.version else self.model_name - ) - options = { - "endpoint": endpoint, - } - return self._bqml_model_factory.create_remote_model( - session=self.session, connection_name=self.connection_name, options=options - ) - - @classmethod - def _from_bq( - cls, session: bigframes.Session, bq_model: bigquery.Model - ) -> PaLM2TextEmbeddingGenerator: - assert bq_model.model_type == "MODEL_TYPE_UNSPECIFIED" - assert "remoteModelInfo" in bq_model._properties - assert "endpoint" in bq_model._properties["remoteModelInfo"] - assert "connection" in bq_model._properties["remoteModelInfo"] - - # Parse the remote model endpoint - bqml_endpoint = bq_model._properties["remoteModelInfo"]["endpoint"] - model_connection = bq_model._properties["remoteModelInfo"]["connection"] - model_endpoint = bqml_endpoint.split("/")[-1] - - model_name, version = utils.parse_model_endpoint(model_endpoint) - - model = cls( - session=session, - # str to literals - model_name=model_name, # type: ignore - version=version, - connection_name=model_connection, - ) - - model._bqml_model = core.BqmlModel(session, bq_model) - return model - - def predict(self, X: utils.ArrayType) -> bigframes.dataframe.DataFrame: - """Predict the result from input DataFrame. - - Args: - X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): - Input DataFrame or Series, can contain one or more columns. If multiple columns are in the DataFrame, it must contain a "content" column for prediction. - - Returns: - bigframes.dataframe.DataFrame: DataFrame of shape (n_samples, n_input_columns + n_prediction_columns). Returns predicted values. - """ - - # Params reference: https://cloud.google.com/vertex-ai/docs/generative-ai/learn/models - (X,) = utils.batch_convert_to_dataframe(X, session=self._bqml_model.session) - - if len(X.columns) == 1: - # BQML identified the column by name - col_label = cast(blocks.Label, X.columns[0]) - X = X.rename(columns={col_label: "content"}) - - options = { - "flatten_json_output": True, - } - - df = self._bqml_model.generate_embedding(X, options) - df = df.rename( - columns={ - "ml_generate_embedding_result": "text_embedding", - "ml_generate_embedding_statistics": "statistics", - "ml_generate_embedding_status": _ML_EMBED_TEXT_STATUS, - } - ) - - if (df[_ML_EMBED_TEXT_STATUS] != "").any(): - msg = ( - f"Some predictions failed. Check column {_ML_EMBED_TEXT_STATUS} for " - "detailed status. You may want to filter the failed rows and retry." - ) - warnings.warn(msg, category=RuntimeWarning) - - return df - - def to_gbq( - self, model_name: str, replace: bool = False - ) -> PaLM2TextEmbeddingGenerator: - """Save the model to BigQuery. - - Args: - model_name (str): - The name of the model. - replace (bool, default False): - Determine whether to replace if the model already exists. Default to False. - - Returns: - PaLM2TextEmbeddingGenerator: Saved model.""" - - new_model = self._bqml_model.copy(model_name, replace) - return new_model.session.read_gbq_model(model_name) @log_adapter.class_logger class TextEmbeddingGenerator(base.RetriableRemotePredictor): """Text embedding generator LLM model. + .. note:: + text-embedding-004 is going to be deprecated. Use text-embedding-005(https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.TextEmbeddingGenerator) instead. + Args: model_name (str, Default to "text-embedding-004"): The model for text embedding. Possible values are "text-embedding-005", "text-embedding-004" or "text-multilingual-embedding-002". text-embedding models returns model embeddings for text inputs. text-multilingual-embedding models returns model embeddings for text inputs which support over 100 languages. - Default to "text-embedding-004". + If no setting is provided, "text-embedding-004" will be used by + default and a warning will be issued. session (bigframes.Session or None): BQ session to create the model. If None, use the global default session. connection_name (str or None): @@ -644,14 +151,20 @@ class TextEmbeddingGenerator(base.RetriableRemotePredictor): def __init__( self, *, - model_name: Literal[ - "text-embedding-005", - "text-embedding-004", - "text-multilingual-embedding-002", - ] = "text-embedding-004", + model_name: Optional[ + Literal[ + "text-embedding-005", + "text-embedding-004", + "text-multilingual-embedding-002", + ] + ] = None, session: Optional[bigframes.Session] = None, connection_name: Optional[str] = None, ): + if model_name is None: + model_name = "text-embedding-004" + msg = exceptions.format_message(_REMOVE_DEFAULT_MODEL_WARNING) + warnings.warn(msg, category=FutureWarning, stacklevel=2) self.model_name = model_name self.session = session or global_session.get_global_session() self.connection_name = connection_name @@ -666,9 +179,20 @@ def _create_bqml_model(self): ) if self.model_name not in _TEXT_EMBEDDING_ENDPOINTS: - msg = _MODEL_NOT_SUPPORTED_WARNING.format( - model_name=self.model_name, - known_models=", ".join(_TEXT_EMBEDDING_ENDPOINTS), + msg = exceptions.format_message( + _MODEL_NOT_SUPPORTED_WARNING.format( + model_name=self.model_name, + known_models=", ".join(_TEXT_EMBEDDING_ENDPOINTS), + ) + ) + warnings.warn(msg) + if self.model_name == "text-embedding-004": + msg = exceptions.format_message( + _MODEL_DEPRECATE_WARNING.format( + model_name=self.model_name, + new_model_name="text-embedding-005", + link="https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.TextEmbeddingGenerator", + ) ) warnings.warn(msg) @@ -702,18 +226,6 @@ def _from_bq( model._bqml_model = core.BqmlModel(session, bq_model) return model - @property - def _predict_func( - self, - ) -> Callable[ - [bigframes.dataframe.DataFrame, Mapping], bigframes.dataframe.DataFrame - ]: - return self._bqml_model.generate_embedding - - @property - def _status_col(self) -> str: - return _ML_GENERATE_EMBEDDING_STATUS - def predict( self, X: utils.ArrayType, *, max_retries: int = 0 ) -> bigframes.dataframe.DataFrame: @@ -742,11 +254,14 @@ def predict( col_label = cast(blocks.Label, X.columns[0]) X = X.rename(columns={col_label: "content"}) - options = { - "flatten_json_output": True, - } + options: dict = {} - return self._predict_and_retry(X, options=options, max_retries=max_retries) + return self._predict_and_retry( + core.BqmlModel.generate_embedding_tvf, + X, + options=options, + max_retries=max_retries, + ) def to_gbq(self, model_name: str, replace: bool = False) -> TextEmbeddingGenerator: """Save the model to BigQuery. @@ -769,12 +284,16 @@ class MultimodalEmbeddingGenerator(base.RetriableRemotePredictor): """Multimodal embedding generator LLM model. .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. + BigFrames Blob is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). Args: model_name (str, Default to "multimodalembedding@001"): The model for multimodal embedding. Can set to "multimodalembedding@001". Multimodal-embedding models returns model embeddings for text, image and video inputs. - Default to "multimodalembedding@001". + If no setting is provided, "multimodalembedding@001" will be used by + default and a warning will be issued. session (bigframes.Session or None): BQ session to create the model. If None, use the global default session. connection_name (str or None): @@ -785,12 +304,14 @@ class MultimodalEmbeddingGenerator(base.RetriableRemotePredictor): def __init__( self, *, - model_name: Literal["multimodalembedding@001"] = "multimodalembedding@001", + model_name: Optional[Literal["multimodalembedding@001"]] = None, session: Optional[bigframes.Session] = None, connection_name: Optional[str] = None, ): - if not bigframes.options.experiments.blob: - raise NotImplementedError() + if model_name is None: + model_name = "multimodalembedding@001" + msg = exceptions.format_message(_REMOVE_DEFAULT_MODEL_WARNING) + warnings.warn(msg, category=FutureWarning, stacklevel=2) self.model_name = model_name self.session = session or global_session.get_global_session() self.connection_name = connection_name @@ -805,9 +326,11 @@ def _create_bqml_model(self): ) if self.model_name != _MULTIMODAL_EMBEDDING_001_ENDPOINT: - msg = _MODEL_NOT_SUPPORTED_WARNING.format( - model_name=self.model_name, - known_models=_MULTIMODAL_EMBEDDING_001_ENDPOINT, + msg = exceptions.format_message( + _MODEL_NOT_SUPPORTED_WARNING.format( + model_name=self.model_name, + known_models=_MULTIMODAL_EMBEDDING_001_ENDPOINT, + ) ) warnings.warn(msg) @@ -841,18 +364,6 @@ def _from_bq( model._bqml_model = core.BqmlModel(session, bq_model) return model - @property - def _predict_func( - self, - ) -> Callable[ - [bigframes.dataframe.DataFrame, Mapping], bigframes.dataframe.DataFrame - ]: - return self._bqml_model.generate_embedding - - @property - def _status_col(self) -> str: - return _ML_GENERATE_EMBEDDING_STATUS - def predict( self, X: utils.ArrayType, *, max_retries: int = 0 ) -> bigframes.dataframe.DataFrame: @@ -886,11 +397,14 @@ def predict( if X["content"].dtype == dtypes.OBJ_REF_DTYPE: X["content"] = X["content"].blob._get_runtime("R", with_metadata=True) - options = { - "flatten_json_output": True, - } + options: dict = {} - return self._predict_and_retry(X, options=options, max_retries=max_retries) + return self._predict_and_retry( + core.BqmlModel.generate_embedding_tvf, + X, + options=options, + max_retries=max_retries, + ) def to_gbq( self, model_name: str, replace: bool = False @@ -914,11 +428,22 @@ def to_gbq( class GeminiTextGenerator(base.RetriableRemotePredictor): """Gemini text generator LLM model. + .. note:: + gemini-1.5-X are going to be deprecated. Use gemini-2.5-X (https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) instead. + Args: - model_name (str, Default to "gemini-pro"): - The model for natural language tasks. Accepted values are "gemini-pro", "gemini-1.5-pro-preview-0514", "gemini-1.5-flash-preview-0514", "gemini-1.5-pro-001", "gemini-1.5-pro-002", "gemini-1.5-flash-001", "gemini-1.5-flash-002" and "gemini-2.0-flash-exp". Default to "gemini-pro". + model_name (str, Default to "gemini-2.0-flash-001"): + The model for natural language tasks. Accepted values are + "gemini-1.5-pro-preview-0514", "gemini-1.5-flash-preview-0514", + "gemini-1.5-pro-001", "gemini-1.5-pro-002", "gemini-1.5-flash-001", + "gemini-1.5-flash-002", "gemini-2.0-flash-exp", + "gemini-2.0-flash-lite-001", "gemini-2.0-flash-001", + "gemini-2.5-pro", "gemini-2.5-flash" and "gemini-2.5-flash-lite". + If no setting is provided, "gemini-2.0-flash-001" will be used by + default and a warning will be issued. .. note:: + "gemini-1.5-X" is going to be deprecated. Please use gemini-2.5-X instead. For example, "gemini-2.5-flash". "gemini-2.0-flash-exp", "gemini-1.5-pro-preview-0514" and "gemini-1.5-flash-preview-0514" is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" and might have limited support. For more information, see the launch stage descriptions @@ -937,22 +462,28 @@ class GeminiTextGenerator(base.RetriableRemotePredictor): def __init__( self, *, - model_name: Literal[ - "gemini-pro", - "gemini-1.5-pro-preview-0514", - "gemini-1.5-flash-preview-0514", - "gemini-1.5-pro-001", - "gemini-1.5-pro-002", - "gemini-1.5-flash-001", - "gemini-1.5-flash-002", - "gemini-2.0-flash-exp", - ] = "gemini-pro", + model_name: Optional[ + Literal[ + "gemini-1.5-pro-preview-0514", + "gemini-1.5-flash-preview-0514", + "gemini-1.5-pro-001", + "gemini-1.5-pro-002", + "gemini-1.5-flash-001", + "gemini-1.5-flash-002", + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ] + ] = None, session: Optional[bigframes.Session] = None, connection_name: Optional[str] = None, max_iterations: int = 300, ): if model_name in _GEMINI_PREVIEW_ENDPOINTS: - msg = ( + msg = exceptions.format_message( f'Model {model_name} is subject to the "Pre-GA Offerings Terms" in ' "the General Service Terms section of the Service Specific Terms" "(https://cloud.google.com/terms/service-terms#1). Pre-GA products and " @@ -961,6 +492,12 @@ def __init__( "(https://cloud.google.com/products#product-launch-stages)." ) warnings.warn(msg, category=exceptions.PreviewWarning) + + if model_name is None: + model_name = "gemini-2.0-flash-001" + msg = exceptions.format_message(_REMOVE_DEFAULT_MODEL_WARNING) + warnings.warn(msg, category=FutureWarning, stacklevel=2) + self.model_name = model_name self.session = session or global_session.get_global_session() self.max_iterations = max_iterations @@ -976,9 +513,20 @@ def _create_bqml_model(self): ) if self.model_name not in _GEMINI_ENDPOINTS: - msg = _MODEL_NOT_SUPPORTED_WARNING.format( - model_name=self.model_name, - known_models=", ".join(_GEMINI_ENDPOINTS), + msg = exceptions.format_message( + _MODEL_NOT_SUPPORTED_WARNING.format( + model_name=self.model_name, + known_models=", ".join(_GEMINI_ENDPOINTS), + ) + ) + warnings.warn(msg) + if self.model_name.startswith("gemini-1.5"): + msg = exceptions.format_message( + _MODEL_DEPRECATE_WARNING.format( + model_name=self.model_name, + new_model_name="gemini-2.5-X", + link="https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator", + ) ) warnings.warn(msg) @@ -1017,25 +565,14 @@ def _bqml_options(self) -> dict: } return options - @property - def _predict_func( - self, - ) -> Callable[ - [bigframes.dataframe.DataFrame, Mapping], bigframes.dataframe.DataFrame - ]: - return self._bqml_model.generate_text - - @property - def _status_col(self) -> str: - return _ML_GENERATE_TEXT_STATUS - def fit( self, X: utils.ArrayType, y: utils.ArrayType, ) -> GeminiTextGenerator: - """Fine tune GeminiTextGenerator model. Only support "gemini-pro", "gemini-1.5-pro-002", - "gemini-1.5-flash-002" models for now. + """Fine tune GeminiTextGenerator model. Only support "gemini-1.5-pro-002", + "gemini-1.5-flash-002", "gemini-2.0-flash-001", + and "gemini-2.0-flash-lite-001"models for now. .. note:: @@ -1054,17 +591,15 @@ def fit( GeminiTextGenerator: Fitted estimator. """ if self.model_name not in _GEMINI_FINE_TUNE_SCORE_ENDPOINTS: - raise NotImplementedError( - "fit() only supports gemini-pro, \ - gemini-1.5-pro-002, or gemini-1.5-flash-002 model." + msg = exceptions.format_message( + "fit() only supports gemini-1.5-pro-002, gemini-1.5-flash-002, gemini-2.0-flash-001, or gemini-2.0-flash-lite-001 model." ) + warnings.warn(msg) X, y = utils.batch_convert_to_dataframe(X, y) options = self._bqml_options - options["endpoint"] = ( - "gemini-1.0-pro-002" if self.model_name == "gemini-pro" else self.model_name - ) + options["endpoint"] = self.model_name options["prompt_col"] = X.columns.tolist()[0] self._bqml_model = self._bqml_model_factory.create_llm_remote_model( @@ -1083,6 +618,7 @@ def predict( ground_with_google_search: bool = False, max_retries: int = 0, prompt: Optional[Iterable[Union[str, bigframes.series.Series]]] = None, + output_schema: Optional[Mapping[str, str]] = None, ) -> bigframes.dataframe.DataFrame: """Predict the result from input DataFrame. @@ -1127,11 +663,17 @@ def predict( prompt (Iterable of str or bigframes.series.Series, or None, default None): .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. + BigFrames Blob is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). Construct a prompt struct column for prediction based on the input. The input must be an Iterable that can take string literals, such as "summarize", string column(s) of X, such as X["str_col"], or blob column(s) of X, such as X["blob_col"]. It creates a struct column of the items of the iterable, and use the concatenated result as the input prompt. No-op if set to None. + output_schema (Mapping[str, str] or None, default None): + The schema used to generate structured output as a bigframes DataFrame. The schema is a string key-value pair of :. + Supported types are int64, float64, bool, string, array and struct. If None, output text result. Returns: bigframes.dataframe.DataFrame: DataFrame of shape (n_samples, n_input_columns + n_prediction_columns). Returns predicted values. """ @@ -1160,13 +702,14 @@ def predict( (X,) = utils.batch_convert_to_dataframe(X, session=session) if prompt: - if not bigframes.options.experiments.blob: - raise NotImplementedError() - if self.model_name not in _GEMINI_MULTIMODAL_ENDPOINTS: - raise NotImplementedError( - f"GeminiTextGenerator only supports model_name {', '.join(_GEMINI_MULTIMODAL_ENDPOINTS)} for Multimodal prompt." + msg = exceptions.format_message( + _GEMINI_MULTIMODAL_MODEL_NOT_SUPPORTED_WARNING.format( + model_name=self.model_name, + known_models=", ".join(_GEMINI_MULTIMODAL_ENDPOINTS), + ) ) + warnings.warn(msg) df_prompt = X[[X.columns[0]]].rename( columns={X.columns[0]: "bigframes_placeholder_col"} @@ -1194,16 +737,31 @@ def predict( col_label = cast(blocks.Label, X.columns[0]) X = X.rename(columns={col_label: "prompt"}) - options = { + options: dict = { "temperature": temperature, "max_output_tokens": max_output_tokens, - "top_k": top_k, + # "top_k": top_k, # TODO(garrettwu): the option is deprecated in Gemini 1.5 forward. "top_p": top_p, - "flatten_json_output": True, "ground_with_google_search": ground_with_google_search, } + if output_schema: + output_schema = { + k: utils.standardize_type(v) for k, v in output_schema.items() + } + options["output_schema"] = output_schema + return self._predict_and_retry( + core.BqmlModel.generate_table_tvf, + X, + options=options, + max_retries=max_retries, + ) - return self._predict_and_retry(X, options=options, max_retries=max_retries) + return self._predict_and_retry( + core.BqmlModel.generate_text_tvf, + X, + options=options, + max_retries=max_retries, + ) def score( self, @@ -1213,7 +771,9 @@ def score( "text_generation", "classification", "summarization", "question_answering" ] = "text_generation", ) -> bigframes.dataframe.DataFrame: - """Calculate evaluation metrics of the model. Only support "gemini-pro" and "gemini-1.5-pro-002", and "gemini-1.5-flash-002". + """Calculate evaluation metrics of the model. Only support + "gemini-1.5-pro-002", "gemini-1.5-flash-002", + "gemini-2.0-flash-lite-001", and "gemini-2.0-flash-001". .. note:: @@ -1246,10 +806,10 @@ def score( raise RuntimeError("A model must be fitted before score") if self.model_name not in _GEMINI_FINE_TUNE_SCORE_ENDPOINTS: - raise NotImplementedError( - "score() only supports gemini-pro \ - , gemini-1.5-pro-002, and gemini-1.5-flash-2 model." + msg = exceptions.format_message( + "score() only supports gemini-1.5-pro-002, gemini-1.5-flash-2, gemini-2.0-flash-001, and gemini-2.0-flash-lite-001 model." ) + warnings.warn(msg) X, y = utils.batch_convert_to_dataframe(X, y, session=self._bqml_model.session) @@ -1288,7 +848,7 @@ def to_gbq(self, model_name: str, replace: bool = False) -> GeminiTextGenerator: class Claude3TextGenerator(base.RetriableRemotePredictor): """Claude3 text generator LLM model. - Go to Google Cloud Console -> Vertex AI -> Model Garden page to enabe the models before use. Must have the Consumer Procurement Entitlement Manager Identity and Access Management (IAM) role to enable the models. + Go to Google Cloud Console -> Vertex AI -> Model Garden page to enable the models before use. Must have the Consumer Procurement Entitlement Manager Identity and Access Management (IAM) role to enable the models. https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models#grant-permissions .. note:: @@ -1303,15 +863,20 @@ class Claude3TextGenerator(base.RetriableRemotePredictor): The models only available in specific regions. Check https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions for details. + .. note:: + + claude-3-sonnet model is deprecated. Use other models instead. + Args: model_name (str, Default to "claude-3-sonnet"): The model for natural language tasks. Possible values are "claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet" and "claude-3-opus". - "claude-3-sonnet" is Anthropic's dependable combination of skills and speed. It is engineered to be dependable for scaled AI deployments across a variety of use cases. + "claude-3-sonnet" (deprecated) is Anthropic's dependable combination of skills and speed. It is engineered to be dependable for scaled AI deployments across a variety of use cases. "claude-3-haiku" is Anthropic's fastest, most compact vision and text model for near-instant responses to simple queries, meant for seamless AI experiences mimicking human interactions. "claude-3-5-sonnet" is Anthropic's most powerful AI model and maintains the speed and cost of Claude 3 Sonnet, which is a mid-tier model. "claude-3-opus" is Anthropic's second-most powerful AI model, with strong performance on highly complex tasks. https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#available-claude-models - Default to "claude-3-sonnet". + If no setting is provided, "claude-3-sonnet" will be used by default + and a warning will be issued. session (bigframes.Session or None): BQ session to create the model. If None, use the global default session. connection_name (str or None): @@ -1323,12 +888,21 @@ class Claude3TextGenerator(base.RetriableRemotePredictor): def __init__( self, *, - model_name: Literal[ - "claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus" - ] = "claude-3-sonnet", + model_name: Optional[ + Literal[ + "claude-3-sonnet", + "claude-3-haiku", + "claude-3-5-sonnet", + "claude-3-opus", + ] + ] = None, session: Optional[bigframes.Session] = None, connection_name: Optional[str] = None, ): + if model_name is None: + model_name = "claude-3-sonnet" + msg = exceptions.format_message(_REMOVE_DEFAULT_MODEL_WARNING) + warnings.warn(msg, category=FutureWarning, stacklevel=2) self.model_name = model_name self.session = session or global_session.get_global_session() self.connection_name = connection_name @@ -1343,9 +917,11 @@ def _create_bqml_model(self): ) if self.model_name not in _CLAUDE_3_ENDPOINTS: - msg = _MODEL_NOT_SUPPORTED_WARNING.format( - model_name=self.model_name, - known_models=", ".join(_CLAUDE_3_ENDPOINTS), + msg = exceptions.format_message( + _MODEL_NOT_SUPPORTED_WARNING.format( + model_name=self.model_name, + known_models=", ".join(_CLAUDE_3_ENDPOINTS), + ) ) warnings.warn(msg) options = { @@ -1391,18 +967,6 @@ def _bqml_options(self) -> dict: } return options - @property - def _predict_func( - self, - ) -> Callable[ - [bigframes.dataframe.DataFrame, Mapping], bigframes.dataframe.DataFrame - ]: - return self._bqml_model.generate_text - - @property - def _status_col(self) -> str: - return _ML_GENERATE_TEXT_STATUS - def predict( self, X: utils.ArrayType, @@ -1475,10 +1039,14 @@ def predict( "max_output_tokens": max_output_tokens, "top_k": top_k, "top_p": top_p, - "flatten_json_output": True, } - return self._predict_and_retry(X, options=options, max_retries=max_retries) + return self._predict_and_retry( + core.BqmlModel.generate_text_tvf, + X, + options=options, + max_retries=max_retries, + ) def to_gbq(self, model_name: str, replace: bool = False) -> Claude3TextGenerator: """Save the model to BigQuery. diff --git a/bigframes/ml/loader.py b/bigframes/ml/loader.py index eef72584bc..f6b5e4e2dc 100644 --- a/bigframes/ml/loader.py +++ b/bigframes/ml/loader.py @@ -42,6 +42,7 @@ "LINEAR_REGRESSION": linear_model.LinearRegression, "LOGISTIC_REGRESSION": linear_model.LogisticRegression, "KMEANS": cluster.KMeans, + "MATRIX_FACTORIZATION": decomposition.MatrixFactorization, "PCA": decomposition.PCA, "BOOSTED_TREE_REGRESSOR": ensemble.XGBRegressor, "BOOSTED_TREE_CLASSIFIER": ensemble.XGBClassifier, @@ -56,11 +57,6 @@ _BQML_ENDPOINT_TYPE_MAPPING = MappingProxyType( { - llm._TEXT_GENERATOR_BISON_ENDPOINT: llm.PaLM2TextGenerator, - llm._TEXT_GENERATOR_BISON_32K_ENDPOINT: llm.PaLM2TextGenerator, - llm._EMBEDDING_GENERATOR_GECKO_ENDPOINT: llm.PaLM2TextEmbeddingGenerator, - llm._EMBEDDING_GENERATOR_GECKO_MULTILINGUAL_ENDPOINT: llm.PaLM2TextEmbeddingGenerator, - llm._GEMINI_PRO_ENDPOINT: llm.GeminiTextGenerator, llm._GEMINI_1P5_PRO_PREVIEW_ENDPOINT: llm.GeminiTextGenerator, llm._GEMINI_1P5_PRO_FLASH_PREVIEW_ENDPOINT: llm.GeminiTextGenerator, llm._GEMINI_1P5_PRO_001_ENDPOINT: llm.GeminiTextGenerator, @@ -68,6 +64,12 @@ llm._GEMINI_1P5_FLASH_001_ENDPOINT: llm.GeminiTextGenerator, llm._GEMINI_1P5_FLASH_002_ENDPOINT: llm.GeminiTextGenerator, llm._GEMINI_2_FLASH_EXP_ENDPOINT: llm.GeminiTextGenerator, + llm._GEMINI_2_FLASH_001_ENDPOINT: llm.GeminiTextGenerator, + llm._GEMINI_2_FLASH_LITE_001_ENDPOINT: llm.GeminiTextGenerator, + llm._GEMINI_2P5_PRO_PREVIEW_ENDPOINT: llm.GeminiTextGenerator, + llm._GEMINI_2P5_FLASH_ENDPOINT: llm.GeminiTextGenerator, + llm._GEMINI_2P5_FLASH_LITE_ENDPOINT: llm.GeminiTextGenerator, + llm._GEMINI_2P5_PRO_ENDPOINT: llm.GeminiTextGenerator, llm._CLAUDE_3_HAIKU_ENDPOINT: llm.Claude3TextGenerator, llm._CLAUDE_3_SONNET_ENDPOINT: llm.Claude3TextGenerator, llm._CLAUDE_3_5_SONNET_ENDPOINT: llm.Claude3TextGenerator, @@ -83,6 +85,7 @@ def from_bq( session: bigframes.session.Session, bq_model: bigquery.Model ) -> Union[ + decomposition.MatrixFactorization, decomposition.PCA, cluster.KMeans, linear_model.LinearRegression, @@ -95,8 +98,6 @@ def from_bq( imported.TensorFlowModel, imported.ONNXModel, imported.XGBoostModel, - llm.PaLM2TextGenerator, - llm.PaLM2TextEmbeddingGenerator, llm.Claude3TextGenerator, llm.TextEmbeddingGenerator, llm.MultimodalEmbeddingGenerator, diff --git a/bigframes/ml/metrics/__init__.py b/bigframes/ml/metrics/__init__.py index e79b46877b..f6c7d5e52f 100644 --- a/bigframes/ml/metrics/__init__.py +++ b/bigframes/ml/metrics/__init__.py @@ -18,6 +18,7 @@ auc, confusion_matrix, f1_score, + mean_absolute_error, mean_squared_error, precision_score, r2_score, @@ -36,6 +37,7 @@ "confusion_matrix", "precision_score", "f1_score", + "mean_absolute_error", "mean_squared_error", "pairwise", ] diff --git a/bigframes/ml/metrics/_metrics.py b/bigframes/ml/metrics/_metrics.py index 90df6f9539..8787a68c58 100644 --- a/bigframes/ml/metrics/_metrics.py +++ b/bigframes/ml/metrics/_metrics.py @@ -15,9 +15,11 @@ """Metrics functions for evaluating models. This module is styled after scikit-learn's metrics module: https://scikit-learn.org/stable/modules/metrics.html.""" +from __future__ import annotations + import inspect import typing -from typing import Tuple, Union +from typing import Literal, overload, Tuple, Union import bigframes_vendored.constants as constants import bigframes_vendored.sklearn.metrics._classification as vendored_metrics_classification @@ -25,7 +27,6 @@ import bigframes_vendored.sklearn.metrics._regression as vendored_metrics_regression import numpy as np import pandas as pd -import sklearn.metrics as sklearn_metrics # type: ignore from bigframes.ml import utils import bigframes.pandas as bpd @@ -176,9 +177,9 @@ def auc( ) -> float: x_series, y_series = utils.batch_convert_to_series(x, y) - # TODO(b/286410053) Support ML exceptions and error handling. - auc = sklearn_metrics.auc(x_series.to_pandas(), y_series.to_pandas()) - return auc + x_pandas = x_series.to_pandas() + y_pandas = y_series.to_pandas() + return vendored_metrics_ranking.auc(x_pandas, y_pandas) auc.__doc__ = inspect.getdoc(vendored_metrics_ranking.auc) @@ -241,7 +242,7 @@ def recall_score( unique_labels = ( bpd.concat([y_true_series, y_pred_series], join="outer") .drop_duplicates() - .sort_values() + .sort_values(inplace=False) ) index = unique_labels.to_list() @@ -260,31 +261,64 @@ def recall_score( recall_score.__doc__ = inspect.getdoc(vendored_metrics_classification.recall_score) +@overload def precision_score( - y_true: Union[bpd.DataFrame, bpd.Series], - y_pred: Union[bpd.DataFrame, bpd.Series], + y_true: bpd.DataFrame | bpd.Series, + y_pred: bpd.DataFrame | bpd.Series, *, - average: typing.Optional[str] = "binary", + pos_label: int | float | bool | str = ..., + average: Literal["binary"] = ..., +) -> float: + ... + + +@overload +def precision_score( + y_true: bpd.DataFrame | bpd.Series, + y_pred: bpd.DataFrame | bpd.Series, + *, + pos_label: int | float | bool | str = ..., + average: None = ..., ) -> pd.Series: - # TODO(ashleyxu): support more average type, default to "binary" - if average is not None: - raise NotImplementedError( - f"Only average=None is supported. {constants.FEEDBACK_LINK}" - ) + ... + +def precision_score( + y_true: bpd.DataFrame | bpd.Series, + y_pred: bpd.DataFrame | bpd.Series, + *, + pos_label: int | float | bool | str = 1, + average: Literal["binary"] | None = "binary", +) -> pd.Series | float: y_true_series, y_pred_series = utils.batch_convert_to_series(y_true, y_pred) - is_accurate = y_true_series == y_pred_series + if average is None: + return _precision_score_per_label(y_true_series, y_pred_series) + + if average == "binary": + return _precision_score_binary_pos_only(y_true_series, y_pred_series, pos_label) + + raise NotImplementedError( + f"Unsupported 'average' param value: {average}. {constants.FEEDBACK_LINK}" + ) + + +precision_score.__doc__ = inspect.getdoc( + vendored_metrics_classification.precision_score +) + + +def _precision_score_per_label(y_true: bpd.Series, y_pred: bpd.Series) -> pd.Series: + is_accurate = y_true == y_pred unique_labels = ( - bpd.concat([y_true_series, y_pred_series], join="outer") + bpd.concat([y_true, y_pred], join="outer") .drop_duplicates() - .sort_values() + .sort_values(inplace=False) ) index = unique_labels.to_list() precision = ( - is_accurate.groupby(y_pred_series).sum() - / is_accurate.groupby(y_pred_series).count() + is_accurate.groupby(y_pred).sum() / is_accurate.groupby(y_pred).count() ).to_pandas() precision_score = pd.Series(0, index=index) @@ -294,9 +328,25 @@ def precision_score( return precision_score -precision_score.__doc__ = inspect.getdoc( - vendored_metrics_classification.precision_score -) +def _precision_score_binary_pos_only( + y_true: bpd.Series, y_pred: bpd.Series, pos_label: int | float | bool | str +) -> float: + unique_labels = bpd.concat([y_true, y_pred]).unique(keep_order=False) + + if unique_labels.count() != 2: + raise ValueError( + "Target is multiclass but average='binary'. Please choose another average setting." + ) + + if not (unique_labels == pos_label).any(): + raise ValueError( + f"pos_labe={pos_label} is not a valid label. It should be one of {unique_labels.to_list()}" + ) + + target_elem_idx = y_pred == pos_label + is_accurate = y_pred[target_elem_idx] == y_true[target_elem_idx] + + return is_accurate.sum() / is_accurate.count() def f1_score( @@ -345,3 +395,17 @@ def mean_squared_error( mean_squared_error.__doc__ = inspect.getdoc( vendored_metrics_regression.mean_squared_error ) + + +def mean_absolute_error( + y_true: Union[bpd.DataFrame, bpd.Series], + y_pred: Union[bpd.DataFrame, bpd.Series], +) -> float: + y_true_series, y_pred_series = utils.batch_convert_to_series(y_true, y_pred) + + return (y_pred_series - y_true_series).abs().sum() / len(y_true_series) + + +mean_absolute_error.__doc__ = inspect.getdoc( + vendored_metrics_regression.mean_absolute_error +) diff --git a/bigframes/ml/model_selection.py b/bigframes/ml/model_selection.py index abb4b0f26c..6eba4f81c2 100644 --- a/bigframes/ml/model_selection.py +++ b/bigframes/ml/model_selection.py @@ -18,6 +18,7 @@ import inspect +from itertools import chain import time from typing import cast, Generator, List, Optional, Union @@ -36,12 +37,9 @@ def train_test_split( train_size: Union[float, None] = None, random_state: Union[int, None] = None, stratify: Union[bpd.Series, None] = None, + shuffle: bool = True, ) -> List[Union[bpd.DataFrame, bpd.Series]]: - # TODO(garrettwu): scikit-learn throws an error when the dataframes don't have the same - # number of rows. We probably want to do something similar. Now the implementation is based - # on index. We'll move to based on ordering first. - if test_size is None: if train_size is None: test_size = 0.25 @@ -61,10 +59,30 @@ def train_test_split( f"The sum of train_size and test_size exceeds 1.0. train_size: {train_size}. test_size: {test_size}" ) + if not shuffle: + if stratify is not None: + raise ValueError( + "Stratified train/test split is not implemented for shuffle=False" + ) + bf_arrays = list(utils.batch_convert_to_bf_equivalent(*arrays)) + + total_rows = len(bf_arrays[0]) + train_rows = int(total_rows * train_size) + test_rows = total_rows - train_rows + + return list( + chain.from_iterable( + [ + [bf_array.head(train_rows), bf_array.tail(test_rows)] + for bf_array in bf_arrays + ] + ) + ) + dfs = list(utils.batch_convert_to_dataframe(*arrays)) def _stratify_split(df: bpd.DataFrame, stratify: bpd.Series) -> List[bpd.DataFrame]: - """Split a single DF accoding to the stratify Series.""" + """Split a single DF according to the stratify Series.""" stratify = stratify.rename("bigframes_stratify_col") # avoid name conflicts merged_df = df.join(stratify.to_frame(), how="outer") diff --git a/bigframes/ml/preprocessing.py b/bigframes/ml/preprocessing.py index 0448d8544a..94c61674f6 100644 --- a/bigframes/ml/preprocessing.py +++ b/bigframes/ml/preprocessing.py @@ -27,6 +27,7 @@ import bigframes_vendored.sklearn.preprocessing._polynomial from bigframes.core import log_adapter +import bigframes.core.utils as core_utils from bigframes.ml import base, core, globals, utils import bigframes.pandas as bpd @@ -59,6 +60,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) return [ self._base_sql_generator.ml_standard_scaler( column, f"standard_scaled_{column}" @@ -136,6 +138,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) return [ self._base_sql_generator.ml_max_abs_scaler( column, f"max_abs_scaled_{column}" @@ -214,6 +217,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) return [ self._base_sql_generator.ml_min_max_scaler( column, f"min_max_scaled_{column}" @@ -304,6 +308,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) array_split_points = {} if self.strategy == "uniform": for column in columns: @@ -433,8 +438,9 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) drop = self.drop if self.drop is not None else "none" - # minus one here since BQML's inplimentation always includes index 0, and top_k is on top of that. + # minus one here since BQML's implementation always includes index 0, and top_k is on top of that. top_k = ( (self.max_categories - 1) if self.max_categories is not None @@ -547,6 +553,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) # minus one here since BQML's inplimentation always includes index 0, and top_k is on top of that. top_k = ( @@ -644,6 +651,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) output_name = "poly_feat" return [ self._base_sql_generator.ml_polynomial_expand( diff --git a/bigframes/ml/remote.py b/bigframes/ml/remote.py index 6ee6840656..b091c61f3f 100644 --- a/bigframes/ml/remote.py +++ b/bigframes/ml/remote.py @@ -21,6 +21,7 @@ from bigframes.core import global_session, log_adapter import bigframes.dataframe +import bigframes.exceptions as bfe from bigframes.ml import base, core, globals, utils import bigframes.session @@ -77,19 +78,14 @@ def _create_bqml_model(self): "endpoint": self.endpoint, } - def standardize_type(v: str): - v = v.lower() - v = v.replace("boolean", "bool") - - if v not in globals._SUPPORTED_DTYPES: - raise ValueError( - f"Data type {v} is not supported. We only support {', '.join(globals._SUPPORTED_DTYPES)}." - ) - - return v - - self.input = {k: standardize_type(v) for k, v in self.input.items()} - self.output = {k: standardize_type(v) for k, v in self.output.items()} + self.input = { + k: utils.standardize_type(v, globals._REMOTE_MODEL_SUPPORTED_DTYPES) + for k, v in self.input.items() + } + self.output = { + k: utils.standardize_type(v, globals._REMOTE_MODEL_SUPPORTED_DTYPES) + for k, v in self.output.items() + } return self._bqml_model_factory.create_remote_model( session=self.session, @@ -119,7 +115,7 @@ def predict( # unlike LLM models, the general remote model status is null for successful runs. if (df[_REMOTE_MODEL_STATUS].notna()).any(): - msg = ( + msg = bfe.format_message( f"Some predictions failed. Check column {_REMOTE_MODEL_STATUS} for " "detailed status. You may want to filter the failed rows and retry." ) diff --git a/bigframes/ml/sql.py b/bigframes/ml/sql.py index b662d4c22c..2937368c92 100644 --- a/bigframes/ml/sql.py +++ b/bigframes/ml/sql.py @@ -24,6 +24,8 @@ import bigframes.core.compile.googlesql as sql_utils import bigframes.core.sql as sql_vals +INDENT_STR = " " + # TODO: Add proper escaping logic from core/compile module class BaseSqlGenerator: @@ -44,35 +46,41 @@ def encode_value(self, v: Union[str, int, float, Iterable[str]]) -> str: def build_parameters(self, **kwargs: Union[str, int, float, Iterable[str]]) -> str: """Encode a dict of values into a formatted Iterable of key-value pairs for SQL""" - indent_str = " " param_strs = [f"{k}={self.encode_value(v)}" for k, v in kwargs.items()] - return "\n" + indent_str + f",\n{indent_str}".join(param_strs) + return "\n" + INDENT_STR + f",\n{INDENT_STR}".join(param_strs) + + def build_named_parameters( + self, **kwargs: Union[str, int, float, Iterable[str]] + ) -> str: + param_strs = [f"{k} => {self.encode_value(v)}" for k, v in kwargs.items()] + return "\n" + INDENT_STR + f",\n{INDENT_STR}".join(param_strs) - def build_structs(self, **kwargs: Union[int, float]) -> str: + def build_structs(self, **kwargs: Union[int, float, str, Mapping]) -> str: """Encode a dict of values into a formatted STRUCT items for SQL""" - indent_str = " " - param_strs = [ - f"{sql_vals.simple_literal(v)} AS {sql_utils.identifier(k)}" - for k, v in kwargs.items() - ] - return "\n" + indent_str + f",\n{indent_str}".join(param_strs) + param_strs = [] + for k, v in kwargs.items(): + v_trans = self.build_schema(**v) if isinstance(v, Mapping) else v + + param_strs.append( + f"{sql_vals.simple_literal(v_trans)} AS {sql_utils.identifier(k)}" + ) + + return "\n" + INDENT_STR + f",\n{INDENT_STR}".join(param_strs) def build_expressions(self, *expr_sqls: str) -> str: """Encode a Iterable of SQL expressions into a formatted Iterable for SQL""" - indent_str = " " - return "\n" + indent_str + f",\n{indent_str}".join(expr_sqls) + return "\n" + INDENT_STR + f",\n{INDENT_STR}".join(expr_sqls) def build_schema(self, **kwargs: str) -> str: """Encode a dict of values into a formatted schema type items for SQL""" - indent_str = " " param_strs = [f"{sql_utils.identifier(k)} {v}" for k, v in kwargs.items()] - return "\n" + indent_str + f",\n{indent_str}".join(param_strs) + return "\n" + INDENT_STR + f",\n{INDENT_STR}".join(param_strs) def options(self, **kwargs: Union[str, int, float, Iterable[str]]) -> str: """Encode the OPTIONS clause for BQML""" return f"OPTIONS({self.build_parameters(**kwargs)})" - def struct_options(self, **kwargs: Union[int, float]) -> str: + def struct_options(self, **kwargs: Union[int, float, Mapping]) -> str: """Encode a BQ STRUCT as options.""" return f"STRUCT({self.build_structs(**kwargs)})" @@ -185,6 +193,17 @@ def ml_distance( https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-distance""" return f"""SELECT *, ML.DISTANCE({sql_utils.identifier(col_x)}, {sql_utils.identifier(col_y)}, '{type}') AS {sql_utils.identifier(name)} FROM ({source_sql})""" + def ai_forecast( + self, + source_sql: str, + options: Mapping[str, Union[int, float, bool, Iterable[str]]], + ): + """Encode AI.FORECAST. + https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-ai-forecast""" + named_parameters_sql = self.build_named_parameters(**options) + + return f"""SELECT * FROM AI.FORECAST(({source_sql}),{named_parameters_sql})""" + class ModelCreationSqlGenerator(BaseSqlGenerator): """Sql generator for creating a model entity. Model id is the standalone id without project id and dataset id.""" @@ -299,6 +318,11 @@ def alter_model( return "\n".join(parts) # ML prediction TVFs + def ml_recommend(self, source_sql: str) -> str: + """Encode ML.RECOMMEND for BQML""" + return f"""SELECT * FROM ML.RECOMMEND(MODEL {self._model_ref_sql()}, + ({source_sql}))""" + def ml_predict(self, source_sql: str) -> str: """Encode ML.PREDICT for BQML""" return f"""SELECT * FROM ML.PREDICT(MODEL {self._model_ref_sql()}, @@ -312,6 +336,12 @@ def ml_explain_predict( return f"""SELECT * FROM ML.EXPLAIN_PREDICT(MODEL {self._model_ref_sql()}, ({source_sql}), {struct_options_sql})""" + def ml_global_explain(self, struct_options) -> str: + """Encode ML.GLOBAL_EXPLAIN for BQML""" + struct_options_sql = self.struct_options(**struct_options) + return f"""SELECT * FROM ML.GLOBAL_EXPLAIN(MODEL {self._model_ref_sql()}, + {struct_options_sql})""" + def ml_forecast(self, struct_options: Mapping[str, Union[int, float]]) -> str: """Encode ML.FORECAST for BQML""" struct_options_sql = self.struct_options(**struct_options) @@ -395,3 +425,13 @@ def ml_transform(self, source_sql: str) -> str: """Encode ML.TRANSFORM for BQML""" return f"""SELECT * FROM ML.TRANSFORM(MODEL {self._model_ref_sql()}, ({source_sql}))""" + + def ai_generate_table( + self, + source_sql: str, + struct_options: Mapping[str, Union[int, float, bool, Mapping]], + ) -> str: + """Encode AI.GENERATE_TABLE for BQML""" + struct_options_sql = self.struct_options(**struct_options) + return f"""SELECT * FROM AI.GENERATE_TABLE(MODEL {self._model_ref_sql()}, + ({source_sql}), {struct_options_sql})""" diff --git a/bigframes/ml/utils.py b/bigframes/ml/utils.py index e034fd00f7..f97dd561be 100644 --- a/bigframes/ml/utils.py +++ b/bigframes/ml/utils.py @@ -13,7 +13,17 @@ # limitations under the License. import typing -from typing import Any, Generator, Hashable, Literal, Mapping, Optional, Tuple, Union +from typing import ( + Any, + Generator, + Hashable, + Iterable, + Literal, + Mapping, + Optional, + Tuple, + Union, +) import bigframes_vendored.constants as constants from google.cloud import bigquery @@ -69,6 +79,30 @@ def batch_convert_to_series( ) +def batch_convert_to_bf_equivalent( + *input: ArrayType, session: Optional[Session] = None +) -> Generator[Union[bpd.DataFrame, bpd.Series], None, None]: + """Converts the input to BigFrames DataFrame or Series. + + Args: + session: + The session to convert local pandas instances to BigFrames counter-parts. + It is not used if the input itself is already a BigFrame data frame or series. + + """ + _validate_sessions(*input, session=session) + + for frame in input: + if isinstance(frame, bpd.DataFrame) or isinstance(frame, pd.DataFrame): + yield convert.to_bf_dataframe(frame, default_index=None, session=session) + elif isinstance(frame, bpd.Series) or isinstance(frame, pd.Series): + yield convert.to_bf_series( + _get_only_column(frame), default_index=None, session=session + ) + else: + raise ValueError(f"Unsupported type: {type(frame)}") + + def _validate_sessions(*input: ArrayType, session: Optional[Session]): session_ids = set( i._session.session_id @@ -167,10 +201,28 @@ def combine_training_and_evaluation_data( split_col = guid.generate_guid() assert split_col not in X_train.columns + # To prevent side effects on the input dataframes, we operate on copies + X_train = X_train.copy() + X_eval = X_eval.copy() + X_train[split_col] = False X_eval[split_col] = True - X = bpd.concat([X_train, X_eval]) - y = bpd.concat([y_train, y_eval]) + + # Rename y columns to avoid collision with X columns during join + y_mapping = {col: guid.generate_guid() + str(col) for col in y_train.columns} + y_train_renamed = y_train.rename(columns=y_mapping) + y_eval_renamed = y_eval.rename(columns=y_mapping) + + # Join X and y first to preserve row alignment + train_combined = X_train.join(y_train_renamed, how="outer") + eval_combined = X_eval.join(y_eval_renamed, how="outer") + + combined = bpd.concat([train_combined, eval_combined]) + + X = combined[X_train.columns] + y = combined[list(y_mapping.values())].rename( + columns={v: k for k, v in y_mapping.items()} + ) # create options copy to not mutate the incoming one bqml_options = bqml_options.copy() @@ -178,3 +230,16 @@ def combine_training_and_evaluation_data( bqml_options["data_split_col"] = split_col return X, y, bqml_options + + +def standardize_type(v: str, supported_dtypes: Optional[Iterable[str]] = None): + t = v.lower() + t = t.replace("boolean", "bool") + + if supported_dtypes: + if t not in supported_dtypes: + raise ValueError( + f"Data type {v} is not supported. We only support {', '.join(supported_dtypes)}." + ) + + return t diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index 7e6f1f793c..5da8efaa3b 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -14,7 +14,22 @@ from __future__ import annotations -from bigframes.operations.array_ops import ArrayIndexOp, ArraySliceOp, ArrayToStringOp +from bigframes.operations.ai_ops import ( + AIClassify, + AIGenerate, + AIGenerateBool, + AIGenerateDouble, + AIGenerateInt, + AIIf, + AIScore, +) +from bigframes.operations.array_ops import ( + ArrayIndexOp, + ArrayReduceOp, + ArraySliceOp, + ArrayToStringOp, + ToArrayOp, +) from bigframes.operations.base_ops import ( BinaryOp, NaryOp, @@ -39,8 +54,13 @@ ne_op, ) from bigframes.operations.date_ops import ( + date_diff_op, day_op, dayofweek_op, + dayofyear_op, + iso_day_op, + iso_week_op, + iso_year_op, month_op, quarter_op, year_op, @@ -88,18 +108,34 @@ from bigframes.operations.geo_ops import ( geo_area_op, geo_st_astext_op, + geo_st_boundary_op, + geo_st_centroid_op, + geo_st_convexhull_op, + geo_st_difference_op, geo_st_geogfromtext_op, geo_st_geogpoint_op, + geo_st_intersection_op, + geo_st_isclosed_op, geo_x_op, geo_y_op, + GeoStBufferOp, + GeoStDistanceOp, + GeoStLengthOp, + GeoStRegionStatsOp, + GeoStSimplifyOp, ) from bigframes.operations.json_ops import ( JSONExtract, JSONExtractArray, JSONExtractStringArray, + JSONKeys, + JSONQuery, + JSONQueryArray, JSONSet, JSONValue, + JSONValueArray, ParseJSON, + ToJSON, ToJSONString, ) from bigframes.operations.numeric_ops import ( @@ -161,11 +197,9 @@ isupper_op, len_op, lower_op, - lstrip_op, RegexReplaceStrOp, ReplaceStrOp, reverse_op, - rstrip_op, StartsWithOp, strconcat_op, StrContainsOp, @@ -174,16 +208,20 @@ StrFindOp, StrGetOp, StringSplitOp, - strip_op, + StrLstripOp, StrPadOp, StrRepeatOp, + StrRstripOp, StrSliceOp, + StrStripOp, upper_op, ZfillOp, ) from bigframes.operations.struct_ops import StructFieldOp, StructOp from bigframes.operations.time_ops import hour_op, minute_op, normalize_op, second_op from bigframes.operations.timedelta_ops import ( + date_add_op, + date_sub_op, timedelta_floor_op, timestamp_add_op, timestamp_sub_op, @@ -229,11 +267,9 @@ "isupper_op", "len_op", "lower_op", - "lstrip_op", "RegexReplaceStrOp", "ReplaceStrOp", "reverse_op", - "rstrip_op", "StartsWithOp", "strconcat_op", "StrContainsOp", @@ -241,25 +277,35 @@ "StrExtractOp", "StrFindOp", "StrGetOp", + "StrLstripOp", "StringSplitOp", "strip_op", "StrPadOp", "StrRepeatOp", + "StrRstripOp", "StrSliceOp", + "StrStripOp", "upper_op", "ZfillOp", # Date ops + "date_diff_op", "day_op", - "month_op", - "year_op", "dayofweek_op", + "dayofyear_op", + "iso_day_op", + "iso_week_op", + "iso_year_op", + "month_op", "quarter_op", + "year_op", # Time ops "hour_op", "minute_op", "second_op", "normalize_op", # Timedelta ops + "date_add_op", + "date_sub_op", "timedelta_floor_op", "timestamp_add_op", "timestamp_sub_op", @@ -336,9 +382,14 @@ "JSONExtract", "JSONExtractArray", "JSONExtractStringArray", + "JSONKeys", + "JSONQuery", + "JSONQueryArray", "JSONSet", "JSONValue", + "JSONValueArray", "ParseJSON", + "ToJSON", "ToJSONString", # Bool ops "and_op", @@ -358,12 +409,33 @@ "manhattan_distance_op", # Geo ops "geo_area_op", + "geo_st_boundary_op", + "geo_st_centroid_op", + "geo_st_convexhull_op", + "geo_st_difference_op", "geo_st_astext_op", "geo_st_geogfromtext_op", "geo_st_geogpoint_op", + "geo_st_intersection_op", + "geo_st_isclosed_op", "geo_x_op", "geo_y_op", + "GeoStBufferOp", + "GeoStDistanceOp", + "GeoStLengthOp", + "GeoStRegionStatsOp", + "GeoStSimplifyOp", + # AI ops + "AIClassify", + "AIGenerate", + "AIGenerateBool", + "AIGenerateDouble", + "AIGenerateInt", + "AIIf", + "AIScore", # Numpy ops mapping "NUMPY_TO_BINOP", "NUMPY_TO_OP", + "ToArrayOp", + "ArrayReduceOp", ] diff --git a/bigframes/operations/_matplotlib/__init__.py b/bigframes/operations/_matplotlib/__init__.py index 5f99d3b50a..caacadf5fe 100644 --- a/bigframes/operations/_matplotlib/__init__.py +++ b/bigframes/operations/_matplotlib/__init__.py @@ -22,6 +22,8 @@ PLOT_CLASSES: dict[str, PLOT_TYPES] = { "area": core.AreaPlot, "bar": core.BarPlot, + "barh": core.BarhPlot, + "pie": core.PiePlot, "line": core.LinePlot, "scatter": core.ScatterPlot, "hist": hist.HistPlot, diff --git a/bigframes/operations/_matplotlib/core.py b/bigframes/operations/_matplotlib/core.py index 9c68a2c5ca..06fb5235d7 100644 --- a/bigframes/operations/_matplotlib/core.py +++ b/bigframes/operations/_matplotlib/core.py @@ -20,6 +20,7 @@ import pandas as pd import bigframes.dtypes as dtypes +import bigframes.exceptions as bfe DEFAULT_SAMPLING_N = 1000 DEFAULT_SAMPLING_STATE = 0 @@ -54,7 +55,12 @@ def _kind(self): @property def _sampling_warning_msg(self) -> typing.Optional[str]: - return None + return ( + "To optimize plotting performance, your data has been downsampled to {sampling_n} " + "rows from the original {total_n} rows. This may result in some data points " + "not being displayed. For a more comprehensive view, consider pre-processing " + "your data by aggregating it or selecting the top categories." + ) def __init__(self, data, **kwargs) -> None: self.kwargs = kwargs @@ -70,10 +76,12 @@ def _compute_sample_data(self, data): if self._sampling_warning_msg is not None: total_n = data.shape[0] if sampling_n < total_n: - msg = self._sampling_warning_msg.format( - sampling_n=sampling_n, total_n=total_n + msg = bfe.format_message( + self._sampling_warning_msg.format( + sampling_n=sampling_n, total_n=total_n + ) ) - warnings.warn(msg) + warnings.warn(msg, category=UserWarning) sampling_random_state = self.kwargs.pop( "sampling_random_state", DEFAULT_SAMPLING_STATE @@ -89,6 +97,10 @@ def _compute_plot_data(self): class AreaPlot(SamplingPlot): + @property + def _sampling_warning_msg(self) -> typing.Optional[str]: + return None + @property def _kind(self) -> typing.Literal["area"]: return "area" @@ -99,14 +111,17 @@ class BarPlot(SamplingPlot): def _kind(self) -> typing.Literal["bar"]: return "bar" + +class BarhPlot(SamplingPlot): @property - def _sampling_warning_msg(self) -> typing.Optional[str]: - return ( - "To optimize plotting performance, your data has been downsampled to {sampling_n} " - "rows from the original {total_n} rows. This may result in some data points " - "not being displayed. For a more comprehensive view, consider pre-processing " - "your data by aggregating it or selecting the top categories." - ) + def _kind(self) -> typing.Literal["barh"]: + return "barh" + + +class PiePlot(SamplingPlot): + @property + def _kind(self) -> typing.Literal["pie"]: + return "pie" class LinePlot(SamplingPlot): @@ -120,6 +135,10 @@ class ScatterPlot(SamplingPlot): def _kind(self) -> typing.Literal["scatter"]: return "scatter" + @property + def _sampling_warning_msg(self) -> typing.Optional[str]: + return None + def __init__(self, data, **kwargs) -> None: super().__init__(data, **kwargs) diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index e9d102b42d..5fe8330263 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -17,14 +17,19 @@ import abc import dataclasses import typing -from typing import ClassVar, Iterable, Optional +from typing import Callable, ClassVar, Iterable, Optional, TYPE_CHECKING +import numpy as np import pandas as pd import pyarrow as pa +from bigframes.core import agg_expressions import bigframes.dtypes as dtypes import bigframes.operations.type as signatures +if TYPE_CHECKING: + from bigframes.core import expression + @dataclasses.dataclass(frozen=True) class WindowOp: @@ -33,6 +38,11 @@ def skips_nulls(self): """Whether the window op skips null rows.""" return True + @property + def nulls_count_for_min_values(self) -> bool: + """Whether null values count for min_values.""" + return not self.skips_nulls + @property def implicitly_inherits_order(self): """ @@ -58,6 +68,11 @@ def order_independent(self): def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: ... + @property + def can_be_windowized(self): + # this is more of an engine property, but will treat feasibility in bigquery sql as source of truth + return True + @dataclasses.dataclass(frozen=True) class NullaryWindowOp(WindowOp): @@ -105,6 +120,14 @@ class NullaryAggregateOp(AggregateOp, NullaryWindowOp): def arguments(self) -> int: return 0 + def as_expr( + self, + *exprs: typing.Union[str, expression.Expression], + ) -> agg_expressions.NullaryAggregation: + from bigframes.core import agg_expressions + + return agg_expressions.NullaryAggregation(self) + @dataclasses.dataclass(frozen=True) class UnaryAggregateOp(AggregateOp, UnaryWindowOp): @@ -112,6 +135,23 @@ class UnaryAggregateOp(AggregateOp, UnaryWindowOp): def arguments(self) -> int: return 1 + def as_expr( + self, + *exprs: typing.Union[str, expression.Expression], + ) -> agg_expressions.UnaryAggregation: + from bigframes.core import agg_expressions + from bigframes.operations.base_ops import _convert_expr_input + + # Keep this in sync with output_type and compilers + inputs: list[expression.Expression] = [] + + for expr in exprs: + inputs.append(_convert_expr_input(expr)) + return agg_expressions.UnaryAggregation( + self, + inputs[0], + ) + @dataclasses.dataclass(frozen=True) class BinaryAggregateOp(AggregateOp): @@ -119,6 +159,21 @@ class BinaryAggregateOp(AggregateOp): def arguments(self) -> int: return 2 + def as_expr( + self, + *exprs: typing.Union[str, expression.Expression], + ) -> agg_expressions.BinaryAggregation: + from bigframes.core import agg_expressions + from bigframes.operations.base_ops import _convert_expr_input + + # Keep this in sync with output_type and compilers + inputs: list[expression.Expression] = [] + + for expr in exprs: + inputs.append(_convert_expr_input(expr)) + + return agg_expressions.BinaryAggregation(self, inputs[0], inputs[1]) + @dataclasses.dataclass(frozen=True) class SizeOp(NullaryAggregateOp): @@ -133,6 +188,10 @@ def output_type(self, *input_types: dtypes.ExpressionType): class SizeUnaryOp(UnaryAggregateOp): name: ClassVar[str] = "size" + @property + def skips_nulls(self): + return False + def output_type(self, *input_types: dtypes.ExpressionType): return dtypes.INT_DTYPE @@ -142,13 +201,16 @@ class SumOp(UnaryAggregateOp): name: ClassVar[str] = "sum" def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - if not dtypes.is_numeric(input_types[0]): - raise TypeError(f"Type {input_types[0]} is not numeric") - if pd.api.types.is_bool_dtype(input_types[0]): - return dtypes.INT_DTYPE - else: + if input_types[0] == dtypes.TIMEDELTA_DTYPE: + return dtypes.TIMEDELTA_DTYPE + + if dtypes.is_numeric(input_types[0]): + if pd.api.types.is_bool_dtype(input_types[0]): + return dtypes.INT_DTYPE return input_types[0] + raise TypeError(f"Type {input_types[0]} is not numeric or timedelta") + @dataclasses.dataclass(frozen=True) class MedianOp(UnaryAggregateOp): @@ -171,6 +233,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT @dataclasses.dataclass(frozen=True) class QuantileOp(UnaryAggregateOp): q: float + should_floor_result: bool = False @property def name(self): @@ -181,6 +244,8 @@ def order_independent(self) -> bool: return True def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if input_types[0] == dtypes.TIMEDELTA_DTYPE: + return dtypes.TIMEDELTA_DTYPE return signatures.UNARY_REAL_NUMERIC.output_type(input_types[0]) @@ -195,12 +260,11 @@ def name(self): def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: if not dtypes.is_orderable(input_types[0]): raise TypeError(f"Type {input_types[0]} is not orderable") - if pd.api.types.is_bool_dtype(input_types[0]) or pd.api.types.is_integer_dtype( - input_types[0] - ): - return dtypes.FLOAT_DTYPE - else: - return input_types[0] + return input_types[0] + + @property + def can_be_windowized(self): + return False @dataclasses.dataclass(frozen=True) @@ -219,12 +283,20 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT ] return pd.ArrowDtype(pa.list_(pa.struct(fields))) + @property + def can_be_windowized(self): + return False + @dataclasses.dataclass(frozen=True) class MeanOp(UnaryAggregateOp): name: ClassVar[str] = "mean" + should_floor_result: bool = False + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if input_types[0] == dtypes.TIMEDELTA_DTYPE: + return dtypes.TIMEDELTA_DTYPE return signatures.UNARY_REAL_NUMERIC.output_type(input_types[0]) @@ -262,7 +334,12 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT class StdOp(UnaryAggregateOp): name: ClassVar[str] = "std" + should_floor_result: bool = False + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if input_types[0] == dtypes.TIMEDELTA_DTYPE: + return dtypes.TIMEDELTA_DTYPE + return signatures.FixedOutputType( dtypes.is_numeric, dtypes.FLOAT_DTYPE, "numeric" ).output_type(input_types[0]) @@ -315,37 +392,67 @@ def skips_nulls(self): return True def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - return pd.ArrowDtype( - pa.list_(dtypes.bigframes_dtype_to_arrow_dtype(input_types[0])) - ) + return dtypes.list_type(input_types[0]) + + +@dataclasses.dataclass(frozen=True) +class StringAggOp(UnaryAggregateOp): + name: ClassVar[str] = "string_agg" + sep: str = "," + + @property + def order_independent(self): + return False + + @property + def skips_nulls(self): + return True + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if input_types[0] != dtypes.STRING_DTYPE: + raise TypeError(f"Type {input_types[0]} is not string-like") + return dtypes.STRING_DTYPE @dataclasses.dataclass(frozen=True) class CutOp(UnaryWindowOp): # TODO: Unintuitive, refactor into multiple ops? bins: typing.Union[int, Iterable] - labels: Optional[bool] + right: Optional[bool] + labels: typing.Union[bool, Iterable[str], None] @property def skips_nulls(self): return False def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - if isinstance(self.bins, int) and (self.labels is False): + if self.labels is False: return dtypes.INT_DTYPE + elif isinstance(self.labels, Iterable): + return dtypes.STRING_DTYPE else: # Assumption: buckets use same numeric type - interval_dtype = ( - pa.float64() - if isinstance(self.bins, int) - else dtypes.infer_literal_arrow_type(list(self.bins)[0][0]) - ) + if isinstance(self.bins, int): + interval_dtype = pa.float64() + elif len(list(self.bins)) == 0: + interval_dtype = pa.int64() + else: + interval_dtype = dtypes.infer_literal_arrow_type(list(self.bins)[0][0]) pa_type = pa.struct( [ - pa.field("left_exclusive", interval_dtype, nullable=True), - pa.field("right_inclusive", interval_dtype, nullable=True), + pa.field( + "left_exclusive" if self.right else "left_inclusive", + interval_dtype, + nullable=True, + ), + pa.field( + "right_inclusive" if self.right else "right_exclusive", + interval_dtype, + nullable=True, + ), ] ) + return pd.ArrowDtype(pa_type) @property @@ -411,7 +518,6 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT return dtypes.INT_DTYPE -# TODO: Convert to NullaryWindowOp @dataclasses.dataclass(frozen=True) class RankOp(UnaryWindowOp): name: ClassVar[str] = "rank" @@ -428,9 +534,10 @@ def implicitly_inherits_order(self): return False -# TODO: Convert to NullaryWindowOp @dataclasses.dataclass(frozen=True) class DenseRankOp(UnaryWindowOp): + name: ClassVar[str] = "dense_rank" + @property def skips_nulls(self): return False @@ -454,6 +561,10 @@ class FirstNonNullOp(UnaryWindowOp): def skips_nulls(self): return False + @property + def nulls_count_for_min_values(self) -> bool: + return False + @dataclasses.dataclass(frozen=True) class LastOp(UnaryWindowOp): @@ -466,6 +577,10 @@ class LastNonNullOp(UnaryWindowOp): def skips_nulls(self): return False + @property + def nulls_count_for_min_values(self) -> bool: + return False + @dataclasses.dataclass(frozen=True) class ShiftOp(UnaryWindowOp): @@ -478,6 +593,7 @@ def skips_nulls(self): @dataclasses.dataclass(frozen=True) class DiffOp(UnaryWindowOp): + name: ClassVar[str] = "diff" periods: int @property @@ -485,7 +601,7 @@ def skips_nulls(self): return False def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - if dtypes.is_datetime_like(input_types[0]): + if dtypes.is_date_like(input_types[0]): return dtypes.TIMEDELTA_DTYPE return super().output_type(*input_types) @@ -504,6 +620,20 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT raise TypeError(f"expect datetime-like types, but got {input_types[0]}") +@dataclasses.dataclass(frozen=True) +class DateSeriesDiffOp(UnaryWindowOp): + periods: int + + @property + def skips_nulls(self): + return False + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if input_types[0] == dtypes.DATE_DTYPE: + return dtypes.TIMEDELTA_DTYPE + raise TypeError(f"expect date type, but got {input_types[0]}") + + @dataclasses.dataclass(frozen=True) class AllOp(UnaryAggregateOp): name: ClassVar[str] = "all" @@ -563,7 +693,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT # TODO: Alternative names and lookup from numpy function objects -_AGGREGATIONS_LOOKUP: typing.Dict[ +_STRING_TO_AGG_OP: typing.Dict[ str, typing.Union[UnaryAggregateOp, NullaryAggregateOp] ] = { op.name: op @@ -590,17 +720,38 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT ] } +_CALLABLE_TO_AGG_OP: typing.Dict[ + Callable, typing.Union[UnaryAggregateOp, NullaryAggregateOp] +] = { + np.sum: sum_op, + np.mean: mean_op, + np.median: median_op, + np.prod: product_op, + np.max: max_op, + np.min: min_op, + np.std: std_op, + np.var: var_op, + np.all: all_op, + np.any: any_op, + np.unique: nunique_op, + np.size: size_op, + # TODO(b/443252872): Solve + list: ArrayAggOp(), + len: size_op, + sum: sum_op, + min: min_op, + max: max_op, + any: any_op, + all: all_op, +} -def lookup_agg_func(key: str) -> typing.Union[UnaryAggregateOp, NullaryAggregateOp]: - if callable(key): - raise NotImplementedError( - "Aggregating with callable object not supported, pass method name as string instead (eg. 'sum' instead of np.sum)." - ) - if not isinstance(key, str): - raise ValueError( - f"Cannot aggregate using object of type: {type(key)}. Use string method name (eg. 'sum')" - ) - if key in _AGGREGATIONS_LOOKUP: - return _AGGREGATIONS_LOOKUP[key] + +def lookup_agg_func( + key, +) -> tuple[typing.Union[UnaryAggregateOp, NullaryAggregateOp], str]: + if key in _STRING_TO_AGG_OP: + return (_STRING_TO_AGG_OP[key], key) + if key in _CALLABLE_TO_AGG_OP: + return (_CALLABLE_TO_AGG_OP[key], key.__name__) else: raise ValueError(f"Unrecognize aggregate function: {key}") diff --git a/bigframes/operations/ai.py b/bigframes/operations/ai.py new file mode 100644 index 0000000000..ad58e8825c --- /dev/null +++ b/bigframes/operations/ai.py @@ -0,0 +1,845 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import re +import typing +from typing import Dict, Iterable, List, Optional, Sequence, Union +import warnings + +from bigframes import dtypes, exceptions, options +from bigframes.core import guid, log_adapter + + +@log_adapter.class_logger +class AIAccessor: + def __init__(self, df, base_bqml=None) -> None: + import bigframes # Import in the function body to avoid circular imports. + import bigframes.dataframe + from bigframes.ml import core as ml_core + + self._df: bigframes.dataframe.DataFrame = df + self._base_bqml: ml_core.BaseBqml = base_bqml or ml_core.BaseBqml(df._session) + + def filter( + self, + instruction: str, + model, + ground_with_google_search: bool = False, + ): + """ + Filters the DataFrame with the semantics of the user instruction. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> bpd.options.experiments.ai_operators = True + >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 + + >>> import bigframes.ml.llm as llm + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") + + >>> df = bpd.DataFrame({"country": ["USA", "Germany"], "city": ["Seattle", "Berlin"]}) + >>> df.ai.filter("{city} is the capital of {country}", model) + country city + 1 Germany Berlin + + [1 rows x 2 columns] + + Args: + instruction (str): + An instruction on how to filter the data. This value must contain + column references by name, which should be wrapped in a pair of braces. + For example, if you have a column "food", you can refer to this column + in the instructions like: + "The {food} is healthy." + + model (bigframes.ml.llm.GeminiTextGenerator): + A GeminiTextGenerator provided by Bigframes ML package. + + ground_with_google_search (bool, default False): + Enables Grounding with Google Search for the GeminiTextGenerator model. + When set to True, the model incorporates relevant information from Google + Search results into its responses, enhancing their accuracy and factualness. + Note: Using this feature may impact billing costs. Refer to the pricing + page for details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models + The default is `False`. + + Returns: + bigframes.pandas.DataFrame: DataFrame filtered by the instruction. + + Raises: + NotImplementedError: when the AI operator experiment is off. + ValueError: when the instruction refers to a non-existing column, or when no + columns are referred to. + """ + if not options.experiments.ai_operators: + raise NotImplementedError() + + answer_col = "answer" + + output_schema = {answer_col: "bool"} + result = self.map( + instruction, + model, + output_schema, + ground_with_google_search, + ) + + return result[result[answer_col]].drop(answer_col, axis=1) + + def map( + self, + instruction: str, + model, + output_schema: Dict[str, str] | None = None, + ground_with_google_search: bool = False, + ): + """ + Maps the DataFrame with the semantics of the user instruction. The name of the keys in the output_schema parameter carry + semantic meaning, and can be used for information extraction. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> bpd.options.experiments.ai_operators = True + >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 + + >>> import bigframes.ml.llm as llm + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") + + >>> df = bpd.DataFrame({"ingredient_1": ["Burger Bun", "Soy Bean"], "ingredient_2": ["Beef Patty", "Bittern"]}) + >>> df.ai.map("What is the food made from {ingredient_1} and {ingredient_2}? One word only.", model=model, output_schema={"food": "string"}) + ingredient_1 ingredient_2 food + 0 Burger Bun Beef Patty Burger + + 1 Soy Bean Bittern Tofu + + + [2 rows x 3 columns] + + + >>> import bigframes.pandas as bpd + >>> bpd.options.experiments.ai_operators = True + >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 + + >>> import bigframes.ml.llm as llm + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") + + >>> df = bpd.DataFrame({"text": ["Elmo lives at 123 Sesame Street."]}) + >>> df.ai.map("{text}", model=model, output_schema={"person": "string", "address": "string"}) + text person address + 0 Elmo lives at 123 Sesame Street. Elmo 123 Sesame Street + + [1 rows x 3 columns] + + Args: + instruction (str): + An instruction on how to map the data. This value must contain + column references by name, which should be wrapped in a pair of braces. + For example, if you have a column "food", you can refer to this column + in the instructions like: + "Get the ingredients of {food}." + + model (bigframes.ml.llm.GeminiTextGenerator): + A GeminiTextGenerator provided by Bigframes ML package. + + output_schema (Dict[str, str] or None, default None): + The schema used to generate structured output as a bigframes DataFrame. The schema is a string key-value pair of :. + Supported types are int64, float64, bool, string, array and struct. If None, generate string result under the column + "ml_generate_text_llm_result". + + ground_with_google_search (bool, default False): + Enables Grounding with Google Search for the GeminiTextGenerator model. + When set to True, the model incorporates relevant information from Google + Search results into its responses, enhancing their accuracy and factualness. + Note: Using this feature may impact billing costs. Refer to the pricing + page for details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models + The default is `False`. + + Returns: + bigframes.pandas.DataFrame: DataFrame with attached mapping results. + + Raises: + NotImplementedError: when the AI operator experiment is off. + ValueError: when the instruction refers to a non-existing column, or when no + columns are referred to. + """ + if not options.experiments.ai_operators: + raise NotImplementedError() + + import bigframes.dataframe + import bigframes.series + + self._validate_model(model) + columns = self._parse_columns(instruction) + for column in columns: + if column not in self._df.columns: + raise ValueError(f"Column {column} not found.") + + if ground_with_google_search: + msg = exceptions.format_message( + "Enables Grounding with Google Search may impact billing cost. See pricing " + "details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models" + ) + warnings.warn(msg, category=UserWarning) + + self._confirm_operation(len(self._df)) + + df: bigframes.dataframe.DataFrame = self._df[columns].copy() + has_blob_column = False + for column in columns: + if df[column].dtype == dtypes.OBJ_REF_DTYPE: + # Don't cast blob columns to string + has_blob_column = True + continue + + if df[column].dtype != dtypes.STRING_DTYPE: + df[column] = df[column].astype(dtypes.STRING_DTYPE) + + user_instruction = self._format_instruction(instruction, columns) + output_instruction = ( + "Based on the provided contenxt, answer the following instruction:" + ) + + if output_schema is None: + output_schema = {"ml_generate_text_llm_result": "string"} + + if has_blob_column: + results = typing.cast( + bigframes.series.Series, + model.predict( + df, + prompt=self._make_multimodel_prompt( + df, columns, user_instruction, output_instruction + ), + temperature=0.0, + ground_with_google_search=ground_with_google_search, + output_schema=output_schema, + ), + ) + else: + results = typing.cast( + bigframes.series.Series, + model.predict( + self._make_text_prompt( + df, columns, user_instruction, output_instruction + ), + temperature=0.0, + ground_with_google_search=ground_with_google_search, + output_schema=output_schema, + ), + ) + + attach_columns = [results[col] for col, _ in output_schema.items()] + + from bigframes.core.reshape.api import concat + + return concat([self._df, *attach_columns], axis=1) + + def classify( + self, + instruction: str, + model, + labels: Sequence[str], + output_column: str = "result", + ground_with_google_search: bool = False, + ): + """ + Classifies the rows of dataframes based on user instruction into the provided labels. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> bpd.options.experiments.ai_operators = True + >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 + + >>> import bigframes.ml.llm as llm + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") + + >>> df = bpd.DataFrame({ + ... "feedback_text": [ + ... "The product is amazing, but the shipping was slow.", + ... "I had an issue with my recent bill.", + ... "The user interface is very intuitive." + ... ], + ... }) + >>> df.ai.classify("{feedback_text}", model=model, labels=["Shipping", "Billing", "UI"]) + feedback_text result + 0 The product is amazing, but the shipping was s... Shipping + 1 I had an issue with my recent bill. Billing + 2 The user interface is very intuitive. UI + + [3 rows x 2 columns] + + Args: + instruction (str): + An instruction on how to classify the data. This value must contain + column references by name, which should be wrapped in a pair of braces. + For example, if you have a column "feedback", you can refer to this column + with"{food}". + + model (bigframes.ml.llm.GeminiTextGenerator): + A GeminiTextGenerator provided by Bigframes ML package. + + labels (Sequence[str]): + A collection of labels (categories). It must contain at least two and at most 20 elements. + Labels are case sensitive. Duplicated labels are not allowed. + + output_column (str, default "result"): + The name of column for the output. + + ground_with_google_search (bool, default False): + Enables Grounding with Google Search for the GeminiTextGenerator model. + When set to True, the model incorporates relevant information from Google + Search results into its responses, enhancing their accuracy and factualness. + Note: Using this feature may impact billing costs. Refer to the pricing + page for details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models + The default is `False`. + + Returns: + bigframes.pandas.DataFrame: DataFrame with classification result. + + Raises: + NotImplementedError: when the AI operator experiment is off. + ValueError: when the instruction refers to a non-existing column, when no + columns are referred to, or when the count of labels does not meet the + requirement. + """ + if not options.experiments.ai_operators: + raise NotImplementedError() + + if len(labels) < 2 or len(labels) > 20: + raise ValueError( + f"The number of labels should be between 2 and 20 (inclusive), but {len(labels)} labels are provided." + ) + + if len(set(labels)) != len(labels): + raise ValueError("There are duplicate labels.") + + updated_instruction = f"Based on the user instruction {instruction}, you must provide an answer that must exist in the following list of labels: {labels}" + + return self.map( + updated_instruction, + model, + output_schema={output_column: "string"}, + ground_with_google_search=ground_with_google_search, + ) + + def join( + self, + other, + instruction: str, + model, + ground_with_google_search: bool = False, + ): + """ + Joines two dataframes by applying the instruction over each pair of rows from + the left and right table. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> bpd.options.experiments.ai_operators = True + >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 + + >>> import bigframes.ml.llm as llm + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") + + >>> cities = bpd.DataFrame({'city': ['Seattle', 'Ottawa', 'Berlin', 'Shanghai', 'New Delhi']}) + >>> continents = bpd.DataFrame({'continent': ['North America', 'Africa', 'Asia']}) + + >>> cities.ai.join(continents, "{city} is in {continent}", model) + city continent + 0 Seattle North America + 1 Ottawa North America + 2 Shanghai Asia + 3 New Delhi Asia + + [4 rows x 2 columns] + + Args: + other (bigframes.pandas.DataFrame): + The other dataframe. + + instruction (str): + An instruction on how left and right rows can be joined. This value must contain + column references by name. which should be wrapped in a pair of braces. + For example: "The {city} belongs to the {country}". + For column names that are shared between two dataframes, you need to add "left." + and "right." prefix for differentiation. This is especially important when you do + self joins. For example: "The {left.employee_name} reports to {right.employee_name}" + For unique column names, this prefix is optional. + + model (bigframes.ml.llm.GeminiTextGenerator): + A GeminiTextGenerator provided by Bigframes ML package. + + ground_with_google_search (bool, default False): + Enables Grounding with Google Search for the GeminiTextGenerator model. + When set to True, the model incorporates relevant information from Google + Search results into its responses, enhancing their accuracy and factualness. + Note: Using this feature may impact billing costs. Refer to the pricing + page for details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models + The default is `False`. + + Returns: + bigframes.pandas.DataFrame: The joined dataframe. + + Raises: + ValueError if the amount of data that will be sent for LLM processing is larger than max_rows. + """ + if not options.experiments.ai_operators: + raise NotImplementedError() + + self._validate_model(model) + columns = self._parse_columns(instruction) + + if ground_with_google_search: + msg = exceptions.format_message( + "Enables Grounding with Google Search may impact billing cost. See pricing " + "details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models" + ) + warnings.warn(msg, category=UserWarning) + + work_estimate = len(self._df) * len(other) + self._confirm_operation(work_estimate) + + left_columns = [] + right_columns = [] + + for col in columns: + if col in self._df.columns and col in other.columns: + raise ValueError(f"Ambiguous column reference: {col}") + + elif col in self._df.columns: + left_columns.append(col) + + elif col in other.columns: + right_columns.append(col) + + elif col.startswith("left."): + original_col_name = col[len("left.") :] + if ( + original_col_name in self._df.columns + and original_col_name in other.columns + ): + left_columns.append(col) + elif original_col_name in self._df.columns: + left_columns.append(col) + instruction = instruction.replace(col, original_col_name) + else: + raise ValueError(f"Column {col} not found") + + elif col.startswith("right."): + original_col_name = col[len("right.") :] + if ( + original_col_name in self._df.columns + and original_col_name in other.columns + ): + right_columns.append(col) + elif original_col_name in other.columns: + right_columns.append(col) + instruction = instruction.replace(col, original_col_name) + else: + raise ValueError(f"Column {col} not found") + + else: + raise ValueError(f"Column {col} not found") + + if not left_columns: + raise ValueError("No left column references.") + + if not right_columns: + raise ValueError("No right column references.") + + # Update column references to be compatible with internal naming scheme. + # That is, "left.col" -> "col_left" and "right.col" -> "col_right" + instruction = re.sub(r"(?>> import bigframes.pandas as bpd + + >>> import bigframes + >>> bigframes.options.experiments.ai_operators = True + >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 + + >>> import bigframes.ml.llm as llm + >>> model = llm.TextEmbeddingGenerator(model_name="text-embedding-005") + + >>> df = bpd.DataFrame({"creatures": ["salmon", "sea urchin", "frog", "chimpanzee"]}) + >>> df.ai.search("creatures", "monkey", top_k=1, model=model, score_column='distance') + creatures distance + 3 chimpanzee 0.635844 + + [1 rows x 2 columns] + + Args: + search_column: + The name of the column to search from. + query (str): + The search query. + top_k (int): + The number of nearest neighbors to return. + model (TextEmbeddingGenerator): + A TextEmbeddingGenerator provided by Bigframes ML package. + score_column (Optional[str], default None): + The name of the the additional column containning the similarity scores. If None, + this column won't be attached to the result. + + Returns: + DataFrame: the DataFrame with the search result. + + Raises: + ValueError: when the search_column is not found from the the data frame. + TypeError: when the provided model is not TextEmbeddingGenerator. + """ + if not options.experiments.ai_operators: + raise NotImplementedError() + + if search_column not in self._df.columns: + raise ValueError(f"Column `{search_column}` not found") + + self._confirm_operation(len(self._df)) + + import bigframes.ml.llm as llm + + if not isinstance(model, llm.TextEmbeddingGenerator): + raise TypeError(f"Expect a text embedding model, but got: {type(model)}") + + if top_k < 1: + raise ValueError("top_k must be an integer greater than or equal to 1.") + + embedded_df = model.predict(self._df[search_column]) + embedded_table = embedded_df.reset_index().to_gbq() + + import bigframes.pandas as bpd + + embedding_result_column = "ml_generate_embedding_result" + query_df = model.predict(bpd.DataFrame({"query_id": [query]})).rename( + columns={"content": "query_id", embedding_result_column: "embedding"} + ) + + import bigframes.bigquery as bbq + + search_result = ( + bbq.vector_search( + base_table=embedded_table, + column_to_search=embedding_result_column, + query=query_df, + top_k=top_k, + # TODO(tswast): set allow_large_results based on Series size. + # If we expect small results, it could be faster to set + # allow_large_results to False. + allow_large_results=True, + ) + .rename(columns={"content": search_column}) + .set_index("index") + ) + + search_result.index.name = self._df.index.name + + if score_column is not None: + search_result = search_result.rename(columns={"distance": score_column})[ + [search_column, score_column] + ] + else: + search_result = search_result[[search_column]] + + import bigframes.dataframe + + return typing.cast(bigframes.dataframe.DataFrame, search_result) + + def sim_join( + self, + other, + left_on: str, + right_on: str, + model, + top_k: int = 3, + score_column: Optional[str] = None, + max_rows: int = 1000, + ): + """ + Joins two dataframes based on the similarity of the specified columns. + + This method uses BigQuery's VECTOR_SEARCH function to match rows on the left side with the rows that have + nearest embedding vectors on the right. In the worst case scenario, the complexity is around O(M * N * log K). + Therefore, this is a potentially expensive operation. + + ** Examples: ** + + >>> import bigframes.pandas as bpd + >>> bpd.options.experiments.ai_operators = True + >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 + + >>> import bigframes.ml.llm as llm + >>> model = llm.TextEmbeddingGenerator(model_name="text-embedding-005") + + >>> df1 = bpd.DataFrame({'animal': ['monkey', 'spider']}) + >>> df2 = bpd.DataFrame({'animal': ['scorpion', 'baboon']}) + + >>> df1.ai.sim_join(df2, left_on='animal', right_on='animal', model=model, top_k=1) + animal animal_1 + 0 monkey baboon + 1 spider scorpion + + [2 rows x 2 columns] + + Args: + other (DataFrame): + The other data frame to join with. + left_on (str): + The name of the column on left side for the join. + right_on (str): + The name of the column on the right side for the join. + top_k (int, default 3): + The number of nearest neighbors to return. + model (TextEmbeddingGenerator): + A TextEmbeddingGenerator provided by Bigframes ML package. + score_column (Optional[str], default None): + The name of the the additional column containning the similarity scores. If None, + this column won't be attached to the result. + max_rows: + The maximum number of rows allowed to be processed per call. If the result is too large, the method + call will end early with an error. + + Returns: + DataFrame: the data frame with the join result. + + Raises: + ValueError: when the amount of data to be processed exceeds the specified max_rows. + """ + if not options.experiments.ai_operators: + raise NotImplementedError() + + if left_on not in self._df.columns: + raise ValueError(f"Left column {left_on} not found") + if right_on not in self._df.columns: + raise ValueError(f"Right column {right_on} not found") + + import bigframes.ml.llm as llm + + if not isinstance(model, llm.TextEmbeddingGenerator): + raise TypeError(f"Expect a text embedding model, but got: {type(model)}") + + joined_table_rows = len(self._df) * len(other) + if joined_table_rows > max_rows: + raise ValueError( + f"Number of rows that need processing is {joined_table_rows}, which exceeds row limit {max_rows}." + ) + + if top_k < 1: + raise ValueError("top_k must be an integer greater than or equal to 1.") + + work_estimate = len(self._df) * len(other) + self._confirm_operation(work_estimate) + + base_table_embedding_column = guid.generate_guid() + base_table = self._attach_embedding( + other, right_on, base_table_embedding_column, model + ).to_gbq() + query_table = self._attach_embedding(self._df, left_on, "embedding", model) + + import bigframes.bigquery as bbq + + join_result = bbq.vector_search( + base_table=base_table, + column_to_search=base_table_embedding_column, + query=query_table, + top_k=top_k, + ) + + join_result = join_result.drop( + ["embedding", base_table_embedding_column], axis=1 + ) + + if score_column is not None: + join_result = join_result.rename(columns={"distance": score_column}) + else: + del join_result["distance"] + + return join_result + + def forecast( + self, + timestamp_column: str, + data_column: str, + *, + model: str = "TimesFM 2.0", + id_columns: Optional[Iterable[str]] = None, + horizon: int = 10, + confidence_level: float = 0.95, + ): + """ + Forecast time series at future horizon. Using Google Research's open source TimesFM(https://github.com/google-research/timesfm) model. + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + timestamp_column (str): + A str value that specified the name of the time points column. + The time points column provides the time points used to generate the forecast. + The time points column must use one of the following data types: TIMESTAMP, DATE and DATETIME + data_column (str): + A str value that specifies the name of the data column. The data column contains the data to forecast. + The data column must use one of the following data types: INT64, NUMERIC and FLOAT64 + model (str, default "TimesFM 2.0"): + A str value that specifies the name of the model. TimesFM 2.0 is the only supported value, and is the default value. + id_columns (Iterable[str] or None, default None): + An iterable of str value that specifies the names of one or more ID columns. Each ID identifies a unique time series to forecast. + Specify one or more values for this argument in order to forecast multiple time series using a single query. + The columns that you specify must use one of the following data types: STRING, INT64, ARRAY and ARRAY + horizon (int, default 10): + An int value that specifies the number of time points to forecast. The default value is 10. The valid input range is [1, 10,000]. + confidence_level (float, default 0.95): + A FLOAT64 value that specifies the percentage of the future values that fall in the prediction interval. + The default value is 0.95. The valid input range is [0, 1). + + Returns: + DataFrame: + The forecast dataframe matches that of the BigQuery AI.FORECAST function. + See: https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-ai-forecast + + Raises: + ValueError: when referring to a non-existing column. + """ + columns = [timestamp_column, data_column] + if id_columns: + columns += id_columns + for column in columns: + if column not in self._df.columns: + raise ValueError(f"Column `{column}` not found") + + options: dict[str, Union[int, float, str, Iterable[str]]] = { + "data_col": data_column, + "timestamp_col": timestamp_column, + "model": model, + "horizon": horizon, + "confidence_level": confidence_level, + } + if id_columns: + options["id_cols"] = id_columns + + return self._base_bqml.ai_forecast(input_data=self._df, options=options) + + @staticmethod + def _attach_embedding(dataframe, source_column: str, embedding_column: str, model): + result_df = dataframe.copy() + embeddings = model.predict(dataframe[source_column])[ + "ml_generate_embedding_result" + ] + result_df[embedding_column] = embeddings + return result_df + + @staticmethod + def _make_multimodel_prompt( + prompt_df, columns, user_instruction: str, output_instruction: str + ): + prompt = [f"{output_instruction}\n{user_instruction}\nContext: "] + for col in columns: + prompt.extend([f"{col} is ", prompt_df[col]]) + + return prompt + + @staticmethod + def _make_text_prompt( + prompt_df, columns, user_instruction: str, output_instruction: str + ): + prompt_df["prompt"] = f"{output_instruction}\n{user_instruction}\nContext: " + + # Combine context from multiple columns. + for col in columns: + prompt_df["prompt"] += f"{col} is `" + prompt_df[col] + "`\n" + + return prompt_df["prompt"] + + @staticmethod + def _parse_columns(instruction: str) -> List[str]: + """Extracts column names enclosed in curly braces from the user instruction. + For example, _parse_columns("{city} is in {continent}") == ["city", "continent"] + """ + columns = re.findall(r"(? str: + """Extracts column names enclosed in curly braces from the user instruction. + For example, `_format_instruction(["city", "continent"], "{city} is in {continent}") + == "city is in continent"` + """ + return instruction.format(**{col: col for col in columns}) + + @staticmethod + def _validate_model(model): + from bigframes.ml.llm import GeminiTextGenerator + + if not isinstance(model, GeminiTextGenerator): + raise TypeError("Model is not GeminiText Generator") + + @staticmethod + def _confirm_operation(row_count: int): + """Raises OperationAbortedError when the confirmation fails""" + import bigframes # Import in the function body to avoid circular imports. + + threshold = bigframes.options.compute.ai_ops_confirmation_threshold + + if threshold is None or row_count <= threshold: + return + + if bigframes.options.compute.ai_ops_threshold_autofail: + raise exceptions.OperationAbortedError( + f"Operation was cancelled because your work estimate is {row_count} rows, which exceeds the threshold {threshold} rows." + ) + + # Separate the prompt out. In IDE such VS Code, leaving prompt in the + # input function makes it less visible to the end user. + print(f"This operation will process about {row_count} rows.") + print( + "You can raise the confirmation threshold by setting `bigframes.options.compute.ai_ops_confirmation_threshold` to a higher value. To completely turn off the confirmation check, set the threshold to `None`." + ) + print("Proceed? [Y/n]") + reply = input().casefold() + if reply not in {"y", "yes", ""}: + raise exceptions.OperationAbortedError("Operation was cancelled.") diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py new file mode 100644 index 0000000000..8dc8c2ffab --- /dev/null +++ b/bigframes/operations/ai_ops.py @@ -0,0 +1,152 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +from typing import ClassVar, Literal, Tuple + +import pandas as pd +import pyarrow as pa + +from bigframes import dtypes +from bigframes.operations import base_ops, output_schemas + + +@dataclasses.dataclass(frozen=True) +class AIGenerate(base_ops.NaryOp): + name: ClassVar[str] = "ai_generate" + + prompt_context: Tuple[str | None, ...] + connection_id: str | None + endpoint: str | None + request_type: Literal["dedicated", "shared", "unspecified"] + model_params: str | None + output_schema: str | None + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if self.output_schema is None: + output_fields = (pa.field("result", pa.string()),) + else: + output_fields = output_schemas.parse_sql_fields(self.output_schema) + + return pd.ArrowDtype( + pa.struct( + ( + *output_fields, + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +@dataclasses.dataclass(frozen=True) +class AIGenerateBool(base_ops.NaryOp): + name: ClassVar[str] = "ai_generate_bool" + + prompt_context: Tuple[str | None, ...] + connection_id: str | None + endpoint: str | None + request_type: Literal["dedicated", "shared", "unspecified"] + model_params: str | None + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +@dataclasses.dataclass(frozen=True) +class AIGenerateInt(base_ops.NaryOp): + name: ClassVar[str] = "ai_generate_int" + + prompt_context: Tuple[str | None, ...] + connection_id: str | None + endpoint: str | None + request_type: Literal["dedicated", "shared", "unspecified"] + model_params: str | None + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.int64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +@dataclasses.dataclass(frozen=True) +class AIGenerateDouble(base_ops.NaryOp): + name: ClassVar[str] = "ai_generate_double" + + prompt_context: Tuple[str | None, ...] + connection_id: str | None + endpoint: str | None + request_type: Literal["dedicated", "shared", "unspecified"] + model_params: str | None + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.float64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +@dataclasses.dataclass(frozen=True) +class AIIf(base_ops.NaryOp): + name: ClassVar[str] = "ai_if" + + prompt_context: Tuple[str | None, ...] + connection_id: str + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.BOOL_DTYPE + + +@dataclasses.dataclass(frozen=True) +class AIClassify(base_ops.NaryOp): + name: ClassVar[str] = "ai_classify" + + prompt_context: Tuple[str | None, ...] + categories: tuple[str, ...] + connection_id: str + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.STRING_DTYPE + + +@dataclasses.dataclass(frozen=True) +class AIScore(base_ops.NaryOp): + name: ClassVar[str] = "ai_score" + + prompt_context: Tuple[str | None, ...] + connection_id: str + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.FLOAT_DTYPE diff --git a/bigframes/operations/array_ops.py b/bigframes/operations/array_ops.py index c1e644fc11..61ada59cc7 100644 --- a/bigframes/operations/array_ops.py +++ b/bigframes/operations/array_ops.py @@ -13,10 +13,11 @@ # limitations under the License. import dataclasses +import functools import typing from bigframes import dtypes -from bigframes.operations import base_ops +from bigframes.operations import aggregations, base_ops @dataclasses.dataclass(frozen=True) @@ -63,3 +64,27 @@ def output_type(self, *input_types): return input_type else: raise TypeError("Input type must be an array or string-like type.") + + +class ToArrayOp(base_ops.NaryOp): + name: typing.ClassVar[str] = "array" + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + # very permissive, maybe should force caller to do this? + common_type = functools.reduce( + lambda t1, t2: dtypes.coerce_to_common(t1, t2), + input_types, + ) + return dtypes.list_type(common_type) + + +@dataclasses.dataclass(frozen=True) +class ArrayReduceOp(base_ops.UnaryOp): + name: typing.ClassVar[str] = "array_reduce" + aggregation: aggregations.AggregateOp + + def output_type(self, *input_types): + input_type = input_types[0] + assert dtypes.is_array_like(input_type) + inner_type = dtypes.get_array_inner_type(input_type) + return self.aggregation.output_type(inner_type) diff --git a/bigframes/operations/base.py b/bigframes/operations/base.py deleted file mode 100644 index 75db2f48e9..0000000000 --- a/bigframes/operations/base.py +++ /dev/null @@ -1,310 +0,0 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import typing -from typing import List, Sequence, Union - -import bigframes_vendored.constants as constants -import bigframes_vendored.pandas.pandas._typing as vendored_pandas_typing -import pandas as pd - -import bigframes.core.blocks as blocks -import bigframes.core.convert -import bigframes.core.expression as ex -import bigframes.core.identifiers as ids -import bigframes.core.indexes as indexes -import bigframes.core.scalar as scalars -import bigframes.dtypes -import bigframes.operations as ops -import bigframes.operations.aggregations as agg_ops -import bigframes.series as series -import bigframes.session - - -class SeriesMethods: - def __init__( - self, - data=None, - index: vendored_pandas_typing.Axes | None = None, - dtype: typing.Optional[ - bigframes.dtypes.DtypeString | bigframes.dtypes.Dtype - ] = None, - name: str | None = None, - copy: typing.Optional[bool] = None, - *, - session: typing.Optional[bigframes.session.Session] = None, - ): - import bigframes.pandas - - # Ignore object dtype if provided, as it provides no additional - # information about what BigQuery type to use. - if dtype is not None and bigframes.dtypes.is_object_like(dtype): - dtype = None - - read_pandas_func = ( - session.read_pandas - if (session is not None) - else (lambda x: bigframes.pandas.read_pandas(x)) - ) - - block: typing.Optional[blocks.Block] = None - if (name is not None) and not isinstance(name, typing.Hashable): - raise ValueError( - f"BigQuery DataFrames only supports hashable series names. {constants.FEEDBACK_LINK}" - ) - if copy is not None and not copy: - raise ValueError( - f"Series constructor only supports copy=True. {constants.FEEDBACK_LINK}" - ) - if isinstance(data, blocks.Block): - # Constructing from block is for internal use only - shouldn't use parameters, block encompasses all state - assert len(data.value_columns) == 1 - assert len(data.column_labels) == 1 - assert index is None - assert name is None - assert dtype is None - block = data - - # interpret these cases as both index and data - elif isinstance(data, bigframes.pandas.Series) or pd.api.types.is_dict_like( - data - ): # includes pd.Series - if isinstance(data, bigframes.pandas.Series): - data = data.copy() - if name is not None: - data.name = name - if dtype is not None: - bf_dtype = bigframes.dtypes.bigframes_type(dtype) - data = data.astype(bf_dtype) - else: # local dict-like data - data = read_pandas_func(pd.Series(data, name=name, dtype=dtype)) # type: ignore - data_block = data._block - if index is not None: - # reindex - bf_index = indexes.Index(index, session=session) - idx_block = bf_index._block - idx_cols = idx_block.value_columns - block_idx, _ = idx_block.join(data_block, how="left") - data_block = block_idx.with_index_labels(bf_index.names) - block = data_block - - # list-like data that will get default index - elif isinstance(data, indexes.Index) or pd.api.types.is_list_like(data): - data = indexes.Index(data, dtype=dtype, name=name, session=session) - # set to none as it has already been applied, avoid re-cast later - if data.nlevels != 1: - raise NotImplementedError("Cannot interpret multi-index as Series.") - # Reset index to promote index columns to value columns, set default index - data_block = data._block.reset_index(drop=False).with_column_labels( - data.names - ) - if index is not None: - # Align by offset - bf_index = indexes.Index(index, session=session) - idx_block = bf_index._block.reset_index( - drop=False - ) # reset to align by offsets, and then reset back - idx_cols = idx_block.value_columns - data_block, (l_mapping, _) = idx_block.join(data_block, how="left") - data_block = data_block.set_index([l_mapping[col] for col in idx_cols]) - data_block = data_block.with_index_labels(bf_index.names) - block = data_block - - else: # Scalar case - if index is not None: - bf_index = indexes.Index(index, session=session) - else: - bf_index = indexes.Index( - [] if (data is None) else [0], - session=session, - dtype=bigframes.dtypes.INT_DTYPE, - ) - block, _ = bf_index._block.create_constant(data, dtype) - block = block.with_column_labels([name]) - - assert block is not None - self._block: blocks.Block = block - - @property - def _value_column(self) -> str: - return self._block.value_columns[0] - - @property - def _name(self) -> blocks.Label: - return self._block.column_labels[0] - - @property - def _dtype(self): - return self._block.dtypes[0] - - def _set_block(self, block: blocks.Block): - self._block = block - - def _get_block(self) -> blocks.Block: - return self._block - - def _apply_unary_op( - self, - op: ops.UnaryOp, - ) -> series.Series: - """Applies a unary operator to the series.""" - block, result_id = self._block.apply_unary_op( - self._value_column, op, result_label=self._name - ) - return series.Series(block.select_column(result_id)) - - def _apply_binary_op( - self, - other: typing.Any, - op: ops.BinaryOp, - alignment: typing.Literal["outer", "left"] = "outer", - reverse: bool = False, - ) -> series.Series: - """Applies a binary operator to the series and other.""" - if bigframes.core.convert.can_convert_to_series(other): - self_index = indexes.Index(self._block) - other_series = bigframes.core.convert.to_bf_series( - other, self_index, self._block.session - ) - (self_col, other_col, block) = self._align(other_series, how=alignment) - - name = self._name - # Drop name if both objects have name attr, but they don't match - if ( - hasattr(other, "name") - and other_series.name != self._name - and alignment == "outer" - ): - name = None - expr = op.as_expr( - other_col if reverse else self_col, self_col if reverse else other_col - ) - block, result_id = block.project_expr(expr, name) - return series.Series(block.select_column(result_id)) - - else: # Scalar binop - name = self._name - expr = op.as_expr( - ex.const(other) if reverse else self._value_column, - self._value_column if reverse else ex.const(other), - ) - block, result_id = self._block.project_expr(expr, name) - return series.Series(block.select_column(result_id)) - - def _apply_nary_op( - self, - op: ops.NaryOp, - others: Sequence[typing.Union[series.Series, scalars.Scalar]], - ignore_self=False, - ): - """Applies an n-ary operator to the series and others.""" - values, block = self._align_n( - others, ignore_self=ignore_self, cast_scalars=False - ) - block, result_id = block.project_expr(op.as_expr(*values)) - return series.Series(block.select_column(result_id)) - - def _apply_binary_aggregation( - self, other: series.Series, stat: agg_ops.BinaryAggregateOp - ) -> float: - (left, right, block) = self._align(other, how="outer") - assert isinstance(left, ex.DerefOp) - assert isinstance(right, ex.DerefOp) - return block.get_binary_stat(left.id.name, right.id.name, stat) - - AlignedExprT = Union[ex.ScalarConstantExpression, ex.DerefOp] - - @typing.overload - def _align( - self, other: series.Series, how="outer" - ) -> tuple[ex.DerefOp, ex.DerefOp, blocks.Block,]: - ... - - @typing.overload - def _align( - self, other: typing.Union[series.Series, scalars.Scalar], how="outer" - ) -> tuple[ex.DerefOp, AlignedExprT, blocks.Block,]: - ... - - def _align( - self, other: typing.Union[series.Series, scalars.Scalar], how="outer" - ) -> tuple[ex.DerefOp, AlignedExprT, blocks.Block,]: - """Aligns the series value with another scalar or series object. Returns new left column id, right column id and joined tabled expression.""" - values, block = self._align_n( - [ - other, - ], - how, - ) - return (typing.cast(ex.DerefOp, values[0]), values[1], block) - - def _align3(self, other1: series.Series | scalars.Scalar, other2: series.Series | scalars.Scalar, how="left") -> tuple[ex.DerefOp, AlignedExprT, AlignedExprT, blocks.Block]: # type: ignore - """Aligns the series value with 2 other scalars or series objects. Returns new values and joined tabled expression.""" - values, index = self._align_n([other1, other2], how) - return ( - typing.cast(ex.DerefOp, values[0]), - values[1], - values[2], - index, - ) - - def _align_n( - self, - others: typing.Sequence[typing.Union[series.Series, scalars.Scalar]], - how="outer", - ignore_self=False, - cast_scalars: bool = True, - ) -> tuple[ - typing.Sequence[Union[ex.ScalarConstantExpression, ex.DerefOp]], - blocks.Block, - ]: - if ignore_self: - value_ids: List[Union[ex.ScalarConstantExpression, ex.DerefOp]] = [] - else: - value_ids = [ex.deref(self._value_column)] - - block = self._block - for other in others: - if isinstance(other, series.Series): - block, ( - get_column_left, - get_column_right, - ) = block.join(other._block, how=how) - rebindings = { - ids.ColumnId(old): ids.ColumnId(new) - for old, new in get_column_left.items() - } - remapped_value_ids = ( - value.remap_column_refs(rebindings) for value in value_ids - ) - value_ids = [ - *remapped_value_ids, # type: ignore - ex.deref(get_column_right[other._value_column]), - ] - else: - # Will throw if can't interpret as scalar. - dtype = typing.cast(bigframes.dtypes.Dtype, self._dtype) - value_ids = [ - *value_ids, - ex.const(other, dtype=dtype if cast_scalars else None), - ] - return (value_ids, block) - - def _throw_if_null_index(self, opname: str): - if len(self._block.index_columns) == 0: - raise bigframes.exceptions.NullIndexError( - f"Series cannot perform {opname} as it has no index. Set an index using set_index." - ) diff --git a/bigframes/operations/base_ops.py b/bigframes/operations/base_ops.py index fc92ffe760..c0145a6711 100644 --- a/bigframes/operations/base_ops.py +++ b/bigframes/operations/base_ops.py @@ -180,7 +180,9 @@ def _convert_expr_input( # Operation Factories -def create_unary_op(name: str, type_signature: op_typing.UnaryTypeSignature) -> UnaryOp: +def create_unary_op( + name: str, type_signature: op_typing.UnaryTypeSignature +) -> type[UnaryOp]: return dataclasses.make_dataclass( name, [ @@ -189,12 +191,12 @@ def create_unary_op(name: str, type_signature: op_typing.UnaryTypeSignature) -> ], bases=(UnaryOp,), frozen=True, - )() + ) def create_binary_op( name: str, type_signature: op_typing.BinaryTypeSignature -) -> BinaryOp: +) -> type[BinaryOp]: return dataclasses.make_dataclass( name, [ @@ -203,4 +205,4 @@ def create_binary_op( ], bases=(BinaryOp,), frozen=True, - )() + ) diff --git a/bigframes/operations/blob.py b/bigframes/operations/blob.py index 24ff315ad5..577de458f4 100644 --- a/bigframes/operations/blob.py +++ b/bigframes/operations/blob.py @@ -15,71 +15,86 @@ from __future__ import annotations import os -from typing import cast, Optional, Union +from typing import cast, Literal, Optional, Union +import warnings import IPython.display as ipy_display +import pandas as pd import requests -from bigframes import clients +from bigframes import clients, dtypes +from bigframes.core import log_adapter import bigframes.dataframe -from bigframes.operations import base +import bigframes.exceptions as bfe import bigframes.operations as ops import bigframes.series +FILE_FOLDER_REGEX = r"^.*\/(.*)$" +FILE_EXT_REGEX = r"(\.[0-9a-zA-Z]+$)" -class BlobAccessor(base.SeriesMethods): - def __init__(self, *args, **kwargs): - if not bigframes.options.experiments.blob: - raise NotImplementedError() - super().__init__(*args, **kwargs) +@log_adapter.class_logger +class BlobAccessor: + """ + Blob functions for Series and Index. + + .. note:: + BigFrames Blob is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + """ + + def __init__(self, data: bigframes.series.Series): + self._data = data def uri(self) -> bigframes.series.Series: """URIs of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: URIs as string.""" - s = bigframes.series.Series(self._block) + bigframes.series.Series: URIs as string.""" + s = bigframes.series.Series(self._data._block) return s.struct.field("uri") def authorizer(self) -> bigframes.series.Series: """Authorizers of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: Autorithers(connection) as string.""" - s = bigframes.series.Series(self._block) + bigframes.series.Series: Autorithers(connection) as string.""" + s = bigframes.series.Series(self._data._block) return s.struct.field("authorizer") def version(self) -> bigframes.series.Series: """Versions of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: Version as string.""" + bigframes.series.Series: Version as string.""" # version must be retrieved after fetching metadata - return self._apply_unary_op(ops.obj_fetch_metadata_op).struct.field("version") + return self._data._apply_unary_op(ops.obj_fetch_metadata_op).struct.field( + "version" + ) def metadata(self) -> bigframes.series.Series: """Retrieve the metadata of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: JSON metadata of the Blob. Contains fields: content_type, md5_hash, size and updated(time).""" - details_json = self._apply_unary_op(ops.obj_fetch_metadata_op).struct.field( - "details" - ) + bigframes.series.Series: JSON metadata of the Blob. Contains fields: content_type, md5_hash, size and updated(time).""" + series_to_check = bigframes.series.Series(self._data._block) + # Check if it's a struct series from a verbose operation + if dtypes.is_struct_like(series_to_check.dtype): + pyarrow_dtype = series_to_check.dtype.pyarrow_dtype + if "content" in [field.name for field in pyarrow_dtype]: + content_field_type = pyarrow_dtype.field("content").type + content_bf_type = dtypes.arrow_dtype_to_bigframes_dtype( + content_field_type + ) + if content_bf_type == dtypes.OBJ_REF_DTYPE: + series_to_check = series_to_check.struct.field("content") + details_json = series_to_check._apply_unary_op( + ops.obj_fetch_metadata_op + ).struct.field("details") import bigframes.bigquery as bbq return bbq.json_extract(details_json, "$.gcs_metadata").rename("metadata") @@ -87,11 +102,8 @@ def metadata(self) -> bigframes.series.Series: def content_type(self) -> bigframes.series.Series: """Retrieve the content type of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: string of the content type.""" + bigframes.series.Series: string of the content type.""" return ( self.metadata() ._apply_unary_op(ops.JSONValue(json_path="$.content_type")) @@ -101,11 +113,8 @@ def content_type(self) -> bigframes.series.Series: def md5_hash(self) -> bigframes.series.Series: """Retrieve the md5 hash of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: string of the md5 hash.""" + bigframes.series.Series: string of the md5 hash.""" return ( self.metadata() ._apply_unary_op(ops.JSONValue(json_path="$.md5_hash")) @@ -115,11 +124,8 @@ def md5_hash(self) -> bigframes.series.Series: def size(self) -> bigframes.series.Series: """Retrieve the file size of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: file size in bytes.""" + bigframes.series.Series: file size in bytes.""" return ( self.metadata() ._apply_unary_op(ops.JSONValue(json_path="$.size")) @@ -130,11 +136,8 @@ def size(self) -> bigframes.series.Series: def updated(self) -> bigframes.series.Series: """Retrieve the updated time of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: updated time as UTC datetime.""" + bigframes.series.Series: updated time as UTC datetime.""" import bigframes.pandas as bpd updated = ( @@ -156,20 +159,59 @@ def _get_runtime( metadata (bool, default False): whether to fetch the metadata in the ObjectRefRuntime. Returns: - bigframes Series: ObjectRefRuntime JSON. + bigframes.series.Series: ObjectRefRuntime JSON. """ - s = self._apply_unary_op(ops.obj_fetch_metadata_op) if with_metadata else self + s = ( + self._data._apply_unary_op(ops.obj_fetch_metadata_op) + if with_metadata + else self._data + ) return s._apply_unary_op(ops.ObjGetAccessUrl(mode=mode)) + def _df_apply_udf( + self, df: bigframes.dataframe.DataFrame, udf + ) -> bigframes.series.Series: + # Catch and rethrow function axis=1 warning to be more user-friendly. + with warnings.catch_warnings(record=True) as catched_warnings: + s = df.apply(udf, axis=1) + for w in catched_warnings: + if isinstance(w.message, bfe.FunctionAxisOnePreviewWarning): + warnings.warn( + "Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.", + category=w.category, + stacklevel=2, + ) + else: + warnings.warn_explicit( + message=w.message, + category=w.category, + filename=w.filename, + lineno=w.lineno, + source=w.source, + ) + + return s + + def _apply_udf_or_raise_error( + self, df: bigframes.dataframe.DataFrame, udf, operation_name: str + ) -> bigframes.series.Series: + """Helper to apply UDF with consistent error handling.""" + try: + res = self._df_apply_udf(df, udf) + except Exception as e: + raise RuntimeError(f"{operation_name} UDF execution failed: {e}") from e + + if res is None: + raise RuntimeError(f"{operation_name} returned None result") + + return res + def read_url(self) -> bigframes.series.Series: """Retrieve the read URL of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: Read only URLs.""" + bigframes.series.Series: Read only URLs.""" return self._get_runtime(mode="R")._apply_unary_op( ops.JSONValue(json_path="$.access_urls.read_url") ) @@ -177,27 +219,33 @@ def read_url(self) -> bigframes.series.Series: def write_url(self) -> bigframes.series.Series: """Retrieve the write URL of the Blob. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Returns: - BigFrames Series: Writable URLs.""" + bigframes.series.Series: Writable URLs.""" return self._get_runtime(mode="RW")._apply_unary_op( ops.JSONValue(json_path="$.access_urls.write_url") ) - def display(self, n: int = 3, *, content_type: str = ""): + def display( + self, + n: int = 3, + *, + content_type: str = "", + width: Optional[int] = None, + height: Optional[int] = None, + ): """Display the blob content in the IPython Notebook environment. Only works for image type now. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Args: n (int, default 3): number of sample blob objects to display. content_type (str, default ""): content type of the blob. If unset, use the blob metadata of the storage. Possible values are "image", "audio" and "video". + width (int or None, default None): width in pixels that the image/video are constrained to. If unset, use the global setting in bigframes.options.display.blob_display_width, otherwise image/video's original size or ratio is used. No-op for other content types. + height (int or None, default None): height in pixels that the image/video are constrained to. If unset, use the global setting in bigframes.options.display.blob_display_height, otherwise image/video's original size or ratio is used. No-op for other content types. """ + width = width or bigframes.options.display.blob_display_width + height = height or bigframes.options.display.blob_display_height + # col name doesn't matter here. Rename to avoid column name conflicts - df = bigframes.series.Series(self._block).rename("blob_col").head(n).to_frame() + df = bigframes.series.Series(self._data._block).rename("blob_col").to_frame() df["read_url"] = df["blob_col"].blob.read_url() @@ -206,31 +254,50 @@ def display(self, n: int = 3, *, content_type: str = ""): else: df["content_type"] = df["blob_col"].blob.content_type() - def display_single_url(read_url: str, content_type: str): - content_type = content_type.casefold() + pandas_df, _, query_job = df._block.retrieve_repr_request_results(n) + df._set_internal_query_job(query_job) + + def display_single_url( + read_url: Union[str, pd._libs.missing.NAType], + content_type: Union[str, pd._libs.missing.NAType], + ): + if pd.isna(read_url): + ipy_display.display("") + return + + if pd.isna(content_type): # display as raw data or error + response = requests.get(read_url) + ipy_display.display(response.content) + return + + content_type = cast(str, content_type).casefold() if content_type.startswith("image"): - ipy_display.display(ipy_display.Image(url=read_url)) + ipy_display.display( + ipy_display.Image(url=read_url, width=width, height=height) + ) elif content_type.startswith("audio"): # using url somehow doesn't work with audios response = requests.get(read_url) ipy_display.display(ipy_display.Audio(response.content)) elif content_type.startswith("video"): - ipy_display.display(ipy_display.Video(read_url)) + ipy_display.display( + ipy_display.Video(read_url, width=width, height=height) + ) else: # display as raw data response = requests.get(read_url) ipy_display.display(response.content) - for _, row in df.iterrows(): + for _, row in pandas_df.iterrows(): display_single_url(row["read_url"], row["content_type"]) + @property + def session(self): + return self._data._block.session + def _resolve_connection(self, connection: Optional[str] = None) -> str: """Resovle the BigQuery connection. - .. note:: - BigFrames Blob is still under experiments. It may not work and - subject to change in the future. - Args: connection (str or None, default None): BQ connection used for function internet transactions, and the output blob if "dst" is @@ -243,28 +310,24 @@ def _resolve_connection(self, connection: Optional[str] = None) -> str: Raises: ValueError: If the connection cannot be resolved to a valid string. """ - connection = connection or self._block.session._bq_connection - return clients.resolve_full_bq_connection_name( + connection = connection or self._data._block.session._bq_connection + return clients.get_canonical_bq_connection_id( connection, - default_project=self._block.session._project, - default_location=self._block.session._location, + default_project=self._data._block.session._project, + default_location=self._data._block.session._location, ) - def _get_runtime_json_str( - self, mode: str = "R", with_metadata: bool = False + def get_runtime_json_str( + self, mode: str = "R", *, with_metadata: bool = False ) -> bigframes.series.Series: - """Get the runtime and apply the ToJSONSTring transformation. - - .. note:: - BigFrames Blob is still under experiments. It may not work and - subject to change in the future. + """Get the runtime (contains signed URL to access gcs data) and apply the ToJSONSTring transformation. Args: mode(str or str, default "R"): the mode for accessing the runtime. Default to "R". Possible values are "R" (read-only) and "RW" (read-write) with_metadata (bool, default False): whether to include metadata - in the JOSN string. Default to False. + in the JSON string. Default to False. Returns: str: the runtime object in the JSON string. @@ -272,98 +335,247 @@ def _get_runtime_json_str( runtime = self._get_runtime(mode=mode, with_metadata=with_metadata) return runtime._apply_unary_op(ops.ToJSONString()) + def exif( + self, + *, + engine: Literal[None, "pillow"] = None, + connection: Optional[str] = None, + max_batching_rows: int = 8192, + container_cpu: Union[float, int] = 0.33, + container_memory: str = "512Mi", + verbose: bool = False, + ) -> bigframes.series.Series: + """Extract EXIF data. Now only support image types. + + Args: + engine ('pillow' or None, default None): The engine (bigquery or third party library) used for the function. The value must be specified. + connection (str or None, default None): BQ connection used for function internet transactions, and the output blob if "dst" is str. If None, uses default connection of the session. + max_batching_rows (int, default 8,192): Max number of rows per batch send to cloud run to execute the function. + container_cpu (int or float, default 0.33): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. + container_memory (str, default "512Mi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default False): If True, returns a struct with status and content fields. If False, returns only the content. + + Returns: + bigframes.series.Series: JSON series of key-value pairs if verbose=False, or struct with status and content if verbose=True. + + Raises: + ValueError: If engine is not 'pillow'. + RuntimeError: If EXIF extraction fails or returns invalid structure. + """ + if engine is None or engine.casefold() != "pillow": + raise ValueError("Must specify the engine, supported value is 'pillow'.") + + import bigframes.bigquery as bbq + import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd + + connection = self._resolve_connection(connection) + df = self.get_runtime_json_str(mode="R").to_frame() + df["verbose"] = verbose + + exif_udf = blob_func.TransformFunction( + blob_func.exif_func_def, + session=self._data._block.session, + connection=connection, + max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, + ).udf() + + res = self._apply_udf_or_raise_error(df, exif_udf, "EXIF extraction") + + if verbose: + try: + exif_content_series = bbq.parse_json( + res._apply_unary_op(ops.JSONValue(json_path="$.content")) + ).rename("exif_content") + exif_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + except Exception as e: + raise RuntimeError(f"Failed to parse EXIF JSON result: {e}") from e + results_df = bpd.DataFrame( + {"status": exif_status_series, "content": exif_content_series} + ) + results_struct = bbq.struct(results_df).rename("exif_results") + return results_struct + else: + try: + return bbq.parse_json(res) + except Exception as e: + raise RuntimeError(f"Failed to parse EXIF JSON result: {e}") from e + def image_blur( self, ksize: tuple[int, int], *, + engine: Literal[None, "opencv"] = None, dst: Optional[Union[str, bigframes.series.Series]] = None, connection: Optional[str] = None, - max_batching_rows: int = 10000, + max_batching_rows: int = 8192, + container_cpu: Union[float, int] = 0.33, + container_memory: str = "512Mi", + verbose: bool = False, ) -> bigframes.series.Series: """Blurs images. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Args: ksize (tuple(int, int)): Kernel size. - dst (str or bigframes.series.Series or None, default None): Destination GCS folder str or blob series. If None, output to BQ as bytes. + engine ('opencv' or None, default None): The engine (bigquery or third party library) used for the function. The value must be specified. + dst (str or bigframes.series.Series or None, default None): Output destination. Can be one of: + str: GCS folder str. The output filenames are the same as the input files. + blob Series: The output file paths are determined by the uris of the blob Series. + None: Output to BQ as bytes. + Encoding is determined by the extension of the output filenames (or input filenames if doesn't have output filenames). If filename doesn't have an extension, use ".jpeg" for encoding. connection (str or None, default None): BQ connection used for function internet transactions, and the output blob if "dst" is str. If None, uses default connection of the session. - max_batching_rows (int, default 10,000): Max number of rows per batch send to cloud run to execute the function. + max_batching_rows (int, default 8,192): Max number of rows per batch send to cloud run to execute the function. + container_cpu (int or float, default 0.33): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. + container_memory (str, default "512Mi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default False): If True, returns a struct with status and content fields. If False, returns only the content. Returns: - BigFrames Blob Series + bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. If verbose=True, returns struct with status and content. + + Raises: + ValueError: If engine is not 'opencv' or parameters are invalid. + RuntimeError: If image blur operation fails. """ + if engine is None or engine.casefold() != "opencv": + raise ValueError("Must specify the engine, supported value is 'opencv'.") + + import bigframes.bigquery as bbq import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd connection = self._resolve_connection(connection) - df = self._get_runtime_json_str(mode="R").to_frame() + df = self.get_runtime_json_str(mode="R").to_frame() if dst is None: + ext = self.uri().str.extract(FILE_EXT_REGEX) + image_blur_udf = blob_func.TransformFunction( blob_func.image_blur_to_bytes_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, ).udf() df["ksize_x"], df["ksize_y"] = ksize - res = df.apply(image_blur_udf, axis=1) - - return res + df["ext"] = ext # type: ignore + df["verbose"] = verbose + res = self._apply_udf_or_raise_error(df, image_blur_udf, "Image blur") + + if verbose: + blurred_content_b64_series = res._apply_unary_op( + ops.JSONValue(json_path="$.content") + ) + blurred_content_series = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[blurred_content_b64_series] + ) + blurred_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + {"status": blurred_status_series, "content": blurred_content_series} + ) + results_struct = bbq.struct(results_df).rename("blurred_results") + return results_struct + else: + blurred_bytes = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[res] + ).rename("blurred_bytes") + return blurred_bytes if isinstance(dst, str): dst = os.path.join(dst, "") - src_uri = bigframes.series.Series(self._block).struct.explode()["uri"] # Replace src folder with dst folder, keep the file names. - dst_uri = src_uri.str.replace(r"^.*\/(.*)$", rf"{dst}\1", regex=True) + dst_uri = self.uri().str.replace(FILE_FOLDER_REGEX, rf"{dst}\1", regex=True) dst = cast( bigframes.series.Series, dst_uri.str.to_blob(connection=connection) ) + ext = dst.blob.uri().str.extract(FILE_EXT_REGEX) + image_blur_udf = blob_func.TransformFunction( blob_func.image_blur_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, ).udf() - dst_rt = dst.blob._get_runtime_json_str(mode="RW") + dst_rt = dst.blob.get_runtime_json_str(mode="RW") df = df.join(dst_rt, how="outer") df["ksize_x"], df["ksize_y"] = ksize + df["ext"] = ext # type: ignore + df["verbose"] = verbose - res = df.apply(image_blur_udf, axis=1) + res = self._apply_udf_or_raise_error(df, image_blur_udf, "Image blur") res.cache() # to execute the udf - return dst + if verbose: + blurred_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + { + "status": blurred_status_series, + "content": dst.blob.uri().str.to_blob( + connection=self._resolve_connection(connection) + ), + } + ) + results_struct = bbq.struct(results_df).rename("blurred_results") + return results_struct + else: + return dst def image_resize( self, dsize: tuple[int, int] = (0, 0), *, + engine: Literal[None, "opencv"] = None, fx: float = 0.0, fy: float = 0.0, dst: Optional[Union[str, bigframes.series.Series]] = None, connection: Optional[str] = None, - max_batching_rows: int = 10000, + max_batching_rows: int = 8192, + container_cpu: Union[float, int] = 0.33, + container_memory: str = "512Mi", + verbose: bool = False, ): """Resize images. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Args: dsize (tuple(int, int), default (0, 0)): Destination size. If set to 0, fx and fy parameters determine the size. + engine ('opencv' or None, default None): The engine (bigquery or third party library) used for the function. The value must be specified. fx (float, default 0.0): scale factor along the horizontal axis. If set to 0.0, dsize parameter determines the output size. fy (float, defalut 0.0): scale factor along the vertical axis. If set to 0.0, dsize parameter determines the output size. - dst (str or bigframes.series.Series or None, default None): Destination GCS folder str or blob series. If None, output to BQ as bytes. + dst (str or bigframes.series.Series or None, default None): Output destination. Can be one of: + str: GCS folder str. The output filenames are the same as the input files. + blob Series: The output file paths are determined by the uris of the blob Series. + None: Output to BQ as bytes. + Encoding is determined by the extension of the output filenames (or input filenames if doesn't have output filenames). If filename doesn't have an extension, use ".jpeg" for encoding. connection (str or None, default None): BQ connection used for function internet transactions, and the output blob if "dst" is str. If None, uses default connection of the session. - max_batching_rows (int, default 10,000): Max number of rows per batch send to cloud run to execute the function. + max_batching_rows (int, default 8,192): Max number of rows per batch send to cloud run to execute the function. + container_cpu (int or float, default 0.33): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. + container_memory (str, default "512Mi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default False): If True, returns a struct with status and content fields. If False, returns only the content. Returns: - BigFrames Blob Series + bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. If verbose=True, returns struct with status and content. + + Raises: + ValueError: If engine is not 'opencv' or parameters are invalid. + RuntimeError: If image resize operation fails. """ + if engine is None or engine.casefold() != "opencv": + raise ValueError("Must specify the engine, supported value is 'opencv'.") + dsize_set = dsize[0] > 0 and dsize[1] > 0 fsize_set = fx > 0.0 and fy > 0.0 if not dsize_set ^ fsize_set: @@ -371,199 +583,371 @@ def image_resize( "Only one of dsize or (fx, fy) parameters must be set. And the set values must be positive. " ) + import bigframes.bigquery as bbq import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd connection = self._resolve_connection(connection) - df = self._get_runtime_json_str(mode="R").to_frame() + df = self.get_runtime_json_str(mode="R").to_frame() if dst is None: + ext = self.uri().str.extract(FILE_EXT_REGEX) + image_resize_udf = blob_func.TransformFunction( blob_func.image_resize_to_bytes_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, ).udf() - df["dsize_x"], df["dsizye_y"] = dsize + df["dsize_x"], df["dsize_y"] = dsize df["fx"], df["fy"] = fx, fy - res = df.apply(image_resize_udf, axis=1) - - return res + df["ext"] = ext # type: ignore + df["verbose"] = verbose + res = self._apply_udf_or_raise_error(df, image_resize_udf, "Image resize") + + if verbose: + resized_content_b64_series = res._apply_unary_op( + ops.JSONValue(json_path="$.content") + ) + resized_content_series = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[resized_content_b64_series] + ) + + resized_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + {"status": resized_status_series, "content": resized_content_series} + ) + results_struct = bbq.struct(results_df).rename("resized_results") + return results_struct + else: + resized_bytes = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[res] + ).rename("resized_bytes") + return resized_bytes if isinstance(dst, str): dst = os.path.join(dst, "") - src_uri = bigframes.series.Series(self._block).struct.explode()["uri"] # Replace src folder with dst folder, keep the file names. - dst_uri = src_uri.str.replace(r"^.*\/(.*)$", rf"{dst}\1", regex=True) + dst_uri = self.uri().str.replace(FILE_FOLDER_REGEX, rf"{dst}\1", regex=True) dst = cast( bigframes.series.Series, dst_uri.str.to_blob(connection=connection) ) + ext = dst.blob.uri().str.extract(FILE_EXT_REGEX) + image_resize_udf = blob_func.TransformFunction( blob_func.image_resize_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, ).udf() - dst_rt = dst.blob._get_runtime_json_str(mode="RW") + dst_rt = dst.blob.get_runtime_json_str(mode="RW") df = df.join(dst_rt, how="outer") - df["dsize_x"], df["dsizye_y"] = dsize + df["dsize_x"], df["dsize_y"] = dsize df["fx"], df["fy"] = fx, fy + df["ext"] = ext # type: ignore + df["verbose"] = verbose - res = df.apply(image_resize_udf, axis=1) + res = self._apply_udf_or_raise_error(df, image_resize_udf, "Image resize") res.cache() # to execute the udf - return dst + if verbose: + resized_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + { + "status": resized_status_series, + "content": dst.blob.uri().str.to_blob( + connection=self._resolve_connection(connection) + ), + } + ) + results_struct = bbq.struct(results_df).rename("resized_results") + return results_struct + else: + return dst def image_normalize( self, *, + engine: Literal[None, "opencv"] = None, alpha: float = 1.0, beta: float = 0.0, norm_type: str = "l2", dst: Optional[Union[str, bigframes.series.Series]] = None, connection: Optional[str] = None, - max_batching_rows: int = 10000, + max_batching_rows: int = 8192, + container_cpu: Union[float, int] = 0.33, + container_memory: str = "512Mi", + verbose: bool = False, ) -> bigframes.series.Series: """Normalize images. - .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. - Args: + engine ('opencv' or None, default None): The engine (bigquery or third party library) used for the function. The value must be specified. alpha (float, default 1.0): Norm value to normalize to or the lower range boundary in case of the range normalization. beta (float, default 0.0): Upper range boundary in case of the range normalization; it is not used for the norm normalization. norm_type (str, default "l2"): Normalization type. Accepted values are "inf", "l1", "l2" and "minmax". - dst (str or bigframes.series.Series or None, default None): Destination GCS folder str or blob series. If None, output to BQ as bytes. + dst (str or bigframes.series.Series or None, default None): Output destination. Can be one of: + str: GCS folder str. The output filenames are the same as the input files. + blob Series: The output file paths are determined by the uris of the blob Series. + None: Output to BQ as bytes. + Encoding is determined by the extension of the output filenames (or input filenames if doesn't have output filenames). If filename doesn't have an extension, use ".jpeg" for encoding. connection (str or None, default None): BQ connection used for function internet transactions, and the output blob if "dst" is str. If None, uses default connection of the session. - max_batching_rows (int, default 10,000): Max number of rows per batch send to cloud run to execute the function. + max_batching_rows (int, default 8,192): Max number of rows per batch send to cloud run to execute the function. + container_cpu (int or float, default 0.33): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. + container_memory (str, default "512Mi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default False): If True, returns a struct with status and content fields. If False, returns only the content. Returns: - BigFrames Blob Series + bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. If verbose=True, returns struct with status and content. + + Raises: + ValueError: If engine is not 'opencv' or parameters are invalid. + RuntimeError: If image normalize operation fails. """ + if engine is None or engine.casefold() != "opencv": + raise ValueError("Must specify the engine, supported value is 'opencv'.") + + import bigframes.bigquery as bbq import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd connection = self._resolve_connection(connection) - df = self._get_runtime_json_str(mode="R").to_frame() + df = self.get_runtime_json_str(mode="R").to_frame() if dst is None: + ext = self.uri().str.extract(FILE_EXT_REGEX) + image_normalize_udf = blob_func.TransformFunction( blob_func.image_normalize_to_bytes_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, ).udf() df["alpha"] = alpha df["beta"] = beta df["norm_type"] = norm_type - res = df.apply(image_normalize_udf, axis=1) + df["ext"] = ext # type: ignore + df["verbose"] = verbose + res = self._apply_udf_or_raise_error( + df, image_normalize_udf, "Image normalize" + ) - return res + if verbose: + normalized_content_b64_series = res._apply_unary_op( + ops.JSONValue(json_path="$.content") + ) + normalized_bytes = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[normalized_content_b64_series] + ) + normalized_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + {"status": normalized_status_series, "content": normalized_bytes} + ) + results_struct = bbq.struct(results_df).rename("normalized_results") + return results_struct + else: + normalized_bytes = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[res] + ).rename("normalized_bytes") + return normalized_bytes if isinstance(dst, str): dst = os.path.join(dst, "") - src_uri = bigframes.series.Series(self._block).struct.explode()["uri"] # Replace src folder with dst folder, keep the file names. - dst_uri = src_uri.str.replace(r"^.*\/(.*)$", rf"{dst}\1", regex=True) + dst_uri = self.uri().str.replace(FILE_FOLDER_REGEX, rf"{dst}\1", regex=True) dst = cast( bigframes.series.Series, dst_uri.str.to_blob(connection=connection) ) + ext = dst.blob.uri().str.extract(FILE_EXT_REGEX) + image_normalize_udf = blob_func.TransformFunction( blob_func.image_normalize_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, ).udf() - dst_rt = dst.blob._get_runtime_json_str(mode="RW") + dst_rt = dst.blob.get_runtime_json_str(mode="RW") df = df.join(dst_rt, how="outer") df["alpha"] = alpha df["beta"] = beta df["norm_type"] = norm_type + df["ext"] = ext # type: ignore + df["verbose"] = verbose - res = df.apply(image_normalize_udf, axis=1) + res = self._apply_udf_or_raise_error(df, image_normalize_udf, "Image normalize") res.cache() # to execute the udf - return dst + if verbose: + normalized_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + { + "status": normalized_status_series, + "content": dst.blob.uri().str.to_blob( + connection=self._resolve_connection(connection) + ), + } + ) + results_struct = bbq.struct(results_df).rename("normalized_results") + return results_struct + else: + return dst def pdf_extract( self, *, + engine: Literal[None, "pypdf"] = None, connection: Optional[str] = None, - max_batching_rows: int = 10000, + max_batching_rows: int = 1, + container_cpu: Union[float, int] = 2, + container_memory: str = "1Gi", + verbose: bool = False, ) -> bigframes.series.Series: - """Extracts and chunks text from PDF URLs and saves the text as - arrays of string. - - .. note:: - BigFrames Blob is still under experiments. It may not work and - subject to change in the future. + """Extracts text from PDF URLs and saves the text as string. Args: + engine ('pypdf' or None, default None): The engine (bigquery or third party library) used for the function. The value must be specified. connection (str or None, default None): BQ connection used for function internet transactions, and the output blob if "dst" is str. If None, uses default connection of the session. - max_batching_rows (int, default 10,000): Max number of rows per batch + max_batching_rows (int, default 1): Max number of rows per batch send to cloud run to execute the function. + container_cpu (int or float, default 2): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. + container_memory (str, default "1Gi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default "False"): controls the verbosity of the output. + When set to True, both error messages and the extracted content + are displayed. Conversely, when set to False, only the extracted + content is presented, suppressing error messages. Returns: - bigframes.series.Series: conatins all text from a pdf file + bigframes.series.Series: str or struct[str, str], + depend on the "verbose" parameter. + Contains the extracted text from the PDF file. + Includes error messages if verbosity is enabled. + + Raises: + ValueError: If engine is not 'pypdf'. + RuntimeError: If PDF extraction fails or returns invalid structure. """ + if engine is None or engine.casefold() != "pypdf": + raise ValueError("Must specify the engine, supported value is 'pypdf'.") + import bigframes.bigquery as bbq import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd connection = self._resolve_connection(connection) pdf_extract_udf = blob_func.TransformFunction( blob_func.pdf_extract_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, ).udf() - src_rt = self._get_runtime_json_str(mode="R") - res = src_rt.apply(pdf_extract_udf) - return res + df = self.get_runtime_json_str(mode="R").to_frame() + df["verbose"] = verbose + + res = self._apply_udf_or_raise_error(df, pdf_extract_udf, "PDF extraction") + + if verbose: + # Extract content with error handling + try: + content_series = res._apply_unary_op( + ops.JSONValue(json_path="$.content") + ) + except Exception as e: + raise RuntimeError( + f"Failed to extract content field from PDF result: {e}" + ) from e + try: + status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) + except Exception as e: + raise RuntimeError( + f"Failed to extract status field from PDF result: {e}" + ) from e + + res_df = bpd.DataFrame({"status": status_series, "content": content_series}) + struct_series = bbq.struct(res_df).rename("extracted_results") + return struct_series + else: + return res.rename("extracted_content") def pdf_chunk( self, *, + engine: Literal[None, "pypdf"] = None, connection: Optional[str] = None, - chunk_size: int = 1000, + chunk_size: int = 2000, overlap_size: int = 200, - max_batching_rows: int = 10000, + max_batching_rows: int = 1, + container_cpu: Union[float, int] = 2, + container_memory: str = "1Gi", + verbose: bool = False, ) -> bigframes.series.Series: """Extracts and chunks text from PDF URLs and saves the text as arrays of strings. - .. note:: - BigFrames Blob is still under experiments. It may not work and - subject to change in the future. - Args: + engine ('pypdf' or None, default None): The engine (bigquery or third party library) used for the function. The value must be specified. connection (str or None, default None): BQ connection used for function internet transactions, and the output blob if "dst" is str. If None, uses default connection of the session. - chunk_size (int, default 1000): the desired size of each text chunk + chunk_size (int, default 2000): the desired size of each text chunk (number of characters). overlap_size (int, default 200): the number of overlapping characters between consective chunks. The helps to ensure context is perserved across chunk boundaries. - max_batching_rows (int, default 10,000): Max number of rows per batch + max_batching_rows (int, default 1): Max number of rows per batch send to cloud run to execute the function. + container_cpu (int or float, default 2): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. + container_memory (str, default "1Gi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default "False"): controls the verbosity of the output. + When set to True, both error messages and the extracted content + are displayed. Conversely, when set to False, only the extracted + content is presented, suppressing error messages. Returns: - bigframe.series.Series of array[str], where each string is a - chunk of text extracted from PDF. + bigframe.series.Series: array[str] or struct[str, array[str]], + depend on the "verbose" parameter. + where each string is a chunk of text extracted from PDF. + Includes error messages if verbosity is enabled. + + Raises: + ValueError: If engine is not 'pypdf'. + RuntimeError: If PDF chunking fails or returns invalid structure. """ + if engine is None or engine.casefold() != "pypdf": + raise ValueError("Must specify the engine, supported value is 'pypdf'.") import bigframes.bigquery as bbq import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd connection = self._resolve_connection(connection) @@ -576,17 +960,120 @@ def pdf_chunk( pdf_chunk_udf = blob_func.TransformFunction( blob_func.pdf_chunk_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, ).udf() - src_rt = self._get_runtime_json_str(mode="R") - df = src_rt.to_frame() + df = self.get_runtime_json_str(mode="R").to_frame() df["chunk_size"] = chunk_size df["overlap_size"] = overlap_size + df["verbose"] = verbose + + res = self._apply_udf_or_raise_error(df, pdf_chunk_udf, "PDF chunking") + + try: + content_series = bbq.json_extract_string_array(res, "$.content") + except Exception as e: + raise RuntimeError( + f"Failed to extract content array from PDF chunk result: {e}" + ) from e + + if verbose: + try: + status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) + except Exception as e: + raise RuntimeError( + f"Failed to extract status field from PDF chunk result: {e}" + ) from e + + results_df = bpd.DataFrame( + {"status": status_series, "content": content_series} + ) + resultes_struct = bbq.struct(results_df).rename("chunked_results") + return resultes_struct + else: + return bbq.json_extract_string_array(res, "$").rename("chunked_content") + + def audio_transcribe( + self, + *, + engine: Literal["bigquery"] = "bigquery", + connection: Optional[str] = None, + model_name: Optional[ + Literal[ + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ] + ] = None, + verbose: bool = False, + ) -> bigframes.series.Series: + """ + Transcribe audio content using a Gemini multimodal model. + + Args: + engine ('bigquery'): The engine (bigquery or third party library) used for the function. + connection (str or None, default None): BQ connection used for + function internet transactions, and the output blob if "dst" + is str. If None, uses default connection of the session. + model_name (str): The model for natural language tasks. Accepted + values are "gemini-2.0-flash-lite-001", and "gemini-2.0-flash-001". + See "https://ai.google.dev/gemini-api/docs/models" for model choices. + verbose (bool, default "False"): controls the verbosity of the output. + When set to True, both error messages and the transcribed content + are displayed. Conversely, when set to False, only the transcribed + content is presented, suppressing error messages. + + Returns: + bigframes.series.Series: str or struct[str, str], + depend on the "verbose" parameter. + Contains the transcribed text from the audio file. + Includes error messages if verbosity is enabled. + + Raises: + ValueError: If engine is not 'bigquery'. + RuntimeError: If the transcription result structure is invalid. + """ + if engine.casefold() != "bigquery": + raise ValueError("Must specify the engine, supported value is 'bigquery'.") + + import bigframes.bigquery as bbq + import bigframes.pandas as bpd + + # col name doesn't matter here. Rename to avoid column name conflicts + audio_series = bigframes.series.Series(self._data._block) - res = df.apply(pdf_chunk_udf, axis=1) + prompt_text = "**Task:** Transcribe the provided audio. **Instructions:** - Your response must contain only the verbatim transcription of the audio. - Do not include any introductory text, summaries, or conversational filler in your response. The output should begin directly with the first word of the audio." - res_array = bbq.json_extract_string_array(res) - return res_array + # Convert the audio series to the runtime representation required by the model. + audio_runtime = audio_series.blob._get_runtime("R", with_metadata=True) + + transcribed_results = bbq.ai.generate( + prompt=(prompt_text, audio_runtime), + connection_id=connection, + endpoint=model_name, + model_params={"generationConfig": {"temperature": 0.0}}, + ) + + # Validate that the result is not None + if transcribed_results is None: + raise RuntimeError("Transcription returned None result") + + transcribed_content_series = transcribed_results.struct.field("result").rename( + "transcribed_content" + ) + + if verbose: + transcribed_status_series = transcribed_results.struct.field("status") + results_df = bpd.DataFrame( + { + "status": transcribed_status_series, + "content": transcribed_content_series, + } + ) + results_struct = bbq.struct(results_df).rename("transcription_results") + return results_struct + else: + return transcribed_content_series.rename("transcribed_content") diff --git a/bigframes/operations/blob_ops.py b/bigframes/operations/blob_ops.py index b17d1b1215..29f23a2f70 100644 --- a/bigframes/operations/blob_ops.py +++ b/bigframes/operations/blob_ops.py @@ -19,9 +19,10 @@ from bigframes.operations import base_ops import bigframes.operations.type as op_typing -obj_fetch_metadata_op = base_ops.create_unary_op( +ObjFetchMetadataOp = base_ops.create_unary_op( name="obj_fetch_metadata", type_signature=op_typing.BLOB_TRANSFORM ) +obj_fetch_metadata_op = ObjFetchMetadataOp() @dataclasses.dataclass(frozen=True) @@ -35,11 +36,11 @@ def output_type(self, *input_types): @dataclasses.dataclass(frozen=True) class ObjMakeRef(base_ops.BinaryOp): - name: typing.ClassVar[str] = "obj.make_ref" + name: typing.ClassVar[str] = "obj_make_ref" def output_type(self, *input_types): if not all(map(dtypes.is_string_like, input_types)): - raise TypeError("obj.make_ref requires string-like arguments") + raise TypeError("obj_make_ref requires string-like arguments") return dtypes.OBJ_REF_DTYPE diff --git a/bigframes/operations/bool_ops.py b/bigframes/operations/bool_ops.py index c8cd08efe5..003318f822 100644 --- a/bigframes/operations/bool_ops.py +++ b/bigframes/operations/bool_ops.py @@ -16,8 +16,11 @@ from bigframes.operations import base_ops import bigframes.operations.type as op_typing -and_op = base_ops.create_binary_op(name="and", type_signature=op_typing.LOGICAL) +AndOp = base_ops.create_binary_op(name="and", type_signature=op_typing.LOGICAL) +and_op = AndOp() -or_op = base_ops.create_binary_op(name="or", type_signature=op_typing.LOGICAL) +OrOp = base_ops.create_binary_op(name="or", type_signature=op_typing.LOGICAL) +or_op = OrOp() -xor_op = base_ops.create_binary_op(name="xor", type_signature=op_typing.LOGICAL) +XorOp = base_ops.create_binary_op(name="xor", type_signature=op_typing.LOGICAL) +xor_op = XorOp() diff --git a/bigframes/operations/comparison_ops.py b/bigframes/operations/comparison_ops.py index b109a85d18..4c2911808d 100644 --- a/bigframes/operations/comparison_ops.py +++ b/bigframes/operations/comparison_ops.py @@ -16,18 +16,25 @@ from bigframes.operations import base_ops import bigframes.operations.type as op_typing -eq_op = base_ops.create_binary_op(name="eq", type_signature=op_typing.COMPARISON) +EqOp = base_ops.create_binary_op(name="eq", type_signature=op_typing.COMPARISON) +eq_op = EqOp() -eq_null_match_op = base_ops.create_binary_op( +EqNullsMatchOp = base_ops.create_binary_op( name="eq_nulls_match", type_signature=op_typing.COMPARISON ) +eq_null_match_op = EqNullsMatchOp() -ne_op = base_ops.create_binary_op(name="ne", type_signature=op_typing.COMPARISON) +NeOp = base_ops.create_binary_op(name="ne", type_signature=op_typing.COMPARISON) +ne_op = NeOp() -lt_op = base_ops.create_binary_op(name="lt", type_signature=op_typing.COMPARISON) +LtOp = base_ops.create_binary_op(name="lt", type_signature=op_typing.COMPARISON) +lt_op = LtOp() -gt_op = base_ops.create_binary_op(name="gt", type_signature=op_typing.COMPARISON) +GtOp = base_ops.create_binary_op(name="gt", type_signature=op_typing.COMPARISON) +gt_op = GtOp() -le_op = base_ops.create_binary_op(name="le", type_signature=op_typing.COMPARISON) +LeOp = base_ops.create_binary_op(name="le", type_signature=op_typing.COMPARISON) +le_op = LeOp() -ge_op = base_ops.create_binary_op(name="ge", type_signature=op_typing.COMPARISON) +GeOp = base_ops.create_binary_op(name="ge", type_signature=op_typing.COMPARISON) +ge_op = GeOp() diff --git a/bigframes/operations/date_ops.py b/bigframes/operations/date_ops.py index 2b68a24caf..352bc9f93e 100644 --- a/bigframes/operations/date_ops.py +++ b/bigframes/operations/date_ops.py @@ -12,30 +12,81 @@ # See the License for the specific language governing permissions and # limitations under the License. +import dataclasses +import typing + +from bigframes import dtypes from bigframes.operations import base_ops import bigframes.operations.type as op_typing -day_op = base_ops.create_unary_op( +DayOp = base_ops.create_unary_op( name="day", type_signature=op_typing.DATELIKE_ACCESSOR, ) +day_op = DayOp() -month_op = base_ops.create_unary_op( +MonthOp = base_ops.create_unary_op( name="month", type_signature=op_typing.DATELIKE_ACCESSOR, ) +month_op = MonthOp() -year_op = base_ops.create_unary_op( +YearOp = base_ops.create_unary_op( name="year", type_signature=op_typing.DATELIKE_ACCESSOR, ) +year_op = YearOp() + +IsoDayOp = base_ops.create_unary_op( + name="iso_day", type_signature=op_typing.DATELIKE_ACCESSOR +) +iso_day_op = IsoDayOp() + +IsoWeekOp = base_ops.create_unary_op( + name="iso_weeek", + type_signature=op_typing.DATELIKE_ACCESSOR, +) +iso_week_op = IsoWeekOp() -dayofweek_op = base_ops.create_unary_op( +IsoYearOp = base_ops.create_unary_op( + name="iso_year", + type_signature=op_typing.DATELIKE_ACCESSOR, +) +iso_year_op = IsoYearOp() + +DayOfWeekOp = base_ops.create_unary_op( name="dayofweek", type_signature=op_typing.DATELIKE_ACCESSOR, ) +dayofweek_op = DayOfWeekOp() -quarter_op = base_ops.create_unary_op( +DayOfYearOp = base_ops.create_unary_op( + name="dayofyear", + type_signature=op_typing.DATELIKE_ACCESSOR, +) +dayofyear_op = DayOfYearOp() + +QuarterOp = base_ops.create_unary_op( name="quarter", type_signature=op_typing.DATELIKE_ACCESSOR, ) +quarter_op = QuarterOp() + + +@dataclasses.dataclass(frozen=True) +class DateDiffOp(base_ops.BinaryOp): + name: typing.ClassVar[str] = "date_diff" + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if input_types[0] is not input_types[1]: + raise TypeError( + f"two inputs have different types. left: {input_types[0]}, right: {input_types[1]}" + ) + + if input_types[0] != dtypes.DATE_DTYPE: + raise TypeError("expected date input") + + return dtypes.TIMEDELTA_DTYPE + + +date_diff_op = DateDiffOp() diff --git a/bigframes/operations/datetime_ops.py b/bigframes/operations/datetime_ops.py index 3ea4c652f1..9988e8ed7b 100644 --- a/bigframes/operations/datetime_ops.py +++ b/bigframes/operations/datetime_ops.py @@ -22,19 +22,43 @@ from bigframes.operations import base_ops import bigframes.operations.type as op_typing -date_op = base_ops.create_unary_op( +DateOp = base_ops.create_unary_op( name="date", type_signature=op_typing.FixedOutputType( dtypes.is_date_like, dtypes.DATE_DTYPE, description="date-like" ), ) +date_op = DateOp() -time_op = base_ops.create_unary_op( +TimeOp = base_ops.create_unary_op( name="time", type_signature=op_typing.FixedOutputType( dtypes.is_time_like, dtypes.TIME_DTYPE, description="time-like" ), ) +time_op = TimeOp() + + +@dataclasses.dataclass(frozen=True) +class ParseDatetimeOp(base_ops.UnaryOp): + # TODO: Support strict format + name: typing.ClassVar[str] = "parse_datetime" + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if input_types[0] != dtypes.STRING_DTYPE: + raise TypeError("expected string input") + return pd.ArrowDtype(pa.timestamp("us", tz=None)) + + +@dataclasses.dataclass(frozen=True) +class ParseTimestampOp(base_ops.UnaryOp): + # TODO: Support strict format + name: typing.ClassVar[str] = "parse_timestamp" + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if input_types[0] != dtypes.STRING_DTYPE: + raise TypeError("expected string input") + return pd.ArrowDtype(pa.timestamp("us", tz="UTC")) @dataclasses.dataclass(frozen=True) @@ -48,6 +72,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT dtypes.FLOAT_DTYPE, dtypes.INT_DTYPE, dtypes.STRING_DTYPE, + dtypes.DATE_DTYPE, ): raise TypeError("expected string or numeric input") return pd.ArrowDtype(pa.timestamp("us", tz=None)) @@ -65,6 +90,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT dtypes.FLOAT_DTYPE, dtypes.INT_DTYPE, dtypes.STRING_DTYPE, + dtypes.DATE_DTYPE, ): raise TypeError("expected string or numeric input") return pd.ArrowDtype(pa.timestamp("us", tz="UTC")) @@ -84,7 +110,7 @@ class UnixSeconds(base_ops.UnaryOp): name: typing.ClassVar[str] = "unix_seconds" def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - if input_types[0] is not dtypes.TIMESTAMP_DTYPE: + if input_types[0] != dtypes.TIMESTAMP_DTYPE: raise TypeError("expected timestamp input") return dtypes.INT_DTYPE @@ -94,7 +120,7 @@ class UnixMillis(base_ops.UnaryOp): name: typing.ClassVar[str] = "unix_millis" def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - if input_types[0] is not dtypes.TIMESTAMP_DTYPE: + if input_types[0] != dtypes.TIMESTAMP_DTYPE: raise TypeError("expected timestamp input") return dtypes.INT_DTYPE @@ -104,7 +130,7 @@ class UnixMicros(base_ops.UnaryOp): name: typing.ClassVar[str] = "unix_micros" def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - if input_types[0] is not dtypes.TIMESTAMP_DTYPE: + if input_types[0] != dtypes.TIMESTAMP_DTYPE: raise TypeError("expected timestamp input") return dtypes.INT_DTYPE @@ -114,7 +140,7 @@ class TimestampDiff(base_ops.BinaryOp): name: typing.ClassVar[str] = "timestamp_diff" def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - if input_types[0] is not input_types[1]: + if input_types[0] != input_types[1]: raise TypeError( f"two inputs have different types. left: {input_types[0]}, right: {input_types[1]}" ) diff --git a/bigframes/operations/datetimes.py b/bigframes/operations/datetimes.py index 7d25ac3622..c259dd018e 100644 --- a/bigframes/operations/datetimes.py +++ b/bigframes/operations/datetimes.py @@ -19,67 +19,127 @@ import bigframes_vendored.pandas.core.arrays.datetimelike as vendored_pandas_datetimelike import bigframes_vendored.pandas.core.indexes.accessor as vendordt +import pandas +from bigframes import dataframe, dtypes, series from bigframes.core import log_adapter import bigframes.operations as ops -import bigframes.operations.base -import bigframes.series as series + +_ONE_DAY = pandas.Timedelta("1D") +_ONE_SECOND = pandas.Timedelta("1s") +_ONE_MICRO = pandas.Timedelta("1us") +_SUPPORTED_FREQS = ("Y", "Q", "M", "W", "D", "h", "min", "s", "ms", "us") @log_adapter.class_logger class DatetimeMethods( - bigframes.operations.base.SeriesMethods, vendordt.DatetimeProperties, vendored_pandas_datetimelike.DatelikeOps, ): __doc__ = vendordt.DatetimeProperties.__doc__ + def __init__(self, data: series.Series): + self._data = data + # Date accessors @property def day(self) -> series.Series: - return self._apply_unary_op(ops.day_op) + return self._data._apply_unary_op(ops.day_op) @property def dayofweek(self) -> series.Series: - return self._apply_unary_op(ops.dayofweek_op) + return self._data._apply_unary_op(ops.dayofweek_op) + + @property + def day_of_week(self) -> series.Series: + return self.dayofweek + + @property + def weekday(self) -> series.Series: + return self.dayofweek + + @property + def dayofyear(self) -> series.Series: + return self._data._apply_unary_op(ops.dayofyear_op) + + @property + def day_of_year(self) -> series.Series: + return self.dayofyear @property def date(self) -> series.Series: - return self._apply_unary_op(ops.date_op) + return self._data._apply_unary_op(ops.date_op) @property def quarter(self) -> series.Series: - return self._apply_unary_op(ops.quarter_op) + return self._data._apply_unary_op(ops.quarter_op) @property def year(self) -> series.Series: - return self._apply_unary_op(ops.year_op) + return self._data._apply_unary_op(ops.year_op) @property def month(self) -> series.Series: - return self._apply_unary_op(ops.month_op) + return self._data._apply_unary_op(ops.month_op) + + def isocalendar(self) -> dataframe.DataFrame: + iso_ops = [ops.iso_year_op, ops.iso_week_op, ops.iso_day_op] + labels = pandas.Index(["year", "week", "day"]) + block = self._data._block.project_exprs( + [op.as_expr(self._data._value_column) for op in iso_ops], labels, drop=True + ) + return dataframe.DataFrame(block) # Time accessors @property def hour(self) -> series.Series: - return self._apply_unary_op(ops.hour_op) + return self._data._apply_unary_op(ops.hour_op) @property def minute(self) -> series.Series: - return self._apply_unary_op(ops.minute_op) + return self._data._apply_unary_op(ops.minute_op) @property def second(self) -> series.Series: - return self._apply_unary_op(ops.second_op) + return self._data._apply_unary_op(ops.second_op) @property def time(self) -> series.Series: - return self._apply_unary_op(ops.time_op) + return self._data._apply_unary_op(ops.time_op) + + # Timedelta accessors + @property + def days(self) -> series.Series: + self._check_dtype(dtypes.TIMEDELTA_DTYPE) + + return self._data._apply_binary_op(_ONE_DAY, ops.floordiv_op) + + @property + def seconds(self) -> series.Series: + self._check_dtype(dtypes.TIMEDELTA_DTYPE) + + return self._data._apply_binary_op(_ONE_DAY, ops.mod_op) // _ONE_SECOND # type: ignore + + @property + def microseconds(self) -> series.Series: + self._check_dtype(dtypes.TIMEDELTA_DTYPE) + + return self._data._apply_binary_op(_ONE_SECOND, ops.mod_op) // _ONE_MICRO # type: ignore + + def total_seconds(self) -> series.Series: + self._check_dtype(dtypes.TIMEDELTA_DTYPE) + + return self._data._apply_binary_op(_ONE_SECOND, ops.div_op) + + def _check_dtype(self, target_dtype: dtypes.Dtype): + if self._data._dtype == target_dtype: + return + raise TypeError(f"Expect dtype: {target_dtype}, but got {self._data._dtype}") @property def tz(self) -> Optional[dt.timezone]: # Assumption: pyarrow dtype - tz_string = self._dtype.pyarrow_dtype.tz + tz_string = self._data._dtype.pyarrow_dtype.tz if tz_string == "UTC": return dt.timezone.utc elif tz_string is None: @@ -90,13 +150,18 @@ def tz(self) -> Optional[dt.timezone]: @property def unit(self) -> str: # Assumption: pyarrow dtype - return self._dtype.pyarrow_dtype.unit + return self._data._dtype.pyarrow_dtype.unit + + def day_name(self) -> series.Series: + return self.strftime("%A") def strftime(self, date_format: str) -> series.Series: - return self._apply_unary_op(ops.StrftimeOp(date_format=date_format)) + return self._data._apply_unary_op(ops.StrftimeOp(date_format=date_format)) def normalize(self) -> series.Series: - return self._apply_unary_op(ops.normalize_op) + return self._data._apply_unary_op(ops.normalize_op) def floor(self, freq: str) -> series.Series: - return self._apply_unary_op(ops.FloorDtOp(freq=freq)) + if freq not in _SUPPORTED_FREQS: + raise ValueError(f"freq must be one of {_SUPPORTED_FREQS}") + return self._data._apply_unary_op(ops.FloorDtOp(freq=freq)) # type: ignore diff --git a/bigframes/operations/distance_ops.py b/bigframes/operations/distance_ops.py index 74595b561a..ac0863b9e6 100644 --- a/bigframes/operations/distance_ops.py +++ b/bigframes/operations/distance_ops.py @@ -16,14 +16,17 @@ from bigframes.operations import base_ops import bigframes.operations.type as op_typing -cosine_distance_op = base_ops.create_binary_op( +CosineDistanceOp = base_ops.create_binary_op( name="ml_cosine_distance", type_signature=op_typing.VECTOR_METRIC ) +cosine_distance_op = CosineDistanceOp() -manhattan_distance_op = base_ops.create_binary_op( +ManhattanDistanceOp = base_ops.create_binary_op( name="ml_manhattan_distance", type_signature=op_typing.VECTOR_METRIC ) +manhattan_distance_op = ManhattanDistanceOp() -euclidean_distance_op = base_ops.create_binary_op( +EuclidDistanceOp = base_ops.create_binary_op( name="ml_euclidean_distance", type_signature=op_typing.VECTOR_METRIC ) +euclidean_distance_op = EuclidDistanceOp() diff --git a/bigframes/operations/frequency_ops.py b/bigframes/operations/frequency_ops.py index 2d5a854c32..b94afa7271 100644 --- a/bigframes/operations/frequency_ops.py +++ b/bigframes/operations/frequency_ops.py @@ -27,9 +27,22 @@ @dataclasses.dataclass(frozen=True) class FloorDtOp(base_ops.UnaryOp): name: typing.ClassVar[str] = "floor_dt" - freq: str + freq: typing.Literal[ + "Y", + "Q", + "M", + "W", + "D", + "h", + "min", + "s", + "ms", + "us", + ] def output_type(self, *input_types): + if not dtypes.is_datetime_like(input_types[0]): + raise TypeError("dt floor requires datetime-like arguments") return input_types[0] diff --git a/bigframes/operations/generic_ops.py b/bigframes/operations/generic_ops.py index b90a43b091..d6155a770c 100644 --- a/bigframes/operations/generic_ops.py +++ b/bigframes/operations/generic_ops.py @@ -20,34 +20,312 @@ from bigframes.operations import base_ops import bigframes.operations.type as op_typing -invert_op = base_ops.create_unary_op( +InvertOp = base_ops.create_unary_op( name="invert", type_signature=op_typing.TypePreserving( dtypes.is_binary_like, description="binary-like", ), ) +invert_op = InvertOp() -isnull_op = base_ops.create_unary_op( +IsNullOp = base_ops.create_unary_op( name="isnull", type_signature=op_typing.FixedOutputType( lambda x: True, dtypes.BOOL_DTYPE, description="nullable" ), ) +isnull_op = IsNullOp() -notnull_op = base_ops.create_unary_op( +NotNullOp = base_ops.create_unary_op( name="notnull", type_signature=op_typing.FixedOutputType( lambda x: True, dtypes.BOOL_DTYPE, description="nullable" ), ) +notnull_op = NotNullOp() -hash_op = base_ops.create_unary_op( +HashOp = base_ops.create_unary_op( name="hash", type_signature=op_typing.FixedOutputType( dtypes.is_string_like, dtypes.INT_DTYPE, description="string-like" ), ) +hash_op = HashOp() + +# source, dest type +_VALID_CASTS = set( + ( + # INT casts + ( + dtypes.BOOL_DTYPE, + dtypes.INT_DTYPE, + ), + ( + dtypes.FLOAT_DTYPE, + dtypes.INT_DTYPE, + ), + ( + dtypes.NUMERIC_DTYPE, + dtypes.INT_DTYPE, + ), + ( + dtypes.BIGNUMERIC_DTYPE, + dtypes.INT_DTYPE, + ), + ( + dtypes.TIME_DTYPE, + dtypes.INT_DTYPE, + ), + ( + dtypes.DATETIME_DTYPE, + dtypes.INT_DTYPE, + ), + ( + dtypes.TIMESTAMP_DTYPE, + dtypes.INT_DTYPE, + ), + ( + dtypes.TIMEDELTA_DTYPE, + dtypes.INT_DTYPE, + ), + ( + dtypes.STRING_DTYPE, + dtypes.INT_DTYPE, + ), + ( + dtypes.JSON_DTYPE, + dtypes.INT_DTYPE, + ), + # Float casts + ( + dtypes.BOOL_DTYPE, + dtypes.FLOAT_DTYPE, + ), + ( + dtypes.NUMERIC_DTYPE, + dtypes.FLOAT_DTYPE, + ), + ( + dtypes.BIGNUMERIC_DTYPE, + dtypes.FLOAT_DTYPE, + ), + ( + dtypes.INT_DTYPE, + dtypes.FLOAT_DTYPE, + ), + ( + dtypes.STRING_DTYPE, + dtypes.FLOAT_DTYPE, + ), + ( + dtypes.JSON_DTYPE, + dtypes.FLOAT_DTYPE, + ), + # Bool casts + ( + dtypes.INT_DTYPE, + dtypes.BOOL_DTYPE, + ), + ( + dtypes.FLOAT_DTYPE, + dtypes.BOOL_DTYPE, + ), + ( + dtypes.JSON_DTYPE, + dtypes.BOOL_DTYPE, + ), + # String casts + ( + dtypes.BYTES_DTYPE, + dtypes.STRING_DTYPE, + ), + ( + dtypes.BOOL_DTYPE, + dtypes.STRING_DTYPE, + ), + ( + dtypes.FLOAT_DTYPE, + dtypes.STRING_DTYPE, + ), + ( + dtypes.TIME_DTYPE, + dtypes.STRING_DTYPE, + ), + ( + dtypes.INT_DTYPE, + dtypes.STRING_DTYPE, + ), + ( + dtypes.DATETIME_DTYPE, + dtypes.STRING_DTYPE, + ), + ( + dtypes.TIMESTAMP_DTYPE, + dtypes.STRING_DTYPE, + ), + ( + dtypes.DATE_DTYPE, + dtypes.STRING_DTYPE, + ), + ( + dtypes.JSON_DTYPE, + dtypes.STRING_DTYPE, + ), + # bytes casts + ( + dtypes.STRING_DTYPE, + dtypes.BYTES_DTYPE, + ), + # decimal casts + ( + dtypes.STRING_DTYPE, + dtypes.NUMERIC_DTYPE, + ), + ( + dtypes.INT_DTYPE, + dtypes.NUMERIC_DTYPE, + ), + ( + dtypes.FLOAT_DTYPE, + dtypes.NUMERIC_DTYPE, + ), + ( + dtypes.BIGNUMERIC_DTYPE, + dtypes.NUMERIC_DTYPE, + ), + # big decimal casts + ( + dtypes.STRING_DTYPE, + dtypes.BIGNUMERIC_DTYPE, + ), + ( + dtypes.INT_DTYPE, + dtypes.BIGNUMERIC_DTYPE, + ), + ( + dtypes.FLOAT_DTYPE, + dtypes.BIGNUMERIC_DTYPE, + ), + ( + dtypes.NUMERIC_DTYPE, + dtypes.BIGNUMERIC_DTYPE, + ), + # time casts + ( + dtypes.INT_DTYPE, + dtypes.TIME_DTYPE, + ), + ( + dtypes.DATETIME_DTYPE, + dtypes.TIME_DTYPE, + ), + ( + dtypes.TIMESTAMP_DTYPE, + dtypes.TIME_DTYPE, + ), + # date casts + ( + dtypes.STRING_DTYPE, + dtypes.DATE_DTYPE, + ), + ( + dtypes.DATETIME_DTYPE, + dtypes.DATE_DTYPE, + ), + ( + dtypes.TIMESTAMP_DTYPE, + dtypes.DATE_DTYPE, + ), + # datetime casts + ( + dtypes.DATE_DTYPE, + dtypes.DATETIME_DTYPE, + ), + ( + dtypes.STRING_DTYPE, + dtypes.DATETIME_DTYPE, + ), + ( + dtypes.TIMESTAMP_DTYPE, + dtypes.DATETIME_DTYPE, + ), + ( + dtypes.INT_DTYPE, + dtypes.DATETIME_DTYPE, + ), + # timestamp casts + ( + dtypes.DATE_DTYPE, + dtypes.TIMESTAMP_DTYPE, + ), + ( + dtypes.STRING_DTYPE, + dtypes.TIMESTAMP_DTYPE, + ), + ( + dtypes.DATETIME_DTYPE, + dtypes.TIMESTAMP_DTYPE, + ), + ( + dtypes.INT_DTYPE, + dtypes.TIMESTAMP_DTYPE, + ), + # timedelta casts + ( + dtypes.INT_DTYPE, + dtypes.TIMEDELTA_DTYPE, + ), + # json casts + ( + dtypes.BOOL_DTYPE, + dtypes.JSON_DTYPE, + ), + ( + dtypes.FLOAT_DTYPE, + dtypes.JSON_DTYPE, + ), + ( + dtypes.STRING_DTYPE, + dtypes.JSON_DTYPE, + ), + ( + dtypes.INT_DTYPE, + dtypes.JSON_DTYPE, + ), + ) +) + + +def _valid_scalar_cast(src: dtypes.Dtype, dst: dtypes.Dtype): + if src == dst: + return True + elif (src, dst) in _VALID_CASTS: + return True + return False + + +def _valid_cast(src: dtypes.Dtype, dst: dtypes.Dtype): + if src == dst: + return True + # TODO: Might need to be more strict within list/array context + if dtypes.is_array_like(src) and dtypes.is_array_like(dst): + src_inner = dtypes.get_array_inner_type(src) + dst_inner = dtypes.get_array_inner_type(dst) + return _valid_cast(src_inner, dst_inner) + if dtypes.is_struct_like(src) and dtypes.is_struct_like(dst): + src_fields = dtypes.get_struct_fields(src) + dst_fields = dtypes.get_struct_fields(dst) + if len(src_fields) != len(dst_fields): + return False + for (_, src_dtype), (_, dst_dtype) in zip( + src_fields.items(), dst_fields.items() + ): + if not _valid_cast(src_dtype, dst_dtype): + return False + return True + + return _valid_scalar_cast(src, dst) @dataclasses.dataclass(frozen=True) @@ -58,6 +336,9 @@ class AsTypeOp(base_ops.UnaryOp): safe: bool = False def output_type(self, *input_types): + if not _valid_cast(input_types[0], self.to_type): + raise TypeError(f"Cannot cast {input_types[0]} to {self.to_type}") + return self.to_type @@ -80,15 +361,17 @@ def output_type(self, *input_types): return input_types[0] -fillna_op = base_ops.create_binary_op(name="fillna", type_signature=op_typing.COERCE) +FillNaOp = base_ops.create_binary_op(name="fillna", type_signature=op_typing.COERCE) +fillna_op = FillNaOp() -maximum_op = base_ops.create_binary_op(name="maximum", type_signature=op_typing.COERCE) +MaximumOp = base_ops.create_binary_op(name="maximum", type_signature=op_typing.COERCE) +maximum_op = MaximumOp() -minimum_op = base_ops.create_binary_op(name="minimum", type_signature=op_typing.COERCE) +MinimumOp = base_ops.create_binary_op(name="minimum", type_signature=op_typing.COERCE) +minimum_op = MinimumOp() -coalesce_op = base_ops.create_binary_op( - name="coalesce", type_signature=op_typing.COERCE -) +CoalesceOp = base_ops.create_binary_op(name="coalesce", type_signature=op_typing.COERCE) +coalesce_op = CoalesceOp() @dataclasses.dataclass(frozen=True) @@ -163,3 +446,15 @@ class SqlScalarOp(base_ops.NaryOp): def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: return self._output_type + + +@dataclasses.dataclass(frozen=True) +class PyUdfOp(base_ops.NaryOp): + """Represents a local UDF.""" + + name: typing.ClassVar[str] = "py_udf" + fn: typing.Callable + _output_type: dtypes.ExpressionType + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return self._output_type diff --git a/bigframes/operations/geo_ops.py b/bigframes/operations/geo_ops.py index 04441957e7..75fef1b832 100644 --- a/bigframes/operations/geo_ops.py +++ b/bigframes/operations/geo_ops.py @@ -12,47 +12,157 @@ # See the License for the specific language governing permissions and # limitations under the License. +import dataclasses +from typing import Optional + from bigframes import dtypes from bigframes.operations import base_ops import bigframes.operations.type as op_typing -geo_x_op = base_ops.create_unary_op( - name="geo_x", +GeoAreaOp = base_ops.create_unary_op( + name="geo_area", type_signature=op_typing.FixedOutputType( dtypes.is_geo_like, dtypes.FLOAT_DTYPE, description="geo-like" ), ) +geo_area_op = GeoAreaOp() -geo_y_op = base_ops.create_unary_op( - name="geo_y", +GeoStAstextOp = base_ops.create_unary_op( + name="geo_st_astext", type_signature=op_typing.FixedOutputType( - dtypes.is_geo_like, dtypes.FLOAT_DTYPE, description="geo-like" + dtypes.is_geo_like, dtypes.STRING_DTYPE, description="geo-like" ), ) +geo_st_astext_op = GeoStAstextOp() -geo_area_op = base_ops.create_unary_op( - name="geo_area", +GeoStBoundaryOp = base_ops.create_unary_op( + name="geo_st_boundary", type_signature=op_typing.FixedOutputType( - dtypes.is_geo_like, dtypes.FLOAT_DTYPE, description="geo-like" + dtypes.is_geo_like, dtypes.GEO_DTYPE, description="geo-like" ), ) +geo_st_boundary_op = GeoStBoundaryOp() +GeoStCentroidOp = base_ops.create_unary_op( + name="geo_st_centroid", + type_signature=op_typing.FixedOutputType( + dtypes.is_geo_like, dtypes.GEO_DTYPE, description="geo-like" + ), +) +geo_st_centroid_op = GeoStCentroidOp() -geo_st_astext_op = base_ops.create_unary_op( - name="geo_st_astext", +GeoStConvexhullOp = base_ops.create_unary_op( + name="geo_st_convexhull", type_signature=op_typing.FixedOutputType( - dtypes.is_geo_like, dtypes.STRING_DTYPE, description="geo-like" + dtypes.is_geo_like, dtypes.GEO_DTYPE, description="geo-like" ), ) +geo_st_convexhull_op = GeoStConvexhullOp() +GeoStDifferenceOp = base_ops.create_binary_op( + name="geo_st_difference", type_signature=op_typing.BinaryGeo() +) +geo_st_difference_op = GeoStDifferenceOp() -geo_st_geogfromtext_op = base_ops.create_unary_op( +GeoStGeogfromtextOp = base_ops.create_unary_op( name="geo_st_geogfromtext", type_signature=op_typing.FixedOutputType( dtypes.is_string_like, dtypes.GEO_DTYPE, description="string-like" ), ) +geo_st_geogfromtext_op = GeoStGeogfromtextOp() -geo_st_geogpoint_op = base_ops.create_binary_op( +GeoStGeogpointOp = base_ops.create_binary_op( name="geo_st_geogpoint", type_signature=op_typing.BinaryNumericGeo() ) +geo_st_geogpoint_op = GeoStGeogpointOp() + +GeoStIsclosedOp = base_ops.create_unary_op( + name="geo_st_isclosed", + type_signature=op_typing.FixedOutputType( + dtypes.is_geo_like, dtypes.BOOL_DTYPE, description="geo-like" + ), +) +geo_st_isclosed_op = GeoStIsclosedOp() + +GeoXOp = base_ops.create_unary_op( + name="geo_x", + type_signature=op_typing.FixedOutputType( + dtypes.is_geo_like, dtypes.FLOAT_DTYPE, description="geo-like" + ), +) +geo_x_op = GeoXOp() + +GeoYOp = base_ops.create_unary_op( + name="geo_y", + type_signature=op_typing.FixedOutputType( + dtypes.is_geo_like, dtypes.FLOAT_DTYPE, description="geo-like" + ), +) +geo_y_op = GeoYOp() + +GeoStIntersectionOp = base_ops.create_binary_op( + name="geo_st_intersection", type_signature=op_typing.BinaryGeo() +) +geo_st_intersection_op = GeoStIntersectionOp() + + +@dataclasses.dataclass(frozen=True) +class GeoStBufferOp(base_ops.UnaryOp): + name = "st_buffer" + buffer_radius: float + num_seg_quarter_circle: float + use_spheroid: bool + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.GEO_DTYPE + + +@dataclasses.dataclass(frozen=True) +class GeoStDistanceOp(base_ops.BinaryOp): + name = "st_distance" + use_spheroid: bool + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.FLOAT_DTYPE + + +@dataclasses.dataclass(frozen=True) +class GeoStLengthOp(base_ops.UnaryOp): + name = "geo_st_length" + use_spheroid: bool = False + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.FLOAT_DTYPE + + +@dataclasses.dataclass(frozen=True) +class GeoStRegionStatsOp(base_ops.UnaryOp): + """See: https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions#st_regionstats""" + + name = "geo_st_regionstats" + raster_id: str + band: Optional[str] + include: Optional[str] + options: Optional[str] + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.struct_type( + [ + ("min", dtypes.FLOAT_DTYPE), + ("max", dtypes.FLOAT_DTYPE), + ("sum", dtypes.FLOAT_DTYPE), + ("count", dtypes.INT_DTYPE), + ("mean", dtypes.FLOAT_DTYPE), + ("area", dtypes.FLOAT_DTYPE), + ] + ) + + +@dataclasses.dataclass(frozen=True) +class GeoStSimplifyOp(base_ops.UnaryOp): + name = "st_simplify" + tolerance_meters: float + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.GEO_DTYPE diff --git a/bigframes/operations/json_ops.py b/bigframes/operations/json_ops.py index 1daacf4e6b..7260a79223 100644 --- a/bigframes/operations/json_ops.py +++ b/bigframes/operations/json_ops.py @@ -31,12 +31,29 @@ def output_type(self, *input_types): input_type = input_types[0] if not dtypes.is_json_like(input_type): raise TypeError( - "Input type must be an valid JSON object or JSON-formatted string type." + "Input type must be a valid JSON object or JSON-formatted string type." + f" Received type: {input_type}" ) return input_type +@dataclasses.dataclass(frozen=True) +class JSONQueryArray(base_ops.UnaryOp): + name: typing.ClassVar[str] = "json_query_array" + json_path: str + + def output_type(self, *input_types): + input_type = input_types[0] + if not dtypes.is_json_like(input_type): + raise TypeError( + "Input type must be a valid JSON object or JSON-formatted string type." + + f" Received type: {input_type}" + ) + return pd.ArrowDtype( + pa.list_(dtypes.bigframes_dtype_to_arrow_dtype(input_type)) + ) + + @dataclasses.dataclass(frozen=True) class JSONExtractArray(base_ops.UnaryOp): name: typing.ClassVar[str] = "json_extract_array" @@ -46,7 +63,7 @@ def output_type(self, *input_types): input_type = input_types[0] if not dtypes.is_json_like(input_type): raise TypeError( - "Input type must be an valid JSON object or JSON-formatted string type." + "Input type must be a valid JSON object or JSON-formatted string type." + f" Received type: {input_type}" ) return pd.ArrowDtype( @@ -63,7 +80,7 @@ def output_type(self, *input_types): input_type = input_types[0] if not dtypes.is_json_like(input_type): raise TypeError( - "Input type must be an valid JSON object or JSON-formatted string type." + "Input type must be a valid JSON object or JSON-formatted string type." + f" Received type: {input_type}" ) return pd.ArrowDtype( @@ -79,22 +96,36 @@ def output_type(self, *input_types): input_type = input_types[0] if input_type != dtypes.STRING_DTYPE: raise TypeError( - "Input type must be an valid JSON-formatted string type." + "Input type must be a valid JSON-formatted string type." + f" Received type: {input_type}" ) return dtypes.JSON_DTYPE +@dataclasses.dataclass(frozen=True) +class ToJSON(base_ops.UnaryOp): + name: typing.ClassVar[str] = "to_json" + + def output_type(self, *input_types): + input_type = input_types[0] + if not dtypes.is_json_encoding_type(input_type): + raise TypeError( + "The value to be assigned must be a type that can be encoded as JSON." + + f"Received type: {input_type}" + ) + return dtypes.JSON_DTYPE + + @dataclasses.dataclass(frozen=True) class ToJSONString(base_ops.UnaryOp): name: typing.ClassVar[str] = "to_json_string" def output_type(self, *input_types): input_type = input_types[0] - if not dtypes.is_json_like(input_type): + if not dtypes.is_json_encoding_type(input_type): raise TypeError( - "Input type must be an valid JSON object or JSON-formatted string type." - + f" Received type: {input_type}" + "The value to be assigned must be a type that can be encoded as JSON." + + f"Received type: {input_type}" ) return dtypes.STRING_DTYPE @@ -109,7 +140,7 @@ def output_type(self, *input_types): right_type = input_types[1] if not dtypes.is_json_like(left_type): raise TypeError( - "Input type must be an valid JSON object or JSON-formatted string type." + "Input type must be a valid JSON object or JSON-formatted string type." + f" Received type: {left_type}" ) if not dtypes.is_json_encoding_type(right_type): @@ -130,7 +161,71 @@ def output_type(self, *input_types): input_type = input_types[0] if not dtypes.is_json_like(input_type): raise TypeError( - "Input type must be an valid JSON object or JSON-formatted string type." + "Input type must be a valid JSON object or JSON-formatted string type." + f" Received type: {input_type}" ) return dtypes.STRING_DTYPE + + +@dataclasses.dataclass(frozen=True) +class JSONValueArray(base_ops.UnaryOp): + name: typing.ClassVar[str] = "json_value_array" + json_path: str + + def output_type(self, *input_types): + input_type = input_types[0] + if not dtypes.is_json_like(input_type): + raise TypeError( + "Input type must be a valid JSON object or JSON-formatted string type." + + f" Received type: {input_type}" + ) + return pd.ArrowDtype( + pa.list_(dtypes.bigframes_dtype_to_arrow_dtype(dtypes.STRING_DTYPE)) + ) + + +@dataclasses.dataclass(frozen=True) +class JSONQuery(base_ops.UnaryOp): + name: typing.ClassVar[str] = "json_query" + json_path: str + + def output_type(self, *input_types): + input_type = input_types[0] + if not dtypes.is_json_like(input_type): + raise TypeError( + "Input type must be a valid JSON object or JSON-formatted string type." + + f" Received type: {input_type}" + ) + return input_type + + +@dataclasses.dataclass(frozen=True) +class JSONKeys(base_ops.UnaryOp): + name: typing.ClassVar[str] = "json_keys" + max_depth: typing.Optional[int] = None + + def output_type(self, *input_types): + input_type = input_types[0] + if input_type != dtypes.JSON_DTYPE: + raise TypeError( + "Input type must be a valid JSON object or JSON-formatted string type." + + f" Received type: {input_type}" + ) + return pd.ArrowDtype( + pa.list_(dtypes.bigframes_dtype_to_arrow_dtype(dtypes.STRING_DTYPE)) + ) + + +@dataclasses.dataclass(frozen=True) +class JSONDecode(base_ops.UnaryOp): + name: typing.ClassVar[str] = "json_decode" + to_type: dtypes.Dtype + + def output_type(self, *input_types): + input_type = input_types[0] + if not dtypes.is_json_like(input_type): + raise TypeError( + "Input type must be a valid JSON object or JSON-formatted string type." + + f" Received type: {input_type}" + ) + return self.to_type diff --git a/bigframes/operations/lists.py b/bigframes/operations/lists.py index 16c22dfb2a..34ecdd8118 100644 --- a/bigframes/operations/lists.py +++ b/bigframes/operations/lists.py @@ -22,24 +22,24 @@ from bigframes.core import log_adapter import bigframes.operations as ops from bigframes.operations._op_converters import convert_index, convert_slice -import bigframes.operations.base import bigframes.series as series @log_adapter.class_logger -class ListAccessor( - bigframes.operations.base.SeriesMethods, vendoracessors.ListAccessor -): +class ListAccessor(vendoracessors.ListAccessor): __doc__ = vendoracessors.ListAccessor.__doc__ + def __init__(self, data: series.Series): + self._data = data + def len(self): - return self._apply_unary_op(ops.len_op) + return self._data._apply_unary_op(ops.len_op) def __getitem__(self, key: Union[int, slice]) -> series.Series: if isinstance(key, int): - return self._apply_unary_op(convert_index(key)) + return self._data._apply_unary_op(convert_index(key)) elif isinstance(key, slice): - return self._apply_unary_op(convert_slice(key)) + return self._data._apply_unary_op(convert_slice(key)) else: raise ValueError(f"key must be an int or slice, got {type(key).__name__}") diff --git a/bigframes/operations/numeric_ops.py b/bigframes/operations/numeric_ops.py index f5a290bde5..83e2078c88 100644 --- a/bigframes/operations/numeric_ops.py +++ b/bigframes/operations/numeric_ops.py @@ -19,97 +19,118 @@ from bigframes.operations import base_ops import bigframes.operations.type as op_typing -sin_op = base_ops.create_unary_op( +SinOp = base_ops.create_unary_op( name="sin", type_signature=op_typing.UNARY_REAL_NUMERIC ) +sin_op = SinOp() -cos_op = base_ops.create_unary_op( +CosOp = base_ops.create_unary_op( name="cos", type_signature=op_typing.UNARY_REAL_NUMERIC ) +cos_op = CosOp() -tan_op = base_ops.create_unary_op( +TanOp = base_ops.create_unary_op( name="tan", type_signature=op_typing.UNARY_REAL_NUMERIC ) +tan_op = TanOp() -arcsin_op = base_ops.create_unary_op( +ArcsinOp = base_ops.create_unary_op( name="arcsin", type_signature=op_typing.UNARY_REAL_NUMERIC ) +arcsin_op = ArcsinOp() -arccos_op = base_ops.create_unary_op( +ArccosOp = base_ops.create_unary_op( name="arccos", type_signature=op_typing.UNARY_REAL_NUMERIC ) +arccos_op = ArccosOp() -arctan_op = base_ops.create_unary_op( +ArctanOp = base_ops.create_unary_op( name="arctan", type_signature=op_typing.UNARY_REAL_NUMERIC ) +arctan_op = ArctanOp() -sinh_op = base_ops.create_unary_op( +SinhOp = base_ops.create_unary_op( name="sinh", type_signature=op_typing.UNARY_REAL_NUMERIC ) +sinh_op = SinhOp() -cosh_op = base_ops.create_unary_op( +CoshOp = base_ops.create_unary_op( name="cosh", type_signature=op_typing.UNARY_REAL_NUMERIC ) +cosh_op = CoshOp() -tanh_op = base_ops.create_unary_op( +TanhOp = base_ops.create_unary_op( name="tanh", type_signature=op_typing.UNARY_REAL_NUMERIC ) +tanh_op = TanhOp() -arcsinh_op = base_ops.create_unary_op( +ArcsinhOp = base_ops.create_unary_op( name="arcsinh", type_signature=op_typing.UNARY_REAL_NUMERIC ) +arcsinh_op = ArcsinhOp() -arccosh_op = base_ops.create_unary_op( +ArccoshOp = base_ops.create_unary_op( name="arccosh", type_signature=op_typing.UNARY_REAL_NUMERIC ) +arccosh_op = ArccoshOp() -arctanh_op = base_ops.create_unary_op( +ArctanhOp = base_ops.create_unary_op( name="arctanh", type_signature=op_typing.UNARY_REAL_NUMERIC ) +arctanh_op = ArctanhOp() -floor_op = base_ops.create_unary_op( +FloorOp = base_ops.create_unary_op( name="floor", type_signature=op_typing.UNARY_REAL_NUMERIC ) +floor_op = FloorOp() -ceil_op = base_ops.create_unary_op( +CeilOp = base_ops.create_unary_op( name="ceil", type_signature=op_typing.UNARY_REAL_NUMERIC ) +ceil_op = CeilOp() -abs_op = base_ops.create_unary_op( +AbsOp = base_ops.create_unary_op( name="abs", type_signature=op_typing.UNARY_NUMERIC_AND_TIMEDELTA ) +abs_op = AbsOp() -pos_op = base_ops.create_unary_op( +PosOp = base_ops.create_unary_op( name="pos", type_signature=op_typing.UNARY_NUMERIC_AND_TIMEDELTA ) +pos_op = PosOp() -neg_op = base_ops.create_unary_op( +NegOp = base_ops.create_unary_op( name="neg", type_signature=op_typing.UNARY_NUMERIC_AND_TIMEDELTA ) +neg_op = NegOp() -exp_op = base_ops.create_unary_op( +ExpOp = base_ops.create_unary_op( name="exp", type_signature=op_typing.UNARY_REAL_NUMERIC ) +exp_op = ExpOp() -expm1_op = base_ops.create_unary_op( +Expm1Op = base_ops.create_unary_op( name="expm1", type_signature=op_typing.UNARY_REAL_NUMERIC ) +expm1_op = Expm1Op() -ln_op = base_ops.create_unary_op( - name="log", type_signature=op_typing.UNARY_REAL_NUMERIC -) +LnOp = base_ops.create_unary_op(name="log", type_signature=op_typing.UNARY_REAL_NUMERIC) +ln_op = LnOp() -log10_op = base_ops.create_unary_op( +Log10Op = base_ops.create_unary_op( name="log10", type_signature=op_typing.UNARY_REAL_NUMERIC ) +log10_op = Log10Op() -log1p_op = base_ops.create_unary_op( +Log1pOp = base_ops.create_unary_op( name="log1p", type_signature=op_typing.UNARY_REAL_NUMERIC ) +log1p_op = Log1pOp() -sqrt_op = base_ops.create_unary_op( +SqrtOp = base_ops.create_unary_op( name="sqrt", type_signature=op_typing.UNARY_REAL_NUMERIC ) +sqrt_op = SqrtOp() @dataclasses.dataclass(frozen=True) @@ -119,17 +140,24 @@ class AddOp(base_ops.BinaryOp): def output_type(self, *input_types): left_type = input_types[0] right_type = input_types[1] - if all(map(dtypes.is_string_like, input_types)) and len(set(input_types)) == 1: + # TODO: Binary/bytes addition requires impl + if all(map(lambda t: t == dtypes.STRING_DTYPE, input_types)): # String addition return input_types[0] - # Timestamp addition. - if dtypes.is_datetime_like(left_type) and right_type is dtypes.TIMEDELTA_DTYPE: + # Temporal addition. + if dtypes.is_datetime_like(left_type) and right_type == dtypes.TIMEDELTA_DTYPE: return left_type - if left_type is dtypes.TIMEDELTA_DTYPE and dtypes.is_datetime_like(right_type): + if left_type == dtypes.TIMEDELTA_DTYPE and dtypes.is_datetime_like(right_type): return right_type - if left_type is dtypes.TIMEDELTA_DTYPE and right_type is dtypes.TIMEDELTA_DTYPE: + if left_type == dtypes.DATE_DTYPE and right_type == dtypes.TIMEDELTA_DTYPE: + return dtypes.DATETIME_DTYPE + + if left_type == dtypes.TIMEDELTA_DTYPE and right_type == dtypes.DATE_DTYPE: + return dtypes.DATETIME_DTYPE + + if left_type == dtypes.TIMEDELTA_DTYPE and right_type == dtypes.TIMEDELTA_DTYPE: return dtypes.TIMEDELTA_DTYPE if (left_type is None or dtypes.is_numeric(left_type)) and ( @@ -152,15 +180,27 @@ def output_type(self, *input_types): left_type = input_types[0] right_type = input_types[1] - if dtypes.is_datetime_like(left_type) and dtypes.is_datetime_like(right_type): + if left_type == dtypes.DATETIME_DTYPE and right_type == dtypes.DATETIME_DTYPE: + return dtypes.TIMEDELTA_DTYPE + + if left_type == dtypes.TIMESTAMP_DTYPE and right_type == dtypes.TIMESTAMP_DTYPE: return dtypes.TIMEDELTA_DTYPE - if dtypes.is_datetime_like(left_type) and right_type is dtypes.TIMEDELTA_DTYPE: + if left_type == dtypes.DATE_DTYPE and right_type == dtypes.DATE_DTYPE: + return dtypes.TIMEDELTA_DTYPE + + if dtypes.is_datetime_like(left_type) and right_type == dtypes.TIMEDELTA_DTYPE: return left_type - if left_type is dtypes.TIMEDELTA_DTYPE and right_type is dtypes.TIMEDELTA_DTYPE: + if left_type == dtypes.DATE_DTYPE and right_type == dtypes.TIMEDELTA_DTYPE: + return dtypes.DATETIME_DTYPE + + if left_type == dtypes.TIMEDELTA_DTYPE and right_type == dtypes.TIMEDELTA_DTYPE: return dtypes.TIMEDELTA_DTYPE + if left_type == dtypes.BOOL_DTYPE and right_type == dtypes.BOOL_DTYPE: + raise TypeError(f"Cannot subtract dtypes {left_type} and {right_type}") + if (left_type is None or dtypes.is_numeric(left_type)) and ( right_type is None or dtypes.is_numeric(right_type) ): @@ -181,9 +221,15 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT left_type = input_types[0] right_type = input_types[1] - if left_type is dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right_type): + if left_type == dtypes.TIMEDELTA_DTYPE and right_type in ( + dtypes.INT_DTYPE, + dtypes.FLOAT_DTYPE, + ): return dtypes.TIMEDELTA_DTYPE - if dtypes.is_numeric(left_type) and right_type is dtypes.TIMEDELTA_DTYPE: + if ( + left_type in (dtypes.INT_DTYPE, dtypes.FLOAT_DTYPE) + and right_type == dtypes.TIMEDELTA_DTYPE + ): return dtypes.TIMEDELTA_DTYPE if (left_type is None or dtypes.is_numeric(left_type)) and ( @@ -205,12 +251,16 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT left_type = input_types[0] right_type = input_types[1] - if left_type is dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right_type): + if left_type == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right_type): + # will fail outright if result undefined or otherwise can't be coerced back into an int return dtypes.TIMEDELTA_DTYPE - if left_type is dtypes.TIMEDELTA_DTYPE and right_type is dtypes.TIMEDELTA_DTYPE: + if left_type == dtypes.TIMEDELTA_DTYPE and right_type == dtypes.TIMEDELTA_DTYPE: return dtypes.FLOAT_DTYPE + if left_type == dtypes.BOOL_DTYPE and right_type == dtypes.BOOL_DTYPE: + raise TypeError(f"Cannot divide dtypes {left_type} and {right_type}") + if (left_type is None or dtypes.is_numeric(left_type)) and ( right_type is None or dtypes.is_numeric(right_type) ): @@ -232,11 +282,14 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT left_type = input_types[0] right_type = input_types[1] - if left_type is dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right_type): + if left_type == dtypes.TIMEDELTA_DTYPE and right_type == dtypes.TIMEDELTA_DTYPE: + return dtypes.INT_DTYPE + + if left_type == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right_type): return dtypes.TIMEDELTA_DTYPE - if left_type is dtypes.TIMEDELTA_DTYPE and right_type is dtypes.TIMEDELTA_DTYPE: - return dtypes.INT_DTYPE + if left_type == dtypes.BOOL_DTYPE and right_type == dtypes.BOOL_DTYPE: + raise TypeError(f"Cannot floor divide dtypes {left_type} and {right_type}") if (left_type is None or dtypes.is_numeric(left_type)) and ( right_type is None or dtypes.is_numeric(right_type) @@ -248,18 +301,66 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT floordiv_op = FloorDivOp() -pow_op = base_ops.create_binary_op(name="pow", type_signature=op_typing.BINARY_NUMERIC) -mod_op = base_ops.create_binary_op(name="mod", type_signature=op_typing.BINARY_NUMERIC) +@dataclasses.dataclass(frozen=True) +class ModOp(base_ops.BinaryOp): + name: typing.ClassVar[str] = "mod" + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + left_type = input_types[0] + right_type = input_types[1] + + if left_type == dtypes.TIMEDELTA_DTYPE and right_type == dtypes.TIMEDELTA_DTYPE: + return dtypes.TIMEDELTA_DTYPE + if left_type in ( + dtypes.NUMERIC_DTYPE, + dtypes.BIGNUMERIC_DTYPE, + ) or right_type in (dtypes.NUMERIC_DTYPE, dtypes.BIGNUMERIC_DTYPE): + raise TypeError(f"Cannot mod dtypes {left_type} and {right_type}") + + if left_type == dtypes.BOOL_DTYPE and right_type == dtypes.BOOL_DTYPE: + raise TypeError(f"Cannot mod dtypes {left_type} and {right_type}") + + if (left_type is None or dtypes.is_numeric(left_type)) and ( + right_type is None or dtypes.is_numeric(right_type) + ): + return dtypes.coerce_to_common(left_type, right_type) + + raise TypeError(f"Cannot mod dtypes {left_type} and {right_type}") + + +mod_op = ModOp() + +PowOp = base_ops.create_binary_op(name="pow", type_signature=op_typing.BINARY_NUMERIC) +pow_op = PowOp() -arctan2_op = base_ops.create_binary_op( +Arctan2Op = base_ops.create_binary_op( name="arctan2", type_signature=op_typing.BINARY_REAL_NUMERIC ) +arctan2_op = Arctan2Op() -round_op = base_ops.create_binary_op( - name="round", type_signature=op_typing.BINARY_REAL_NUMERIC +RoundOp = base_ops.create_binary_op( + name="round", type_signature=op_typing.BINARY_NUMERIC ) +round_op = RoundOp() -unsafe_pow_op = base_ops.create_binary_op( +UnsafePowOp = base_ops.create_binary_op( name="unsafe_pow_op", type_signature=op_typing.BINARY_REAL_NUMERIC ) +unsafe_pow_op = UnsafePowOp() + +IsNanOp = base_ops.create_unary_op( + name="isnan", + type_signature=op_typing.FixedOutputType( + dtypes.is_numeric, dtypes.BOOL_DTYPE, "numeric" + ), +) +isnan_op = IsNanOp() + +IsFiniteOp = base_ops.create_unary_op( + name="isfinite", + type_signature=op_typing.FixedOutputType( + dtypes.is_numeric, dtypes.BOOL_DTYPE, "numeric" + ), +) +isfinite_op = IsFiniteOp() diff --git a/bigframes/operations/numpy_op_maps.py b/bigframes/operations/numpy_op_maps.py index 7f3decdfa0..791e2eb890 100644 --- a/bigframes/operations/numpy_op_maps.py +++ b/bigframes/operations/numpy_op_maps.py @@ -40,6 +40,8 @@ np.ceil: numeric_ops.ceil_op, np.log1p: numeric_ops.log1p_op, np.expm1: numeric_ops.expm1_op, + np.isnan: numeric_ops.isnan_op, + np.isfinite: numeric_ops.isfinite_op, } diff --git a/bigframes/operations/output_schemas.py b/bigframes/operations/output_schemas.py new file mode 100644 index 0000000000..ff9c9883dc --- /dev/null +++ b/bigframes/operations/output_schemas.py @@ -0,0 +1,90 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pyarrow as pa + + +def parse_sql_type(sql: str) -> pa.DataType: + """ + Parses a SQL type string to its PyArrow equivalence: + + For example: + "STRING" -> pa.string() + "ARRAY" -> pa.list_(pa.int64()) + "STRUCT, y BOOL>" -> pa.struct( + ( + pa.field("x", pa.list_(pa.float64())), + pa.field("y", pa.bool_()), + ) + ) + """ + sql = sql.strip() + + if sql.upper() == "STRING": + return pa.string() + + if sql.upper() == "INT64": + return pa.int64() + + if sql.upper() == "FLOAT64": + return pa.float64() + + if sql.upper() == "BOOL": + return pa.bool_() + + if sql.upper().startswith("ARRAY<") and sql.endswith(">"): + inner_type = sql[len("ARRAY<") : -1] + return pa.list_(parse_sql_type(inner_type)) + + if sql.upper().startswith("STRUCT<") and sql.endswith(">"): + inner_fields = parse_sql_fields(sql[len("STRUCT<") : -1]) + return pa.struct(inner_fields) + + raise ValueError(f"Unsupported SQL type: {sql}") + + +def parse_sql_fields(sql: str) -> tuple[pa.Field]: + sql = sql.strip() + + start_idx = 0 + nested_depth = 0 + fields: list[pa.field] = [] + + for end_idx in range(len(sql)): + c = sql[end_idx] + + if c == "<": + nested_depth += 1 + elif c == ">": + nested_depth -= 1 + elif c == "," and nested_depth == 0: + field = sql[start_idx:end_idx] + fields.append(parse_sql_field(field)) + start_idx = end_idx + 1 + + # Append the last field + fields.append(parse_sql_field(sql[start_idx:])) + + return tuple(sorted(fields, key=lambda f: f.name)) + + +def parse_sql_field(sql: str) -> pa.Field: + sql = sql.strip() + + space_idx = sql.find(" ") + + if space_idx == -1: + raise ValueError(f"Invalid struct field: {sql}") + + return pa.field(sql[:space_idx].strip(), parse_sql_type(sql[space_idx:])) diff --git a/bigframes/operations/plotting.py b/bigframes/operations/plotting.py index e9a86be6c9..df0c138f0f 100644 --- a/bigframes/operations/plotting.py +++ b/bigframes/operations/plotting.py @@ -17,14 +17,16 @@ import bigframes_vendored.constants as constants import bigframes_vendored.pandas.plotting._core as vendordt +from bigframes.core import log_adapter import bigframes.operations._matplotlib as bfplt +@log_adapter.class_logger class PlotAccessor(vendordt.PlotAccessor): __doc__ = vendordt.PlotAccessor.__doc__ - _common_kinds = ("line", "area", "hist", "bar") - _dataframe_kinds = ("scatter",) + _common_kinds = ("line", "area", "hist", "bar", "barh", "pie") + _dataframe_kinds = ("scatter", "hexbin,") _all_kinds = _common_kinds + _dataframe_kinds def __call__(self, **kwargs): @@ -80,6 +82,21 @@ def bar( ): return self(kind="bar", x=x, y=y, **kwargs) + def barh( + self, + x: typing.Optional[typing.Hashable] = None, + y: typing.Optional[typing.Hashable] = None, + **kwargs, + ): + return self(kind="barh", x=x, y=y, **kwargs) + + def pie( + self, + y: typing.Optional[typing.Hashable] = None, + **kwargs, + ): + return self(kind="pie", y=y, **kwargs) + def scatter( self, x: typing.Optional[typing.Hashable] = None, diff --git a/bigframes/operations/python_op_maps.py b/bigframes/operations/python_op_maps.py new file mode 100644 index 0000000000..39f153ec05 --- /dev/null +++ b/bigframes/operations/python_op_maps.py @@ -0,0 +1,85 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math +import operator +from typing import Optional + +import bigframes.operations +from bigframes.operations import ( + aggregations, + array_ops, + bool_ops, + comparison_ops, + numeric_ops, + string_ops, +) + +PYTHON_TO_BIGFRAMES = { + ## operators + operator.add: numeric_ops.add_op, + operator.sub: numeric_ops.sub_op, + operator.mul: numeric_ops.mul_op, + operator.truediv: numeric_ops.div_op, + operator.floordiv: numeric_ops.floordiv_op, + operator.mod: numeric_ops.mod_op, + operator.pow: numeric_ops.pow_op, + operator.pos: numeric_ops.pos_op, + operator.neg: numeric_ops.neg_op, + operator.abs: numeric_ops.abs_op, + operator.eq: comparison_ops.eq_op, + operator.ne: comparison_ops.ne_op, + operator.gt: comparison_ops.gt_op, + operator.lt: comparison_ops.lt_op, + operator.ge: comparison_ops.ge_op, + operator.le: comparison_ops.le_op, + operator.and_: bool_ops.and_op, + operator.or_: bool_ops.or_op, + operator.xor: bool_ops.xor_op, + ## math + math.log: numeric_ops.ln_op, + math.log10: numeric_ops.log10_op, + math.log1p: numeric_ops.log1p_op, + math.expm1: numeric_ops.expm1_op, + math.sin: numeric_ops.sin_op, + math.cos: numeric_ops.cos_op, + math.tan: numeric_ops.tan_op, + math.sinh: numeric_ops.sinh_op, + math.cosh: numeric_ops.cosh_op, + math.tanh: numeric_ops.tanh_op, + math.asin: numeric_ops.arcsin_op, + math.acos: numeric_ops.arccos_op, + math.atan: numeric_ops.arctan_op, + math.floor: numeric_ops.floor_op, + math.ceil: numeric_ops.ceil_op, + ## str + str.upper: string_ops.upper_op, + str.lower: string_ops.lower_op, + ## builtins + len: string_ops.len_op, + abs: numeric_ops.abs_op, + pow: numeric_ops.pow_op, + ### builtins -- iterable + all: array_ops.ArrayReduceOp(aggregations.all_op), + any: array_ops.ArrayReduceOp(aggregations.any_op), + sum: array_ops.ArrayReduceOp(aggregations.sum_op), + min: array_ops.ArrayReduceOp(aggregations.min_op), + max: array_ops.ArrayReduceOp(aggregations.max_op), +} + + +def python_callable_to_op(obj) -> Optional[bigframes.operations.RowOp]: + if obj in PYTHON_TO_BIGFRAMES: + return PYTHON_TO_BIGFRAMES[obj] + return None diff --git a/bigframes/operations/remote_function_ops.py b/bigframes/operations/remote_function_ops.py index 5b738c0bb5..e610ce61d6 100644 --- a/bigframes/operations/remote_function_ops.py +++ b/bigframes/operations/remote_function_ops.py @@ -15,14 +15,15 @@ import dataclasses import typing -from bigframes import dtypes +from bigframes.functions import udf_def from bigframes.operations import base_ops +# TODO: Enforce input type constraints from function def @dataclasses.dataclass(frozen=True) class RemoteFunctionOp(base_ops.UnaryOp): name: typing.ClassVar[str] = "remote_function" - func: typing.Callable + function_def: udf_def.BigqueryUdf apply_on_null: bool @property @@ -30,63 +31,30 @@ def expensive(self) -> bool: return True def output_type(self, *input_types): - # This property should be set to a valid Dtype by the @remote_function decorator or read_gbq_function method - if hasattr(self.func, "output_dtype"): - if dtypes.is_array_like(self.func.output_dtype): - # TODO(b/284515241): remove this special handling to support - # array output types once BQ remote functions support ARRAY. - # Until then, use json serialized strings at the remote function - # level, and parse that to the intended output type at the - # bigframes level. - return dtypes.STRING_DTYPE - return self.func.output_dtype - else: - raise AttributeError("output_dtype not defined") + return self.function_def.bigframes_output_type @dataclasses.dataclass(frozen=True) class BinaryRemoteFunctionOp(base_ops.BinaryOp): name: typing.ClassVar[str] = "binary_remote_function" - func: typing.Callable + function_def: udf_def.BigqueryUdf @property def expensive(self) -> bool: return True def output_type(self, *input_types): - # This property should be set to a valid Dtype by the @remote_function decorator or read_gbq_function method - if hasattr(self.func, "output_dtype"): - if dtypes.is_array_like(self.func.output_dtype): - # TODO(b/284515241): remove this special handling to support - # array output types once BQ remote functions support ARRAY. - # Until then, use json serialized strings at the remote function - # level, and parse that to the intended output type at the - # bigframes level. - return dtypes.STRING_DTYPE - return self.func.output_dtype - else: - raise AttributeError("output_dtype not defined") + return self.function_def.bigframes_output_type @dataclasses.dataclass(frozen=True) class NaryRemoteFunctionOp(base_ops.NaryOp): name: typing.ClassVar[str] = "nary_remote_function" - func: typing.Callable + function_def: udf_def.BigqueryUdf @property def expensive(self) -> bool: return True def output_type(self, *input_types): - # This property should be set to a valid Dtype by the @remote_function decorator or read_gbq_function method - if hasattr(self.func, "output_dtype"): - if dtypes.is_array_like(self.func.output_dtype): - # TODO(b/284515241): remove this special handling to support - # array output types once BQ remote functions support ARRAY. - # Until then, use json serialized strings at the remote function - # level, and parse that to the intended output type at the - # bigframes level. - return dtypes.STRING_DTYPE - return self.func.output_dtype - else: - raise AttributeError("output_dtype not defined") + return self.function_def.bigframes_output_type diff --git a/bigframes/operations/semantics.py b/bigframes/operations/semantics.py index 3b7a77e5b7..2266702d47 100644 --- a/bigframes/operations/semantics.py +++ b/bigframes/operations/semantics.py @@ -52,12 +52,11 @@ def agg( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 >>> import bigframes.ml.llm as llm - >>> model = llm.GeminiTextGenerator(model_name="gemini-1.5-flash-001") + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") # doctest: +SKIP >>> df = bpd.DataFrame( ... { @@ -68,7 +67,7 @@ def agg( ... ], ... "Year": [1997, 2013, 2010], ... }) - >>> df.semantics.agg( + >>> df.semantics.agg( # doctest: +SKIP ... "Find the first name shared by all actors in {Movies}. One word answer.", ... model=model, ... ) @@ -141,11 +140,11 @@ def agg( column = columns[0] if ground_with_google_search: - msg = ( + msg = exceptions.format_message( "Enables Grounding with Google Search may impact billing cost. See pricing " "details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models" ) - warnings.warn(msg) + warnings.warn(msg, category=UserWarning) user_instruction = self._format_instruction(instruction, columns) @@ -247,12 +246,11 @@ def cluster_by( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 >>> import bigframes.ml.llm as llm - >>> model = llm.TextEmbeddingGenerator() + >>> model = llm.TextEmbeddingGenerator(model_name="text-embedding-005") >>> df = bpd.DataFrame({ ... "Product": ["Smartphone", "Laptop", "T-shirt", "Jeans"], @@ -321,15 +319,14 @@ def filter(self, instruction: str, model, ground_with_google_search: bool = Fals **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 >>> import bigframes.ml.llm as llm - >>> model = llm.GeminiTextGenerator(model_name="gemini-1.5-flash-001") + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") # doctest: +SKIP >>> df = bpd.DataFrame({"country": ["USA", "Germany"], "city": ["Seattle", "Berlin"]}) - >>> df.semantics.filter("{city} is the capital of {country}", model) + >>> df.semantics.filter("{city} is the capital of {country}", model) # doctest: +SKIP country city 1 Germany Berlin @@ -372,30 +369,51 @@ def filter(self, instruction: str, model, ground_with_google_search: bool = Fals raise ValueError(f"Column {column} not found.") if ground_with_google_search: - msg = ( + msg = exceptions.format_message( "Enables Grounding with Google Search may impact billing cost. See pricing " "details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models" ) - warnings.warn(msg) + warnings.warn(msg, category=UserWarning) self._confirm_operation(len(self._df)) df: bigframes.dataframe.DataFrame = self._df[columns].copy() + has_blob_column = False for column in columns: + if df[column].dtype == dtypes.OBJ_REF_DTYPE: + # Don't cast blob columns to string + has_blob_column = True + continue + if df[column].dtype != dtypes.STRING_DTYPE: df[column] = df[column].astype(dtypes.STRING_DTYPE) user_instruction = self._format_instruction(instruction, columns) output_instruction = "Based on the provided context, reply to the following claim by only True or False:" - results = typing.cast( - bigframes.dataframe.DataFrame, - model.predict( - self._make_prompt(df, columns, user_instruction, output_instruction), - temperature=0.0, - ground_with_google_search=ground_with_google_search, - ), - ) + if has_blob_column: + results = typing.cast( + bigframes.dataframe.DataFrame, + model.predict( + df, + prompt=self._make_multimodel_prompt( + df, columns, user_instruction, output_instruction + ), + temperature=0.0, + ground_with_google_search=ground_with_google_search, + ), + ) + else: + results = typing.cast( + bigframes.dataframe.DataFrame, + model.predict( + self._make_text_prompt( + df, columns, user_instruction, output_instruction + ), + temperature=0.0, + ground_with_google_search=ground_with_google_search, + ), + ) return self._df[ results["ml_generate_text_llm_result"].str.lower().str.contains("true") @@ -414,15 +432,14 @@ def map( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 >>> import bigframes.ml.llm as llm - >>> model = llm.GeminiTextGenerator(model_name="gemini-1.5-flash-001") + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") # doctest: +SKIP >>> df = bpd.DataFrame({"ingredient_1": ["Burger Bun", "Soy Bean"], "ingredient_2": ["Beef Patty", "Bittern"]}) - >>> df.semantics.map("What is the food made from {ingredient_1} and {ingredient_2}? One word only.", output_column="food", model=model) + >>> df.semantics.map("What is the food made from {ingredient_1} and {ingredient_2}? One word only.", output_column="food", model=model) # doctest: +SKIP ingredient_1 ingredient_2 food 0 Burger Bun Beef Patty Burger @@ -471,16 +488,22 @@ def map( raise ValueError(f"Column {column} not found.") if ground_with_google_search: - msg = ( + msg = exceptions.format_message( "Enables Grounding with Google Search may impact billing cost. See pricing " "details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models" ) - warnings.warn(msg) + warnings.warn(msg, category=UserWarning) self._confirm_operation(len(self._df)) df: bigframes.dataframe.DataFrame = self._df[columns].copy() + has_blob_column = False for column in columns: + if df[column].dtype == dtypes.OBJ_REF_DTYPE: + # Don't cast blob columns to string + has_blob_column = True + continue + if df[column].dtype != dtypes.STRING_DTYPE: df[column] = df[column].astype(dtypes.STRING_DTYPE) @@ -489,14 +512,29 @@ def map( "Based on the provided contenxt, answer the following instruction:" ) - results = typing.cast( - bigframes.series.Series, - model.predict( - self._make_prompt(df, columns, user_instruction, output_instruction), - temperature=0.0, - ground_with_google_search=ground_with_google_search, - )["ml_generate_text_llm_result"], - ) + if has_blob_column: + results = typing.cast( + bigframes.series.Series, + model.predict( + df, + prompt=self._make_multimodel_prompt( + df, columns, user_instruction, output_instruction + ), + temperature=0.0, + ground_with_google_search=ground_with_google_search, + )["ml_generate_text_llm_result"], + ) + else: + results = typing.cast( + bigframes.series.Series, + model.predict( + self._make_text_prompt( + df, columns, user_instruction, output_instruction + ), + temperature=0.0, + ground_with_google_search=ground_with_google_search, + )["ml_generate_text_llm_result"], + ) from bigframes.core.reshape.api import concat @@ -516,17 +554,16 @@ def join( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 >>> import bigframes.ml.llm as llm - >>> model = llm.GeminiTextGenerator(model_name="gemini-1.5-flash-001") + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") # doctest: +SKIP >>> cities = bpd.DataFrame({'city': ['Seattle', 'Ottawa', 'Berlin', 'Shanghai', 'New Delhi']}) >>> continents = bpd.DataFrame({'continent': ['North America', 'Africa', 'Asia']}) - >>> cities.semantics.join(continents, "{city} is in {continent}", model) + >>> cities.semantics.join(continents, "{city} is in {continent}", model) # doctest: +SKIP city continent 0 Seattle North America 1 Ottawa North America @@ -573,11 +610,11 @@ def join( columns = self._parse_columns(instruction) if ground_with_google_search: - msg = ( + msg = exceptions.format_message( "Enables Grounding with Google Search may impact billing cost. See pricing " "details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models" ) - warnings.warn(msg) + warnings.warn(msg, category=UserWarning) work_estimate = len(self._df) * len(other) self._confirm_operation(work_estimate) @@ -655,17 +692,16 @@ def search( ** Examples: ** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> import bigframes >>> bigframes.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 >>> import bigframes.ml.llm as llm - >>> model = llm.TextEmbeddingGenerator(model_name="text-embedding-005") + >>> model = llm.TextEmbeddingGenerator(model_name="text-embedding-005") # doctest: +SKIP >>> df = bpd.DataFrame({"creatures": ["salmon", "sea urchin", "frog", "chimpanzee"]}) - >>> df.semantics.search("creatures", "monkey", top_k=1, model=model, score_column='distance') + >>> df.semantics.search("creatures", "monkey", top_k=1, model=model, score_column='distance') # doctest: +SKIP creatures distance 3 chimpanzee 0.635844 @@ -758,20 +794,23 @@ def top_k( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 >>> import bigframes.ml.llm as llm - >>> model = llm.GeminiTextGenerator(model_name="gemini-1.5-flash-001") + >>> model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") # doctest: +SKIP - >>> df = bpd.DataFrame({"Animals": ["Dog", "Bird", "Cat", "Horse"]}) - >>> df.semantics.top_k("{Animals} are more popular as pets", model=model, k=2) - Animals - 0 Dog - 2 Cat + >>> df = bpd.DataFrame( + ... { + ... "Animals": ["Dog", "Bird", "Cat", "Horse"], + ... "Sounds": ["Woof", "Chirp", "Meow", "Neigh"], + ... }) + >>> df.semantics.top_k("{Animals} are more popular as pets", model=model, k=2) # doctest: +SKIP + Animals Sounds + 0 Dog Woof + 2 Cat Meow - [2 rows x 1 columns] + [2 rows x 2 columns] Args: instruction (str): @@ -811,16 +850,14 @@ def top_k( if column not in self._df.columns: raise ValueError(f"Column {column} not found.") if len(columns) > 1: - raise NotImplementedError( - "Semantic aggregations are limited to a single column." - ) + raise NotImplementedError("Semantic top K are limited to a single column.") if ground_with_google_search: - msg = ( + msg = exceptions.format_message( "Enables Grounding with Google Search may impact billing cost. See pricing " "details: https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models" ) - warnings.warn(msg) + warnings.warn(msg, category=UserWarning) work_estimate = int(len(self._df) * (len(self._df) - 1) / 2) self._confirm_operation(work_estimate) @@ -854,7 +891,9 @@ def top_k( # - 1.0: Selected as part of the top-k items # - -1.0: Excluded from the top-k items status_column = guid.generate_guid("status") - df[status_column] = bigframes.series.Series(None, dtype=dtypes.FLOAT_DTYPE) + df[status_column] = bigframes.series.Series( + None, dtype=dtypes.FLOAT_DTYPE, session=df._session + ) num_selected = 0 while num_selected < k: @@ -869,14 +908,8 @@ def top_k( ) num_selected += num_new_selected - df = ( - df[df[status_column] > 0] - .drop(["index", status_column], axis=1) - .rename(columns={"old_index": "index"}) - .set_index("index") - ) - df.index.name = None - return df + result_df: bigframes.dataframe.DataFrame = self._df.copy() + return result_df[df.set_index("old_index")[status_column] > 0.0] @staticmethod def _topk_partition( @@ -961,17 +994,16 @@ def sim_join( ** Examples: ** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 >>> import bigframes.ml.llm as llm - >>> model = llm.TextEmbeddingGenerator(model_name="text-embedding-005") + >>> model = llm.TextEmbeddingGenerator(model_name="text-embedding-005") # doctest: +SKIP >>> df1 = bpd.DataFrame({'animal': ['monkey', 'spider']}) >>> df2 = bpd.DataFrame({'animal': ['scorpion', 'baboon']}) - >>> df1.semantics.sim_join(df2, left_on='animal', right_on='animal', model=model, top_k=1) + >>> df1.semantics.sim_join(df2, left_on='animal', right_on='animal', model=model, top_k=1) # doctest: +SKIP animal animal_1 0 monkey baboon 1 spider scorpion @@ -1060,8 +1092,19 @@ def _attach_embedding(dataframe, source_column: str, embedding_column: str, mode result_df[embedding_column] = embeddings return result_df - def _make_prompt( - self, prompt_df, columns, user_instruction: str, output_instruction: str + @staticmethod + def _make_multimodel_prompt( + prompt_df, columns, user_instruction: str, output_instruction: str + ): + prompt = [f"{output_instruction}\n{user_instruction}\nContext: "] + for col in columns: + prompt.extend([f"{col} is ", prompt_df[col]]) + + return prompt + + @staticmethod + def _make_text_prompt( + prompt_df, columns, user_instruction: str, output_instruction: str ): prompt_df["prompt"] = f"{output_instruction}\n{user_instruction}\nContext: " @@ -1071,7 +1114,8 @@ def _make_prompt( return prompt_df["prompt"] - def _parse_columns(self, instruction: str) -> List[str]: + @staticmethod + def _parse_columns(instruction: str) -> List[str]: """Extracts column names enclosed in curly braces from the user instruction. For example, _parse_columns("{city} is in {continent}") == ["city", "continent"] """ diff --git a/bigframes/operations/string_ops.py b/bigframes/operations/string_ops.py index b2ce0706ce..a50a1b39f6 100644 --- a/bigframes/operations/string_ops.py +++ b/bigframes/operations/string_ops.py @@ -22,72 +22,90 @@ from bigframes.operations import base_ops import bigframes.operations.type as op_typing -len_op = base_ops.create_unary_op( +LenOp = base_ops.create_unary_op( name="len", type_signature=op_typing.FixedOutputType( dtypes.is_iterable, dtypes.INT_DTYPE, description="iterable" ), ) +len_op = LenOp() -reverse_op = base_ops.create_unary_op( +## Specialized len ops for compile-time lowering +StrLenOp = base_ops.create_unary_op( + name="strlen", + type_signature=op_typing.FixedOutputType( + dtypes.is_string_like, dtypes.INT_DTYPE, description="string-like" + ), +) +str_len_op = StrLenOp() + +ArrayLenOp = base_ops.create_unary_op( + name="arraylen", + type_signature=op_typing.FixedOutputType( + dtypes.is_array_like, dtypes.INT_DTYPE, description="array-like" + ), +) +array_len_op = ArrayLenOp() + +ReverseOp = base_ops.create_unary_op( name="reverse", type_signature=op_typing.STRING_TRANSFORM ) +reverse_op = ReverseOp() -lower_op = base_ops.create_unary_op( +LowerOp = base_ops.create_unary_op( name="lower", type_signature=op_typing.STRING_TRANSFORM ) +lower_op = LowerOp() -upper_op = base_ops.create_unary_op( +UpperOp = base_ops.create_unary_op( name="upper", type_signature=op_typing.STRING_TRANSFORM ) +upper_op = UpperOp() -strip_op = base_ops.create_unary_op( - name="strip", type_signature=op_typing.STRING_TRANSFORM -) - -isalnum_op = base_ops.create_unary_op( +IsAlnumOp = base_ops.create_unary_op( name="isalnum", type_signature=op_typing.STRING_PREDICATE ) +isalnum_op = IsAlnumOp() -isalpha_op = base_ops.create_unary_op( +IsAlphaOp = base_ops.create_unary_op( name="isalpha", type_signature=op_typing.STRING_PREDICATE ) +isalpha_op = IsAlphaOp() -isdecimal_op = base_ops.create_unary_op( +IsDecimalOp = base_ops.create_unary_op( name="isdecimal", type_signature=op_typing.STRING_PREDICATE ) +isdecimal_op = IsDecimalOp() -isdigit_op = base_ops.create_unary_op( +IsDigitOp = base_ops.create_unary_op( name="isdigit", type_signature=op_typing.STRING_PREDICATE ) +isdigit_op = IsDigitOp() -isnumeric_op = base_ops.create_unary_op( +IsNumericOp = base_ops.create_unary_op( name="isnumeric", type_signature=op_typing.STRING_PREDICATE ) +isnumeric_op = IsNumericOp() -isspace_op = base_ops.create_unary_op( +IsSpaceOp = base_ops.create_unary_op( name="isspace", type_signature=op_typing.STRING_PREDICATE ) +isspace_op = IsSpaceOp() -islower_op = base_ops.create_unary_op( +IsLowerOp = base_ops.create_unary_op( name="islower", type_signature=op_typing.STRING_PREDICATE ) +islower_op = IsLowerOp() -isupper_op = base_ops.create_unary_op( +IsUpperOp = base_ops.create_unary_op( name="isupper", type_signature=op_typing.STRING_PREDICATE ) +isupper_op = IsUpperOp() -rstrip_op = base_ops.create_unary_op( - name="rstrip", type_signature=op_typing.STRING_TRANSFORM -) - -lstrip_op = base_ops.create_unary_op( - name="lstrip", type_signature=op_typing.STRING_TRANSFORM -) - -capitalize_op = base_ops.create_unary_op( +CapitalizeOp = base_ops.create_unary_op( name="capitalize", type_signature=op_typing.STRING_TRANSFORM ) +capitalize_op = CapitalizeOp() @dataclasses.dataclass(frozen=True) @@ -128,6 +146,33 @@ def output_type(self, *input_types): return op_typing.STRING_TRANSFORM.output_type(input_types[0]) +@dataclasses.dataclass(frozen=True) +class StrStripOp(base_ops.UnaryOp): + name: typing.ClassVar[str] = "str_strip" + to_strip: str + + def output_type(self, *input_types): + return op_typing.STRING_TRANSFORM.output_type(input_types[0]) + + +@dataclasses.dataclass(frozen=True) +class StrLstripOp(base_ops.UnaryOp): + name: typing.ClassVar[str] = "str_lstrip" + to_strip: str + + def output_type(self, *input_types): + return op_typing.STRING_TRANSFORM.output_type(input_types[0]) + + +@dataclasses.dataclass(frozen=True) +class StrRstripOp(base_ops.UnaryOp): + name: typing.ClassVar[str] = "str_rstrip" + to_strip: str + + def output_type(self, *input_types): + return op_typing.STRING_TRANSFORM.output_type(input_types[0]) + + @dataclasses.dataclass(frozen=True) class ReplaceStrOp(base_ops.UnaryOp): name: typing.ClassVar[str] = "str_replace" diff --git a/bigframes/operations/strings.py b/bigframes/operations/strings.py index 46d4344499..d84a66789d 100644 --- a/bigframes/operations/strings.py +++ b/bigframes/operations/strings.py @@ -15,16 +15,17 @@ from __future__ import annotations import re -from typing import cast, Literal, Optional, Union +from typing import Generic, Hashable, Literal, Optional, TypeVar, Union import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.strings.accessor as vendorstr from bigframes.core import log_adapter +import bigframes.core.indexes.base as indices import bigframes.dataframe as df import bigframes.operations as ops from bigframes.operations._op_converters import convert_index, convert_slice -import bigframes.operations.base +import bigframes.operations.aggregations as agg_ops import bigframes.series as series # Maps from python to re2 @@ -34,16 +35,21 @@ re.DOTALL: "s", } +T = TypeVar("T", series.Series, indices.Index) + @log_adapter.class_logger -class StringMethods(bigframes.operations.base.SeriesMethods, vendorstr.StringMethods): +class StringMethods(vendorstr.StringMethods, Generic[T]): __doc__ = vendorstr.StringMethods.__doc__ - def __getitem__(self, key: Union[int, slice]) -> series.Series: + def __init__(self, data: T): + self._data: T = data + + def __getitem__(self, key: Union[int, slice]) -> T: if isinstance(key, int): - return self._apply_unary_op(convert_index(key)) + return self._data._apply_unary_op(convert_index(key)) elif isinstance(key, slice): - return self._apply_unary_op(convert_slice(key)) + return self._data._apply_unary_op(convert_slice(key)) else: raise ValueError(f"key must be an int or slice, got {type(key).__name__}") @@ -52,24 +58,24 @@ def find( sub: str, start: Optional[int] = None, end: Optional[int] = None, - ) -> series.Series: - return self._apply_unary_op(ops.StrFindOp(substr=sub, start=start, end=end)) + ) -> T: + return self._data._apply_unary_op( + ops.StrFindOp(substr=sub, start=start, end=end) + ) - def len(self) -> series.Series: - return self._apply_unary_op(ops.len_op) + def len(self) -> T: + return self._data._apply_unary_op(ops.len_op) - def lower(self) -> series.Series: - return self._apply_unary_op(ops.lower_op) + def lower(self) -> T: + return self._data._apply_unary_op(ops.lower_op) - def reverse(self) -> series.Series: + def reverse(self) -> T: """Reverse strings in the Series. **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - - >>> s = bpd.Series(["apple", "banana", "", bpd.NA]) + >>> s = bpd.Series(["apple", "banana", "", pd.NA]) >>> s.str.reverse() 0 elppa 1 ananab @@ -82,112 +88,118 @@ def reverse(self) -> series.Series: pattern matches the start of each string element. """ # reverse method is in ibis, not pandas. - return self._apply_unary_op(ops.reverse_op) + return self._data._apply_unary_op(ops.reverse_op) def slice( self, start: Optional[int] = None, stop: Optional[int] = None, - ) -> series.Series: - return self._apply_unary_op(ops.StrSliceOp(start=start, end=stop)) + ) -> T: + return self._data._apply_unary_op(ops.StrSliceOp(start=start, end=stop)) - def strip(self) -> series.Series: - return self._apply_unary_op(ops.strip_op) + def strip(self, to_strip: Optional[str] = None) -> T: + return self._data._apply_unary_op( + ops.StrStripOp(to_strip=" \n\t" if to_strip is None else to_strip) + ) - def upper(self) -> series.Series: - return self._apply_unary_op(ops.upper_op) + def upper(self) -> T: + return self._data._apply_unary_op(ops.upper_op) - def isnumeric(self) -> series.Series: - return self._apply_unary_op(ops.isnumeric_op) + def isnumeric(self) -> T: + return self._data._apply_unary_op(ops.isnumeric_op) def isalpha( self, - ) -> series.Series: - return self._apply_unary_op(ops.isalpha_op) + ) -> T: + return self._data._apply_unary_op(ops.isalpha_op) def isdigit( self, - ) -> series.Series: - return self._apply_unary_op(ops.isdigit_op) + ) -> T: + return self._data._apply_unary_op(ops.isdigit_op) def isdecimal( self, - ) -> series.Series: - return self._apply_unary_op(ops.isdecimal_op) + ) -> T: + return self._data._apply_unary_op(ops.isdecimal_op) def isalnum( self, - ) -> series.Series: - return self._apply_unary_op(ops.isalnum_op) + ) -> T: + return self._data._apply_unary_op(ops.isalnum_op) def isspace( self, - ) -> series.Series: - return self._apply_unary_op(ops.isspace_op) + ) -> T: + return self._data._apply_unary_op(ops.isspace_op) def islower( self, - ) -> series.Series: - return self._apply_unary_op(ops.islower_op) + ) -> T: + return self._data._apply_unary_op(ops.islower_op) def isupper( self, - ) -> series.Series: - return self._apply_unary_op(ops.isupper_op) + ) -> T: + return self._data._apply_unary_op(ops.isupper_op) - def rstrip(self) -> series.Series: - return self._apply_unary_op(ops.rstrip_op) + def rstrip(self, to_strip: Optional[str] = None) -> T: + return self._data._apply_unary_op( + ops.StrRstripOp(to_strip=" \n\t" if to_strip is None else to_strip) + ) - def lstrip(self) -> series.Series: - return self._apply_unary_op(ops.lstrip_op) + def lstrip(self, to_strip: Optional[str] = None) -> T: + return self._data._apply_unary_op( + ops.StrLstripOp(to_strip=" \n\t" if to_strip is None else to_strip) + ) - def repeat(self, repeats: int) -> series.Series: - return self._apply_unary_op(ops.StrRepeatOp(repeats=repeats)) + def repeat(self, repeats: int) -> T: + return self._data._apply_unary_op(ops.StrRepeatOp(repeats=repeats)) - def capitalize(self) -> series.Series: - return self._apply_unary_op(ops.capitalize_op) + def capitalize(self) -> T: + return self._data._apply_unary_op(ops.capitalize_op) - def match(self, pat, case=True, flags=0) -> series.Series: + def match(self, pat, case=True, flags=0) -> T: # \A anchors start of entire string rather than start of any line in multiline mode adj_pat = rf"\A{pat}" return self.contains(pat=adj_pat, case=case, flags=flags) - def fullmatch(self, pat, case=True, flags=0) -> series.Series: + def fullmatch(self, pat, case=True, flags=0) -> T: # \A anchors start of entire string rather than start of any line in multiline mode # \z likewise anchors to the end of the entire multiline string adj_pat = rf"\A{pat}\z" return self.contains(pat=adj_pat, case=case, flags=flags) - def get(self, i: int) -> series.Series: - return self._apply_unary_op(ops.StrGetOp(i=i)) + def get(self, i: int) -> T: + return self._data._apply_unary_op(ops.StrGetOp(i=i)) - def pad(self, width, side="left", fillchar=" ") -> series.Series: - return self._apply_unary_op( + def pad(self, width, side="left", fillchar=" ") -> T: + return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side=side) ) - def ljust(self, width, fillchar=" ") -> series.Series: - return self._apply_unary_op( + def ljust(self, width, fillchar=" ") -> T: + return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="right") ) - def rjust(self, width, fillchar=" ") -> series.Series: - return self._apply_unary_op( + def rjust(self, width, fillchar=" ") -> T: + return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="left") ) def contains( self, pat, case: bool = True, flags: int = 0, *, regex: bool = True - ) -> series.Series: + ) -> T: if not case: return self.contains(pat=pat, flags=flags | re.IGNORECASE, regex=True) if regex: re2flags = _parse_flags(flags) if re2flags: pat = re2flags + pat - return self._apply_unary_op(ops.StrContainsRegexOp(pat=pat)) + return self._data._apply_unary_op(ops.StrContainsRegexOp(pat=pat)) else: - return self._apply_unary_op(ops.StrContainsOp(pat=pat)) + return self._data._apply_unary_op(ops.StrContainsOp(pat=pat)) def extract(self, pat: str, flags: int = 0) -> df.DataFrame: re2flags = _parse_flags(flags) @@ -197,23 +209,19 @@ def extract(self, pat: str, flags: int = 0) -> df.DataFrame: if compiled.groups == 0: raise ValueError("No capture groups in 'pat'") - results: list[str] = [] - block = self._block + results: dict[Hashable, series.Series] = {} for i in range(compiled.groups): labels = [ label for label, groupn in compiled.groupindex.items() if i + 1 == groupn ] - label = labels[0] if labels else str(i) - block, id = block.apply_unary_op( - self._value_column, + label = labels[0] if labels else i + result = self._data._apply_unary_op( ops.StrExtractOp(pat=pat, n=i + 1), - result_label=label, ) - results.append(id) - block = block.select_columns(results) - return df.DataFrame(block) + results[label] = series.Series(result) + return df.DataFrame(results) def replace( self, @@ -223,72 +231,87 @@ def replace( case: Optional[bool] = None, flags: int = 0, regex: bool = False, - ) -> series.Series: - is_compiled = isinstance(pat, re.Pattern) - patstr = cast(str, pat.pattern if is_compiled else pat) # type: ignore + ) -> T: + if isinstance(pat, re.Pattern): + assert isinstance(pat.pattern, str) + pat_str = pat.pattern + flags = pat.flags | flags + else: + pat_str = pat + if case is False: - return self.replace(pat, repl, flags=flags | re.IGNORECASE, regex=True) + return self.replace(pat_str, repl, flags=flags | re.IGNORECASE, regex=True) if regex: re2flags = _parse_flags(flags) if re2flags: - patstr = re2flags + patstr - return self._apply_unary_op(ops.RegexReplaceStrOp(pat=patstr, repl=repl)) + pat_str = re2flags + pat_str + return self._data._apply_unary_op( + ops.RegexReplaceStrOp(pat=pat_str, repl=repl) + ) else: - if is_compiled: + if isinstance(pat, re.Pattern): raise ValueError( "Must set 'regex'=True if using compiled regex pattern." ) - return self._apply_unary_op(ops.ReplaceStrOp(pat=patstr, repl=repl)) + return self._data._apply_unary_op(ops.ReplaceStrOp(pat=pat_str, repl=repl)) def startswith( self, pat: Union[str, tuple[str, ...]], - ) -> series.Series: + ) -> T: if not isinstance(pat, tuple): pat = (pat,) - return self._apply_unary_op(ops.StartsWithOp(pat=pat)) + return self._data._apply_unary_op(ops.StartsWithOp(pat=pat)) def endswith( self, pat: Union[str, tuple[str, ...]], - ) -> series.Series: + ) -> T: if not isinstance(pat, tuple): pat = (pat,) - return self._apply_unary_op(ops.EndsWithOp(pat=pat)) + return self._data._apply_unary_op(ops.EndsWithOp(pat=pat)) def split( self, pat: str = " ", regex: Union[bool, None] = None, - ) -> series.Series: + ) -> T: if regex is True or (regex is None and len(pat) > 1): raise NotImplementedError( "Regular expressions aren't currently supported. Please set " + f"`regex=False` and try again. {constants.FEEDBACK_LINK}" ) - return self._apply_unary_op(ops.StringSplitOp(pat=pat)) + return self._data._apply_unary_op(ops.StringSplitOp(pat=pat)) - def zfill(self, width: int) -> series.Series: - return self._apply_unary_op(ops.ZfillOp(width=width)) + def zfill(self, width: int) -> T: + return self._data._apply_unary_op(ops.ZfillOp(width=width)) - def center(self, width: int, fillchar: str = " ") -> series.Series: - return self._apply_unary_op( + def center(self, width: int, fillchar: str = " ") -> T: + return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="both") ) def cat( self, - others: Union[str, series.Series], + others: Union[str, indices.Index, series.Series], *, join: Literal["outer", "left"] = "left", - ) -> series.Series: - return self._apply_binary_op(others, ops.strconcat_op, alignment=join) + ) -> T: + return self._data._apply_binary_op(others, ops.strconcat_op, alignment=join) + + def join(self, sep: str) -> T: + return self._data._apply_unary_op( + ops.ArrayReduceOp(aggregation=agg_ops.StringAggOp(sep=sep)) + ) - def to_blob(self, connection: Optional[str] = None) -> series.Series: + def to_blob(self, connection: Optional[str] = None) -> T: """Create a BigFrames Blob series from a series of URIs. .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. + BigFrames Blob is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). Args: @@ -301,23 +324,23 @@ def to_blob(self, connection: Optional[str] = None) -> series.Series: bigframes.series.Series: Blob Series. """ - if not bigframes.options.experiments.blob: - raise NotImplementedError() - - session = self._block.session - connection = session._create_bq_connection( - connection=connection, iam_role="storage.objectUser" - ) - return self._apply_binary_op(connection, ops.obj_make_ref_op) + session = self._data._block.session + connection = session._create_bq_connection(connection=connection) + return self._data._apply_binary_op(connection, ops.obj_make_ref_op) def _parse_flags(flags: int) -> Optional[str]: re2flags = [] for reflag, re2flag in REGEXP_FLAGS.items(): - if flags & flags: + if flags & reflag: re2flags.append(re2flag) flags = flags ^ reflag + # re2 handles unicode fine by default + # most compiled re in python will have unicode set + if re.U and flags: + flags = flags ^ re.U + # Remaining flags couldn't be mapped to re2 engine if flags: raise NotImplementedError( diff --git a/bigframes/operations/struct_ops.py b/bigframes/operations/struct_ops.py index 0926142b17..de51efd8a4 100644 --- a/bigframes/operations/struct_ops.py +++ b/bigframes/operations/struct_ops.py @@ -43,7 +43,7 @@ def output_type(self, *input_types): @dataclasses.dataclass(frozen=True) class StructOp(base_ops.NaryOp): name: typing.ClassVar[str] = "struct" - column_names: tuple[str] + column_names: tuple[str, ...] def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: num_input_types = len(input_types) diff --git a/bigframes/operations/structs.py b/bigframes/operations/structs.py index 051023c299..35010e1733 100644 --- a/bigframes/operations/structs.py +++ b/bigframes/operations/structs.py @@ -17,45 +17,53 @@ import bigframes_vendored.pandas.core.arrays.arrow.accessors as vendoracessors import pandas as pd -from bigframes.core import log_adapter +from bigframes.core import backports, log_adapter import bigframes.dataframe -import bigframes.dtypes import bigframes.operations -import bigframes.operations.base import bigframes.series @log_adapter.class_logger -class StructAccessor( - bigframes.operations.base.SeriesMethods, vendoracessors.StructAccessor -): +class StructAccessor(vendoracessors.StructAccessor): __doc__ = vendoracessors.StructAccessor.__doc__ + def __init__(self, data: bigframes.series.Series): + self._data = data + def field(self, name_or_index: str | int) -> bigframes.series.Series: - series = self._apply_unary_op(bigframes.operations.StructFieldOp(name_or_index)) + series = self._data._apply_unary_op( + bigframes.operations.StructFieldOp(name_or_index) + ) if isinstance(name_or_index, str): name = name_or_index else: - struct_field = self._dtype.pyarrow_dtype[name_or_index] + struct_field = self._data._dtype.pyarrow_dtype[name_or_index] name = struct_field.name return series.rename(name) def explode(self) -> bigframes.dataframe.DataFrame: import bigframes.pandas - pa_type = self._dtype.pyarrow_dtype + pa_type = self._data._dtype.pyarrow_dtype return bigframes.pandas.concat( - [self.field(i) for i in range(pa_type.num_fields)], axis="columns" + [ + self.field(field.name) + for field in backports.pyarrow_struct_type_fields(pa_type) + ], + axis="columns", ) + @property def dtypes(self) -> pd.Series: - pa_type = self._dtype.pyarrow_dtype + pa_type = self._data._dtype.pyarrow_dtype return pd.Series( data=[ - bigframes.dtypes.arrow_dtype_to_bigframes_dtype(pa_type.field(i).type) - for i in range(pa_type.num_fields) + pd.ArrowDtype(field.type) + for field in backports.pyarrow_struct_type_fields(pa_type) + ], + index=[ + field.name for field in backports.pyarrow_struct_type_fields(pa_type) ], - index=[pa_type.field(i).name for i in range(pa_type.num_fields)], ) diff --git a/bigframes/operations/time_ops.py b/bigframes/operations/time_ops.py index a6a65ad80e..bf6fa3e7d1 100644 --- a/bigframes/operations/time_ops.py +++ b/bigframes/operations/time_ops.py @@ -16,25 +16,29 @@ from bigframes.operations import base_ops import bigframes.operations.type as op_typing -hour_op = base_ops.create_unary_op( +HourOp = base_ops.create_unary_op( name="hour", type_signature=op_typing.TIMELIKE_ACCESSOR, ) +hour_op = HourOp() -minute_op = base_ops.create_unary_op( +MinuteOp = base_ops.create_unary_op( name="minute", type_signature=op_typing.TIMELIKE_ACCESSOR, ) +minute_op = MinuteOp() -second_op = base_ops.create_unary_op( +SecondOp = base_ops.create_unary_op( name="second", type_signature=op_typing.TIMELIKE_ACCESSOR, ) +second_op = SecondOp() -normalize_op = base_ops.create_unary_op( +NormalizeOp = base_ops.create_unary_op( name="normalize", type_signature=op_typing.TypePreserving( dtypes.is_time_like, description="time-like", ), ) +normalize_op = NormalizeOp() diff --git a/bigframes/operations/timedelta_ops.py b/bigframes/operations/timedelta_ops.py index 364154f728..5e9a1189e4 100644 --- a/bigframes/operations/timedelta_ops.py +++ b/bigframes/operations/timedelta_ops.py @@ -46,7 +46,7 @@ class TimedeltaFloorOp(base_ops.UnaryOp): def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: input_type = input_types[0] - if dtypes.is_numeric(input_type) or input_type is dtypes.TIMEDELTA_DTYPE: + if dtypes.is_numeric(input_type) or input_type == dtypes.TIMEDELTA_DTYPE: return dtypes.TIMEDELTA_DTYPE raise TypeError(f"unsupported type: {input_type}") @@ -62,11 +62,11 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT # timestamp + timedelta => timestamp if ( dtypes.is_datetime_like(input_types[0]) - and input_types[1] is dtypes.TIMEDELTA_DTYPE + and input_types[1] == dtypes.TIMEDELTA_DTYPE ): return input_types[0] # timedelta + timestamp => timestamp - if input_types[0] is dtypes.TIMEDELTA_DTYPE and dtypes.is_datetime_like( + if input_types[0] == dtypes.TIMEDELTA_DTYPE and dtypes.is_datetime_like( input_types[1] ): return input_types[1] @@ -79,6 +79,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT timestamp_add_op = TimestampAddOp() +@dataclasses.dataclass(frozen=True) class TimestampSubOp(base_ops.BinaryOp): name: typing.ClassVar[str] = "timestamp_sub" @@ -86,7 +87,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT # timestamp - timedelta => timestamp if ( dtypes.is_datetime_like(input_types[0]) - and input_types[1] is dtypes.TIMEDELTA_DTYPE + and input_types[1] == dtypes.TIMEDELTA_DTYPE ): return input_types[0] @@ -96,3 +97,49 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT timestamp_sub_op = TimestampSubOp() + + +@dataclasses.dataclass(frozen=True) +class DateAddOp(base_ops.BinaryOp): + name: typing.ClassVar[str] = "date_add" + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + # date + timedelta => timestamp without timezone + if ( + input_types[0] == dtypes.DATE_DTYPE + and input_types[1] == dtypes.TIMEDELTA_DTYPE + ): + return dtypes.DATETIME_DTYPE + # timedelta + date => timestamp without timezone + if ( + input_types[0] == dtypes.TIMEDELTA_DTYPE + and input_types[1] == dtypes.DATE_DTYPE + ): + return dtypes.DATETIME_DTYPE + + raise TypeError( + f"unsupported types for date_add. left: {input_types[0]} right: {input_types[1]}" + ) + + +date_add_op = DateAddOp() + + +@dataclasses.dataclass(frozen=True) +class DateSubOp(base_ops.BinaryOp): + name: typing.ClassVar[str] = "date_sub" + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + # date - timedelta => timestamp without timezone + if ( + input_types[0] == dtypes.DATE_DTYPE + and input_types[1] == dtypes.TIMEDELTA_DTYPE + ): + return dtypes.DATETIME_DTYPE + + raise TypeError( + f"unsupported types for date_sub. left: {input_types[0]} right: {input_types[1]}" + ) + + +date_sub_op = DateSubOp() diff --git a/bigframes/operations/type.py b/bigframes/operations/type.py index 0a47cd91f0..6542233081 100644 --- a/bigframes/operations/type.py +++ b/bigframes/operations/type.py @@ -122,6 +122,20 @@ def output_type( @dataclasses.dataclass +@dataclasses.dataclass +class BinaryGeo(BinaryTypeSignature): + """Type signature for geo functions like difference that can map geo to geo.""" + + def output_type( + self, left_type: ExpressionType, right_type: ExpressionType + ) -> ExpressionType: + if (left_type is not None) and not bigframes.dtypes.is_geo_like(left_type): + raise TypeError(f"Type {left_type} is not geo") + if (right_type is not None) and not bigframes.dtypes.is_geo_like(right_type): + raise TypeError(f"Type {right_type} is not numeric") + return bigframes.dtypes.GEO_DTYPE + + class BinaryNumericGeo(BinaryTypeSignature): """Type signature for geo functions like from_xy that can map ints to ints.""" @@ -160,15 +174,7 @@ class CoerceCommon(BinaryTypeSignature): def output_type( self, left_type: ExpressionType, right_type: ExpressionType ) -> ExpressionType: - try: - return bigframes.dtypes.coerce_to_common(left_type, right_type) - except TypeError: - pass - if bigframes.dtypes.can_coerce(left_type, right_type): - return right_type - if bigframes.dtypes.can_coerce(right_type, left_type): - return left_type - raise TypeError(f"Cannot coerce {left_type} and {right_type} to a common type.") + return bigframes.dtypes.coerce_to_common(left_type, right_type) @dataclasses.dataclass @@ -178,8 +184,7 @@ class Comparison(BinaryTypeSignature): def output_type( self, left_type: ExpressionType, right_type: ExpressionType ) -> ExpressionType: - common_type = CoerceCommon().output_type(left_type, right_type) - if not bigframes.dtypes.is_comparable(common_type): + if not bigframes.dtypes.can_compare(left_type, right_type): raise TypeError(f"Types {left_type} and {right_type} are not comparable") return bigframes.dtypes.BOOL_DTYPE @@ -199,7 +204,7 @@ def output_type( raise TypeError(f"Type {right_type} is not binary") if left_type != right_type: raise TypeError( - "Bitwise operands {left_type} and {right_type} do not match" + f"Bitwise operands {left_type} and {right_type} do not match" ) return left_type @@ -217,7 +222,7 @@ def output_type( raise TypeError(f"Type {right_type} is not array-like") if left_type != right_type: raise TypeError( - "Vector op operands {left_type} and {right_type} do not match" + f"Vector op operands {left_type} and {right_type} do not match" ) return bigframes.dtypes.FLOAT_DTYPE diff --git a/bigframes/pandas/__init__.py b/bigframes/pandas/__init__.py index 93c08a22aa..0b9648fd56 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -16,8 +16,8 @@ from __future__ import annotations -from collections import namedtuple -from datetime import datetime +import collections +import datetime import inspect import sys import typing @@ -27,17 +27,18 @@ import pandas import bigframes._config as config -import bigframes.core.blocks +from bigframes.core import log_adapter import bigframes.core.global_session as global_session import bigframes.core.indexes -from bigframes.core.reshape.api import concat, cut, get_dummies, merge, qcut -import bigframes.core.tools +from bigframes.core.reshape.api import concat, crosstab, cut, get_dummies, merge, qcut import bigframes.dataframe -import bigframes.enums import bigframes.functions._utils as bff_utils +from bigframes.pandas import api from bigframes.pandas.core.api import to_timedelta from bigframes.pandas.io.api import ( + _read_gbq_colab, from_glob_path, + read_arrow, read_csv, read_gbq, read_gbq_function, @@ -53,7 +54,6 @@ import bigframes.series import bigframes.session import bigframes.session._io.bigquery -import bigframes.session.clients import bigframes.version try: @@ -65,24 +65,33 @@ def remote_function( + # Make sure that the input/output types, and dataset can be used + # positionally. This avoids the worst of the breaking change from 1.x to + # 2.x while still preventing possible mixups between consecutive str + # parameters. input_types: Union[None, type, Sequence[type]] = None, output_type: Optional[type] = None, dataset: Optional[str] = None, + *, bigquery_connection: Optional[str] = None, reuse: bool = True, name: Optional[str] = None, packages: Optional[Sequence[str]] = None, - cloud_function_service_account: Optional[str] = None, + cloud_function_service_account: str, cloud_function_kms_key_name: Optional[str] = None, cloud_function_docker_repository: Optional[str] = None, max_batching_rows: Optional[int] = 1000, cloud_function_timeout: Optional[int] = 600, cloud_function_max_instances: Optional[int] = None, cloud_function_vpc_connector: Optional[str] = None, + cloud_function_vpc_connector_egress_settings: Optional[ + Literal["all", "private-ranges-only", "unspecified"] + ] = None, cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" - ] = "all", + ] = "internal-only", + cloud_build_service_account: Optional[str] = None, ): return global_session.with_default_session( bigframes.session.Session.remote_function, @@ -100,14 +109,75 @@ def remote_function( cloud_function_timeout=cloud_function_timeout, cloud_function_max_instances=cloud_function_max_instances, cloud_function_vpc_connector=cloud_function_vpc_connector, + cloud_function_vpc_connector_egress_settings=cloud_function_vpc_connector_egress_settings, cloud_function_memory_mib=cloud_function_memory_mib, cloud_function_ingress_settings=cloud_function_ingress_settings, + cloud_build_service_account=cloud_build_service_account, ) remote_function.__doc__ = inspect.getdoc(bigframes.session.Session.remote_function) +def deploy_remote_function( + func, + **kwargs, +): + return global_session.with_default_session( + bigframes.session.Session.deploy_remote_function, + func=func, + **kwargs, + ) + + +deploy_remote_function.__doc__ = inspect.getdoc( + bigframes.session.Session.deploy_remote_function +) + + +def udf( + *, + input_types: Union[None, type, Sequence[type]] = None, + output_type: Optional[type] = None, + dataset: str, + bigquery_connection: Optional[str] = None, + name: str, + packages: Optional[Sequence[str]] = None, + max_batching_rows: Optional[int] = None, + container_cpu: Optional[float] = None, + container_memory: Optional[str] = None, +): + return global_session.with_default_session( + bigframes.session.Session.udf, + input_types=input_types, + output_type=output_type, + dataset=dataset, + bigquery_connection=bigquery_connection, + name=name, + packages=packages, + max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, + ) + + +udf.__doc__ = inspect.getdoc(bigframes.session.Session.udf) + + +def deploy_udf( + func, + **kwargs, +): + return global_session.with_default_session( + bigframes.session.Session.deploy_udf, + func=func, + **kwargs, + ) + + +deploy_udf.__doc__ = inspect.getdoc(bigframes.session.Session.deploy_udf) + + @typing.overload def to_datetime( arg: Union[ @@ -125,18 +195,18 @@ def to_datetime( @typing.overload def to_datetime( - arg: Union[int, float, str, datetime], + arg: Union[int, float, str, datetime.datetime, datetime.date], *, utc: bool = False, format: Optional[str] = None, unit: Optional[str] = None, -) -> Union[pandas.Timestamp, datetime]: +) -> Union[pandas.Timestamp, datetime.datetime]: ... def to_datetime( arg: Union[ - Union[int, float, str, datetime], + Union[int, float, str, datetime.datetime, datetime.date], vendored_pandas_datetimes.local_iterables, bigframes.series.Series, bigframes.dataframe.DataFrame, @@ -145,8 +215,9 @@ def to_datetime( utc: bool = False, format: Optional[str] = None, unit: Optional[str] = None, -) -> Union[pandas.Timestamp, datetime, bigframes.series.Series]: - return bigframes.core.tools.to_datetime( +) -> Union[pandas.Timestamp, datetime.datetime, bigframes.series.Series]: + return global_session.with_default_session( + bigframes.session.Session.to_datetime, arg, utc=utc, format=format, @@ -171,6 +242,7 @@ def get_default_session_id() -> str: return get_global_session().session_id +@log_adapter.method_logger def clean_up_by_session_id( session_id: str, location: Optional[str] = None, @@ -217,25 +289,36 @@ def clean_up_by_session_id( session.bqclient, location=location, project=project, - api_name="clean_up_by_session_id", + publisher=session._publisher, ) bigframes.session._io.bigquery.delete_tables_matching_session_id( session.bqclient, dataset, session_id ) - bff_utils._clean_up_by_session_id( + bff_utils.clean_up_by_session_id( session.bqclient, session.cloudfunctionsclient, dataset, session_id ) # pandas dtype attributes NA = pandas.NA +"""Alias for :class:`pandas.NA`.""" + BooleanDtype = pandas.BooleanDtype +"""Alias for :class:`pandas.BooleanDtype`.""" + Float64Dtype = pandas.Float64Dtype +"""Alias for :class:`pandas.Float64Dtype`.""" + Int64Dtype = pandas.Int64Dtype +"""Alias for :class:`pandas.Int64Dtype`.""" + StringDtype = pandas.StringDtype +"""Alias for :class:`pandas.StringDtype`.""" + ArrowDtype = pandas.ArrowDtype +"""Alias for :class:`pandas.ArrowDtype`.""" # Class aliases # TODO(swast): Make these real classes so we can refer to these in type @@ -243,11 +326,12 @@ def clean_up_by_session_id( DataFrame = bigframes.dataframe.DataFrame Index = bigframes.core.indexes.Index MultiIndex = bigframes.core.indexes.MultiIndex +DatetimeIndex = bigframes.core.indexes.DatetimeIndex Series = bigframes.series.Series __version__ = bigframes.version.__version__ # Other public pandas attributes -NamedAgg = namedtuple("NamedAgg", ["column", "aggfunc"]) +NamedAgg = collections.namedtuple("NamedAgg", ["column", "aggfunc"]) options = config.options """Global :class:`~bigframes._config.Options` to configure BigQuery DataFrames.""" @@ -293,18 +377,55 @@ def reset_session(): except Exception: pass +_functions = [ + clean_up_by_session_id, + concat, + crosstab, + cut, + deploy_remote_function, + deploy_udf, + get_default_session_id, + get_dummies, + merge, + qcut, + read_csv, + read_arrow, + read_gbq, + _read_gbq_colab, + read_gbq_function, + read_gbq_model, + read_gbq_object_table, + read_gbq_query, + read_gbq_table, + read_json, + read_pandas, + read_parquet, + read_pickle, + remote_function, + to_datetime, + to_timedelta, + from_glob_path, +] + # Use __all__ to let type checkers know what is part of the public API. +# Note that static analysis checkers like pylance depend on these being string +# literals, not derived at runtime. __all__ = [ - # Functions + # Function names "clean_up_by_session_id", "concat", + "crosstab", "cut", + "deploy_remote_function", + "deploy_udf", "get_default_session_id", "get_dummies", "merge", "qcut", "read_csv", + "read_arrow", "read_gbq", + "_read_gbq_colab", "read_gbq_function", "read_gbq_model", "read_gbq_object_table", @@ -318,6 +439,8 @@ def reset_session(): "to_datetime", "to_timedelta", "from_glob_path", + # Other names + "api", # pandas dtype attributes "NA", "BooleanDtype", @@ -329,6 +452,7 @@ def reset_session(): "DataFrame", "Index", "MultiIndex", + "DatetimeIndex", "Series", "__version__", # Other public pandas attributes @@ -339,4 +463,11 @@ def reset_session(): "get_global_session", "close_session", "reset_session", + "udf", ] + +_module = sys.modules[__name__] + +for _function in _functions: + _decorated_object = log_adapter.method_logger(_function, custom_base_name="pandas") + setattr(_module, _function.__name__, _decorated_object) diff --git a/.github/.OwlBot.yaml b/bigframes/pandas/api/__init__.py similarity index 76% rename from .github/.OwlBot.yaml rename to bigframes/pandas/api/__init__.py index c379bd3092..6d181f92c1 100644 --- a/.github/.OwlBot.yaml +++ b/bigframes/pandas/api/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2021 Google LLC +# Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,7 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -docker: - image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest +"""BigQuery DataFrames public pandas APIs.""" -begin-after-commit-hash: 92006bb3cdc84677aa93c7f5235424ec2b157146 +from bigframes.pandas.api import typing + +__all__ = [ + "typing", +] diff --git a/bigframes/pandas/api/typing.py b/bigframes/pandas/api/typing.py new file mode 100644 index 0000000000..e21216bb68 --- /dev/null +++ b/bigframes/pandas/api/typing.py @@ -0,0 +1,35 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""BigQuery DataFrames public pandas types that aren't exposed in bigframes.pandas. + +Note: These objects aren't intended to be constructed directly. +""" + +from bigframes.core.groupby.dataframe_group_by import DataFrameGroupBy +from bigframes.core.groupby.series_group_by import SeriesGroupBy +from bigframes.core.window import Window +from bigframes.operations.datetimes import DatetimeMethods +from bigframes.operations.strings import StringMethods +from bigframes.operations.structs import StructAccessor, StructFrameAccessor + +__all__ = [ + "DataFrameGroupBy", + "DatetimeMethods", + "SeriesGroupBy", + "StringMethods", + "StructAccessor", + "StructFrameAccessor", + "Window", +] diff --git a/bigframes/pandas/core/methods/__init__.py b/bigframes/pandas/core/methods/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/bigframes/pandas/core/methods/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/bigframes/pandas/core/methods/describe.py b/bigframes/pandas/core/methods/describe.py new file mode 100644 index 0000000000..6fd7960daf --- /dev/null +++ b/bigframes/pandas/core/methods/describe.py @@ -0,0 +1,124 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import pandas as pd + +from bigframes import dataframe, dtypes, series +from bigframes.core import agg_expressions, blocks +from bigframes.operations import aggregations + +_DEFAULT_DTYPES = ( + dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE + dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES +) + + +def describe( + input: dataframe.DataFrame | series.Series, + include: None | typing.Literal["all"], +) -> dataframe.DataFrame | series.Series: + if isinstance(input, series.Series): + # Convert the series to a dataframe, describe it, and cast the result back to a series. + return series.Series(describe(input.to_frame(), include)._block) + elif not isinstance(input, dataframe.DataFrame): + raise TypeError(f"Unsupported type: {type(input)}") + + block = input._block + + describe_block = _describe(block, columns=block.value_columns, include=include) + # we override default stack behavior, because we want very specific ordering + stack_cols = pd.Index( + [ + "count", + "nunique", + "top", + "freq", + "mean", + "std", + "min", + "25%", + "50%", + "75%", + "max", + ] + ).intersection(describe_block.column_labels.get_level_values(-1)) + describe_block = describe_block.stack(override_labels=stack_cols) + + return dataframe.DataFrame(describe_block).droplevel(level=0) + + +def _describe( + block: blocks.Block, + columns: typing.Sequence[str], + include: None | typing.Literal["all"] = None, + *, + as_index: bool = True, + by_col_ids: typing.Sequence[str] = [], + dropna: bool = False, +) -> blocks.Block: + stats: list[agg_expressions.Aggregation] = [] + column_labels: list[typing.Hashable] = [] + + # include=None behaves like include='all' if no numeric columns present + if include is None: + if not any( + block.expr.get_column_type(col) in _DEFAULT_DTYPES for col in columns + ): + include = "all" + + for col_id in columns: + label = block.col_id_to_label[col_id] + dtype = block.expr.get_column_type(col_id) + if include != "all" and dtype not in _DEFAULT_DTYPES: + continue + agg_ops = _get_aggs_for_dtype(dtype) + stats.extend(op.as_expr(col_id) for op in agg_ops) + label_tuple = (label,) if block.column_labels.nlevels == 1 else label + column_labels.extend((*label_tuple, op.name) for op in agg_ops) # type: ignore + + agg_block = block.aggregate( + by_column_ids=by_col_ids, + aggregations=stats, + dropna=dropna, + column_labels=pd.Index(column_labels, name=(*block.column_labels.names, None)), + ) + return agg_block if as_index else agg_block.reset_index(drop=False) + + +def _get_aggs_for_dtype(dtype) -> list[aggregations.UnaryAggregateOp]: + if dtype in dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE: + return [ + aggregations.count_op, + aggregations.mean_op, + aggregations.std_op, + aggregations.min_op, + aggregations.ApproxQuartilesOp(1), + aggregations.ApproxQuartilesOp(2), + aggregations.ApproxQuartilesOp(3), + aggregations.max_op, + ] + elif dtype in dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES: + return [aggregations.count_op] + elif dtype in [ + dtypes.STRING_DTYPE, + dtypes.BOOL_DTYPE, + dtypes.BYTES_DTYPE, + dtypes.TIME_DTYPE, + ]: + return [aggregations.count_op, aggregations.nunique_op] + else: + return [] diff --git a/bigframes/pandas/core/tools/timedeltas.py b/bigframes/pandas/core/tools/timedeltas.py index 070a41d62d..eb01f9f846 100644 --- a/bigframes/pandas/core/tools/timedeltas.py +++ b/bigframes/pandas/core/tools/timedeltas.py @@ -35,7 +35,7 @@ def to_timedelta( return arg._apply_unary_op(ops.ToTimedeltaOp(canonical_unit)) if pdtypes.is_list_like(arg): - return to_timedelta(series.Series(arg), unit, session=session) + return to_timedelta(series.Series(arg, session=session), unit, session=session) return pd.to_timedelta(arg, unit) diff --git a/bigframes/pandas/io/api.py b/bigframes/pandas/io/api.py index a119ff67b0..483bc5e530 100644 --- a/bigframes/pandas/io/api.py +++ b/bigframes/pandas/io/api.py @@ -14,7 +14,10 @@ from __future__ import annotations +import functools import inspect +import os +import threading import typing from typing import ( Any, @@ -25,10 +28,12 @@ Literal, MutableSequence, Optional, + overload, Sequence, Tuple, Union, ) +import warnings import bigframes_vendored.constants as constants import bigframes_vendored.pandas.io.gbq as vendored_pandas_gbq @@ -41,19 +46,19 @@ ReadPickleBuffer, StorageOptions, ) +import pyarrow as pa import bigframes._config as config -import bigframes.core.blocks import bigframes.core.global_session as global_session import bigframes.core.indexes -import bigframes.core.reshape -import bigframes.core.tools import bigframes.dataframe import bigframes.enums import bigframes.series import bigframes.session +from bigframes.session import dry_runs import bigframes.session._io.bigquery import bigframes.session.clients +import bigframes.session.metrics # Note: the following methods are duplicated from Session. This duplication # enables the following: @@ -71,6 +76,21 @@ # method and its arguments. +def read_arrow(pa_table: pa.Table) -> bigframes.dataframe.DataFrame: + """Load a PyArrow Table to a BigQuery DataFrames DataFrame. + + Args: + pa_table (pyarrow.Table): + PyArrow table to load data from. + + Returns: + bigframes.dataframe.DataFrame: + A new DataFrame representing the data from the PyArrow table. + """ + session = global_session.get_global_session() + return session.read_arrow(pa_table=pa_table) + + def read_csv( filepath_or_buffer: str | IO["bytes"], *, @@ -155,6 +175,40 @@ def read_json( read_json.__doc__ = inspect.getdoc(bigframes.session.Session.read_json) +@overload +def read_gbq( # type: ignore[overload-overlap] + query_or_table: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + filters: vendored_pandas_gbq.FiltersType = ..., + use_cache: Optional[bool] = ..., + col_order: Iterable[str] = ..., + dry_run: Literal[False] = ..., + allow_large_results: Optional[bool] = ..., +) -> bigframes.dataframe.DataFrame: + ... + + +@overload +def read_gbq( + query_or_table: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + filters: vendored_pandas_gbq.FiltersType = ..., + use_cache: Optional[bool] = ..., + col_order: Iterable[str] = ..., + dry_run: Literal[True] = ..., + allow_large_results: Optional[bool] = ..., +) -> pandas.Series: + ... + + def read_gbq( query_or_table: str, *, @@ -165,7 +219,9 @@ def read_gbq( filters: vendored_pandas_gbq.FiltersType = (), use_cache: Optional[bool] = None, col_order: Iterable[str] = (), -) -> bigframes.dataframe.DataFrame: + dry_run: bool = False, + allow_large_results: Optional[bool] = None, +) -> bigframes.dataframe.DataFrame | pandas.Series: _set_default_session_location_if_possible(query_or_table) return global_session.with_default_session( bigframes.session.Session.read_gbq, @@ -177,12 +233,140 @@ def read_gbq( filters=filters, use_cache=use_cache, col_order=col_order, + dry_run=dry_run, + allow_large_results=allow_large_results, ) read_gbq.__doc__ = inspect.getdoc(bigframes.session.Session.read_gbq) +def _run_read_gbq_colab_sessionless_dry_run( + query: str, + *, + pyformat_args: Dict[str, Any], +) -> pandas.Series: + """Run a dry_run without a session.""" + + query_formatted = bigframes.core.pyformat.pyformat( + query, + pyformat_args=pyformat_args, + dry_run=True, + ) + bqclient = _get_bqclient() + job = _dry_run(query_formatted, bqclient) + return dry_runs.get_query_stats_with_inferred_dtypes(job, (), ()) + + +def _try_read_gbq_colab_sessionless_dry_run( + query: str, + *, + pyformat_args: Dict[str, Any], +) -> Optional[pandas.Series]: + """Run a dry_run without a session, only if the session hasn't yet started.""" + + global _default_location_lock + + # Avoid creating a session just for dry run. We don't want to bind to a + # location too early. This is especially important if the query only refers + # to local data and not any BigQuery tables. + with _default_location_lock: + if not config.options.bigquery._session_started: + return _run_read_gbq_colab_sessionless_dry_run( + query, pyformat_args=pyformat_args + ) + + # Explicitly return None to indicate that we didn't run the dry run query. + return None + + +@overload +def _read_gbq_colab( # type: ignore[overload-overlap] + query_or_table: str, + *, + pyformat_args: Optional[Dict[str, Any]] = ..., + dry_run: Literal[False] = ..., +) -> bigframes.dataframe.DataFrame: + ... + + +@overload +def _read_gbq_colab( + query_or_table: str, + *, + pyformat_args: Optional[Dict[str, Any]] = ..., + dry_run: Literal[True] = ..., +) -> pandas.Series: + ... + + +def _read_gbq_colab( + query_or_table: str, + *, + pyformat_args: Optional[Dict[str, Any]] = None, + dry_run: bool = False, +) -> bigframes.dataframe.DataFrame | pandas.Series: + """A Colab-specific version of read_gbq. + + Calls `_set_default_session_location_if_possible` and then delegates + to `bigframes.session.Session._read_gbq_colab`. + + Args: + query_or_table (str): + SQL query or table ID (table ID not yet supported). + pyformat_args (Optional[Dict[str, Any]]): + Parameters to format into the query string. + dry_run (bool): + If True, estimates the query results size without returning data. + The return will be a pandas Series with query metadata. + + Returns: + Union[bigframes.dataframe.DataFrame, pandas.Series]: + A BigQuery DataFrame if `dry_run` is False, otherwise a pandas Series. + """ + if pyformat_args is None: + pyformat_args = {} + + # Only try to set the global location if it's not a dry run. We don't want + # to bind to a location too early. This is especially important if the query + # only refers to local data and not any BigQuery tables. + if dry_run: + result = _try_read_gbq_colab_sessionless_dry_run( + query_or_table, pyformat_args=pyformat_args + ) + + if result is not None: + return result + + # If we made it this far, we must have a session that has already + # started. That means we can safely call the "real" _read_gbq_colab, + # which generates slightly nicer SQL. + else: + # Delay formatting the query with the special "session-less" logic. This + # avoids doing unnecessary work if the session already has a location or has + # already started. + create_query = functools.partial( + bigframes.core.pyformat.pyformat, + query_or_table, + pyformat_args=pyformat_args, + dry_run=True, + ) + _set_default_session_location_if_possible_deferred_query(create_query) + if not config.options.bigquery._session_started: + with warnings.catch_warnings(): + # Don't warning about Polars in SQL cell. + # Related to b/437090788. + warnings.simplefilter("ignore", bigframes.exceptions.PreviewWarning) + config.options.bigquery.enable_polars_execution = True + + return global_session.with_default_session( + bigframes.session.Session._read_gbq_colab, + query_or_table, + pyformat_args=pyformat_args, + dry_run=dry_run, + ) + + def read_gbq_model(model_name: str): return global_session.with_default_session( bigframes.session.Session.read_gbq_model, @@ -208,6 +392,40 @@ def read_gbq_object_table( ) +@overload +def read_gbq_query( # type: ignore[overload-overlap] + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + use_cache: Optional[bool] = ..., + col_order: Iterable[str] = ..., + filters: vendored_pandas_gbq.FiltersType = ..., + dry_run: Literal[False] = ..., + allow_large_results: Optional[bool] = ..., +) -> bigframes.dataframe.DataFrame: + ... + + +@overload +def read_gbq_query( + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + use_cache: Optional[bool] = ..., + col_order: Iterable[str] = ..., + filters: vendored_pandas_gbq.FiltersType = ..., + dry_run: Literal[True] = ..., + allow_large_results: Optional[bool] = ..., +) -> pandas.Series: + ... + + def read_gbq_query( query: str, *, @@ -218,7 +436,9 @@ def read_gbq_query( use_cache: Optional[bool] = None, col_order: Iterable[str] = (), filters: vendored_pandas_gbq.FiltersType = (), -) -> bigframes.dataframe.DataFrame: + dry_run: bool = False, + allow_large_results: Optional[bool] = None, +) -> bigframes.dataframe.DataFrame | pandas.Series: _set_default_session_location_if_possible(query) return global_session.with_default_session( bigframes.session.Session.read_gbq_query, @@ -230,12 +450,44 @@ def read_gbq_query( use_cache=use_cache, col_order=col_order, filters=filters, + dry_run=dry_run, + allow_large_results=allow_large_results, ) read_gbq_query.__doc__ = inspect.getdoc(bigframes.session.Session.read_gbq_query) +@overload +def read_gbq_table( # type: ignore[overload-overlap] + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + max_results: Optional[int] = ..., + filters: vendored_pandas_gbq.FiltersType = ..., + use_cache: bool = ..., + col_order: Iterable[str] = ..., + dry_run: Literal[False] = ..., +) -> bigframes.dataframe.DataFrame: + ... + + +@overload +def read_gbq_table( + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + max_results: Optional[int] = ..., + filters: vendored_pandas_gbq.FiltersType = ..., + use_cache: bool = ..., + col_order: Iterable[str] = ..., + dry_run: Literal[True] = ..., +) -> pandas.Series: + ... + + def read_gbq_table( query: str, *, @@ -245,7 +497,8 @@ def read_gbq_table( filters: vendored_pandas_gbq.FiltersType = (), use_cache: bool = True, col_order: Iterable[str] = (), -) -> bigframes.dataframe.DataFrame: + dry_run: bool = False, +) -> bigframes.dataframe.DataFrame | pandas.Series: _set_default_session_location_if_possible(query) return global_session.with_default_session( bigframes.session.Session.read_gbq_table, @@ -256,6 +509,7 @@ def read_gbq_table( filters=filters, use_cache=use_cache, col_order=col_order, + dry_run=dry_run, ) @@ -367,24 +621,15 @@ def from_glob_path( from_glob_path.__doc__ = inspect.getdoc(bigframes.session.Session.from_glob_path) +_default_location_lock = threading.Lock() -def _set_default_session_location_if_possible(query): - # Set the location as per the query if this is the first query the user is - # running and: - # (1) Default session has not started yet, and - # (2) Location is not set yet, and - # (3) Use of regional endpoints is not set. - # If query is a table name, then it would be the location of the table. - # If query is a SQL with a table, then it would be table's location. - # If query is a SQL with no table, then it would be the BQ default location. - if ( - config.options.bigquery._session_started - or config.options.bigquery.location - or config.options.bigquery.use_regional_endpoints - ): - return - - clients_provider = bigframes.session.clients.ClientsProvider( + +def _get_bqclient() -> bigquery.Client: + # Address circular imports in doctest due to bigframes/session/__init__.py + # containing a lot of logic and samples. + from bigframes.session import clients + + clients_provider = clients.ClientsProvider( project=config.options.bigquery.project, location=config.options.bigquery.location, use_regional_endpoints=config.options.bigquery.use_regional_endpoints, @@ -392,16 +637,61 @@ def _set_default_session_location_if_possible(query): application_name=config.options.bigquery.application_name, bq_kms_key_name=config.options.bigquery.kms_key_name, client_endpoints_override=config.options.bigquery.client_endpoints_override, + requests_transport_adapters=config.options.bigquery.requests_transport_adapters, ) + return clients_provider.bqclient - bqclient = clients_provider.bqclient - if bigframes.session._io.bigquery.is_query(query): - # Intentionally run outside of the session so that we can detect the - # location before creating the session. Since it's a dry_run, labels - # aren't necessary. - job = bqclient.query(query, bigquery.QueryJobConfig(dry_run=True)) - config.options.bigquery.location = job.location - else: - table = bqclient.get_table(query) - config.options.bigquery.location = table.location +def _dry_run(query, bqclient) -> bigquery.QueryJob: + # Address circular imports in doctest due to bigframes/session/__init__.py + # containing a lot of logic and samples. + from bigframes.session import metrics as bf_metrics + + job = bqclient.query(query, bigquery.QueryJobConfig(dry_run=True)) + + # Fix for b/435183833. Log metrics even if a Session isn't available. + if bf_metrics.LOGGING_NAME_ENV_VAR in os.environ: + metrics = bf_metrics.ExecutionMetrics() + metrics.count_job_stats(job) + return job + + +def _set_default_session_location_if_possible(query): + _set_default_session_location_if_possible_deferred_query(lambda: query) + + +def _set_default_session_location_if_possible_deferred_query(create_query): + # Address circular imports in doctest due to bigframes/session/__init__.py + # containing a lot of logic and samples. + from bigframes.session._io import bigquery + + # Set the location as per the query if this is the first query the user is + # running and: + # (1) Default session has not started yet, and + # (2) Location is not set yet, and + # (3) Use of regional endpoints is not set. + # If query is a table name, then it would be the location of the table. + # If query is a SQL with a table, then it would be table's location. + # If query is a SQL with no table, then it would be the BQ default location. + global _default_location_lock + + with _default_location_lock: + if ( + config.options.bigquery._session_started + or config.options.bigquery.location + or config.options.bigquery.use_regional_endpoints + ): + return + + query = create_query() + bqclient = _get_bqclient() + + if bigquery.is_query(query): + # Intentionally run outside of the session so that we can detect the + # location before creating the session. Since it's a dry_run, labels + # aren't necessary. + job = _dry_run(query, bqclient) + config.options.bigquery.location = job.location + else: + table = bqclient.get_table(query) + config.options.bigquery.location = table.location diff --git a/bigframes/series.py b/bigframes/series.py index fe2d1aae0e..606169a8a1 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -23,23 +23,37 @@ import numbers import textwrap import typing -from typing import Any, cast, List, Literal, Mapping, Optional, Sequence, Tuple, Union +from typing import ( + Any, + Callable, + cast, + Iterable, + List, + Literal, + Mapping, + Optional, + overload, + Sequence, + Tuple, + Union, +) +import warnings import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.series as vendored_pandas_series import google.cloud.bigquery as bigquery import numpy import pandas -import pandas.core.dtypes.common +from pandas.api import extensions as pd_ext import pyarrow as pa import typing_extensions import bigframes.core -from bigframes.core import log_adapter +from bigframes.core import agg_expressions, groupby, log_adapter import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks import bigframes.core.expression as ex -import bigframes.core.groupby as groupby +import bigframes.core.identifiers as ids import bigframes.core.indexers import bigframes.core.indexes as indexes import bigframes.core.ordering as order @@ -47,55 +61,160 @@ import bigframes.core.utils as utils import bigframes.core.validations as validations import bigframes.core.window +from bigframes.core.window import rolling import bigframes.core.window_spec as windows import bigframes.dataframe import bigframes.dtypes +import bigframes.exceptions as bfe import bigframes.formatting_helpers as formatter +import bigframes.functions import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops -import bigframes.operations.base import bigframes.operations.blob as blob import bigframes.operations.datetimes as dt import bigframes.operations.lists as lists import bigframes.operations.plotting as plotting -import bigframes.operations.strings as strings +import bigframes.operations.python_op_maps as python_ops import bigframes.operations.structs as structs +import bigframes.session if typing.TYPE_CHECKING: import bigframes.geopandas.geoseries + import bigframes.operations.strings as strings + LevelType = typing.Union[str, int] LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] -_remote_function_recommendation_message = ( +_bigquery_function_recommendation_message = ( "Your functions could not be applied directly to the Series." - " Try converting it to a remote function." + " Try converting it to a BigFrames BigQuery function." ) _list = list # Type alias to escape Series.list property @log_adapter.class_logger -class Series(bigframes.operations.base.SeriesMethods, vendored_pandas_series.Series): +class Series(vendored_pandas_series.Series): # Must be above 5000 for pandas to delegate to bigframes for binops __pandas_priority__ = 13000 - def __init__(self, *args, **kwargs): + # Ensure mypy can more robustly determine the type of self._block since it + # gets set in various places. + _block: blocks.Block + + def __init__( + self, + data=None, + index=None, + dtype: Optional[bigframes.dtypes.DtypeString | bigframes.dtypes.Dtype] = None, + name: str | None = None, + copy: Optional[bool] = None, + *, + session: Optional[bigframes.session.Session] = None, + ): self._query_job: Optional[bigquery.QueryJob] = None - super().__init__(*args, **kwargs) + import bigframes.pandas + + # Ignore object dtype if provided, as it provides no additional + # information about what BigQuery type to use. + if dtype is not None and bigframes.dtypes.is_object_like(dtype): + dtype = None + + read_pandas_func = ( + session.read_pandas + if (session is not None) + else (lambda x: bigframes.pandas.read_pandas(x)) + ) + + block: typing.Optional[blocks.Block] = None + if (name is not None) and not isinstance(name, typing.Hashable): + raise ValueError( + f"BigQuery DataFrames only supports hashable series names. {constants.FEEDBACK_LINK}" + ) + if copy is not None and not copy: + raise ValueError( + f"Series constructor only supports copy=True. {constants.FEEDBACK_LINK}" + ) + + if isinstance(data, blocks.Block): + block = data + elif isinstance(data, bigframes.pandas.Series): + block = data._get_block() + # special case where data is local scalar, but index is bigframes index (maybe very big) + elif ( + not utils.is_list_like(data) and not isinstance(data, indexes.Index) + ) and isinstance(index, indexes.Index): + block = index._block + block, _ = block.create_constant(data) + block = block.with_column_labels([None]) + # prevents no-op reindex later + index = None + elif isinstance(data, indexes.Index) or isinstance(index, indexes.Index): + data = indexes.Index(data, dtype=dtype, name=name, session=session) + # set to none as it has already been applied, avoid re-cast later + if data.nlevels != 1: + raise NotImplementedError("Cannot interpret multi-index as Series.") + # Reset index to promote index columns to value columns, set default index + data_block = data._block.reset_index(drop=False).with_column_labels( + data.names + ) + if index is not None: # Align data and index by offset + bf_index = indexes.Index(index, session=session) + idx_block = bf_index._block.reset_index( + drop=False + ) # reset to align by offsets, and then reset back + idx_cols = idx_block.value_columns + data_block, (l_mapping, _) = idx_block.join(data_block, how="left") + data_block = data_block.set_index([l_mapping[col] for col in idx_cols]) + data_block = data_block.with_index_labels(bf_index.names) + # prevents no-op reindex later + index = None + block = data_block + + if block: + assert len(block.value_columns) == 1 + assert len(block.column_labels) == 1 + if index is not None: # reindexing operation + bf_index = indexes.Index(index) + idx_block = bf_index._block + idx_cols = idx_block.index_columns + block, _ = idx_block.join(block, how="left") + block = block.with_index_labels(bf_index.names) + if name: + block = block.with_column_labels([name]) + if dtype: + bf_dtype = bigframes.dtypes.bigframes_type(dtype) + block = block.multi_apply_unary_op(ops.AsTypeOp(to_type=bf_dtype)) + else: + if isinstance(dtype, str) and dtype.lower() == "json": + dtype = bigframes.dtypes.JSON_DTYPE + pd_series = pandas.Series( + data=data, + index=index, # type:ignore + dtype=dtype, # type:ignore + name=name, + ) + block = read_pandas_func(pd_series)._get_block() # type:ignore + + assert block is not None + self._block: blocks.Block = block + self._block.session._register_object(self) @property def dt(self) -> dt.DatetimeMethods: - return dt.DatetimeMethods(self._block) + return dt.DatetimeMethods(self) @property def dtype(self): + bigframes.dtypes.warn_on_db_dtypes_json_dtype([self._dtype]) return self._dtype @property def dtypes(self): + bigframes.dtypes.warn_on_db_dtypes_json_dtype([self._dtype]) return self._dtype @property @@ -189,15 +308,15 @@ def query_job(self) -> Optional[bigquery.QueryJob]: @property def struct(self) -> structs.StructAccessor: - return structs.StructAccessor(self._block) + return structs.StructAccessor(self) @property def list(self) -> lists.ListAccessor: - return lists.ListAccessor(self._block) + return lists.ListAccessor(self) @property def blob(self) -> blob.BlobAccessor: - return blob.BlobAccessor(self._block) + return blob.BlobAccessor(self) @property @validations.requires_ordering() @@ -237,25 +356,51 @@ def __iter__(self) -> typing.Iterator: map(lambda x: x.squeeze(axis=1), self._block.to_pandas_batches()) ) + def __contains__(self, key) -> bool: + return key in self.index + def copy(self) -> Series: return Series(self._block) + @overload def rename( - self, index: Union[blocks.Label, Mapping[Any, Any]] = None, **kwargs + self, + index: Union[blocks.Label, Mapping[Any, Any]] = None, + ) -> Series: + ... + + @overload + def rename( + self, + index: Union[blocks.Label, Mapping[Any, Any]] = None, + *, + inplace: Literal[False], + **kwargs, ) -> Series: + ... + + @overload + def rename( + self, + index: Union[blocks.Label, Mapping[Any, Any]] = None, + *, + inplace: Literal[True], + **kwargs, + ) -> None: + ... + + def rename( + self, + index: Union[blocks.Label, Mapping[Any, Any]] = None, + *, + inplace: bool = False, + **kwargs, + ) -> Optional[Series]: if len(kwargs) != 0: raise NotImplementedError( f"rename does not currently support any keyword arguments. {constants.FEEDBACK_LINK}" ) - # rename the Series name - if index is None or isinstance( - index, str - ): # Python 3.9 doesn't allow isinstance of Optional - index = typing.cast(Optional[str], index) - block = self._block.with_column_labels([index]) - return Series(block) - # rename the index if isinstance(index, Mapping): index = typing.cast(Mapping[Any, Any], index) @@ -280,22 +425,61 @@ def rename( block = block.set_index(new_idx_ids, index_labels=block.index.names) - return Series(block) + if inplace: + self._block = block + return None + else: + return Series(block) # rename the Series name if isinstance(index, typing.Hashable): + # Python 3.9 doesn't allow isinstance of Optional index = typing.cast(Optional[str], index) block = self._block.with_column_labels([index]) - return Series(block) + + if inplace: + self._block = block + return None + else: + return Series(block) raise ValueError(f"Unsupported type of parameter index: {type(index)}") - @validations.requires_index + @overload + def rename_axis( + self, + mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], + ) -> Series: + ... + + @overload def rename_axis( self, mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], + *, + inplace: Literal[False], **kwargs, ) -> Series: + ... + + @overload + def rename_axis( + self, + mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], + *, + inplace: Literal[True], + **kwargs, + ) -> None: + ... + + @validations.requires_index + def rename_axis( + self, + mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], + *, + inplace: bool = False, + **kwargs, + ) -> Optional[Series]: if len(kwargs) != 0: raise NotImplementedError( f"rename_axis does not currently support any keyword arguments. {constants.FEEDBACK_LINK}" @@ -305,7 +489,13 @@ def rename_axis( labels = mapper else: labels = [mapper] - return Series(self._block.with_index_labels(labels)) + + block = self._block.with_index_labels(labels) + if inplace: + self._block = block + return None + else: + return Series(block) def equals( self, other: typing.Union[Series, bigframes.dataframe.DataFrame] @@ -315,21 +505,80 @@ def equals( return False return block_ops.equals(self._block, other._block) + @overload # type: ignore[override] + def reset_index( + self, + level: blocks.LevelsType = ..., + *, + name: typing.Optional[str] = ..., + drop: Literal[False] = ..., + inplace: Literal[False] = ..., + allow_duplicates: Optional[bool] = ..., + ) -> bigframes.dataframe.DataFrame: + ... + + @overload + def reset_index( + self, + level: blocks.LevelsType = ..., + *, + name: typing.Optional[str] = ..., + drop: Literal[True] = ..., + inplace: Literal[False] = ..., + allow_duplicates: Optional[bool] = ..., + ) -> Series: + ... + + @overload + def reset_index( + self, + level: blocks.LevelsType = ..., + *, + name: typing.Optional[str] = ..., + drop: bool = ..., + inplace: Literal[True] = ..., + allow_duplicates: Optional[bool] = ..., + ) -> None: + ... + @validations.requires_ordering() def reset_index( self, + level: blocks.LevelsType = None, *, name: typing.Optional[str] = None, drop: bool = False, - ) -> bigframes.dataframe.DataFrame | Series: - block = self._block.reset_index(drop) + inplace: bool = False, + allow_duplicates: Optional[bool] = None, + ) -> bigframes.dataframe.DataFrame | Series | None: + if allow_duplicates is None: + allow_duplicates = False + block = self._block.reset_index(level, drop, allow_duplicates=allow_duplicates) if drop: + if inplace: + self._set_block(block) + return None return Series(block) else: + if inplace: + raise ValueError( + "Series.reset_index cannot combine inplace=True and drop=False" + ) if name: block = block.assign_label(self._value_column, name) return bigframes.dataframe.DataFrame(block) + def _repr_mimebundle_(self, include=None, exclude=None): + """ + Custom display method for IPython/Jupyter environments. + This is called by IPython's display system when the object is displayed. + """ + # TODO(b/467647693): Anywidget integration has been tested in Jupyter, VS Code, and + # BQ Studio, but there is a known compatibility issue with Marimo that needs to be addressed. + from bigframes.display import html + + return html.repr_mimebundle(self, include=include, exclude=exclude) + def __repr__(self) -> str: # Protect against errors with uninitialized Series. See: # https://github.com/googleapis/python-bigquery-dataframes/issues/728 @@ -341,25 +590,22 @@ def __repr__(self) -> str: # TODO(swast): Avoid downloading the whole series by using job # metadata, like we do with DataFrame. opts = bigframes.options.display - max_results = opts.max_rows if opts.repr_mode == "deferred": return formatter.repr_query_job(self._compute_dry_run()) self._cached() - pandas_df, _, query_job = self._block.retrieve_repr_request_results(max_results) + pandas_df, row_count, query_job = self._block.retrieve_repr_request_results( + opts.max_rows + ) self._set_internal_query_job(query_job) + from bigframes.display import plaintext - pd_series = pandas_df.iloc[:, 0] - - import pandas.io.formats - - # safe to mutate this, this dict is owned by this code, and does not affect global config - to_string_kwargs = pandas.io.formats.format.get_series_repr_params() # type: ignore - if len(self._block.index_columns) == 0: - to_string_kwargs.update({"index": False}) - repr_string = pd_series.to_string(**to_string_kwargs) - - return repr_string + return plaintext.create_text_representation( + pandas_df, + row_count, + is_series=True, + has_index=len(self._block.index_columns) > 0, + ) def astype( self, @@ -381,47 +627,189 @@ def to_pandas( random_state: Optional[int] = None, *, ordered: bool = True, + dry_run: bool = False, + allow_large_results: Optional[bool] = None, ) -> pandas.Series: """Writes Series to pandas Series. + **Examples:** + + >>> s = bpd.Series([4, 3, 2]) + + Download the data from BigQuery and convert it into an in-memory pandas Series. + + >>> s.to_pandas() + 0 4 + 1 3 + 2 2 + dtype: Int64 + + Estimate job statistics without processing or downloading data by using `dry_run=True`. + + >>> s.to_pandas(dry_run=True) # doctest: +SKIP + columnCount 1 + columnDtypes {None: Int64} + indexLevel 1 + indexDtypes [Int64] + projectId bigframes-dev + location US + jobType QUERY + destinationTable {'projectId': 'bigframes-dev', 'datasetId': '_... + useLegacySql False + referencedTables None + totalBytesProcessed 0 + cacheHit False + statementType SELECT + creationTime 2025-04-03 18:54:59.219000+00:00 + dtype: object + Args: max_download_size (int, default None): - Download size threshold in MB. If max_download_size is exceeded when downloading data - (e.g., to_pandas()), the data will be downsampled if - bigframes.options.sampling.enable_downsampling is True, otherwise, an error will be - raised. If set to a value other than None, this will supersede the global config. + .. deprecated:: 2.0.0 + ``max_download_size`` parameter is deprecated. Please use ``to_pandas_batches()`` + method instead. + + Download size threshold in MB. If ``max_download_size`` is exceeded when downloading data, + the data will be downsampled if ``bigframes.options.sampling.enable_downsampling`` is + ``True``, otherwise, an error will be raised. If set to a value other than ``None``, + this will supersede the global config. sampling_method (str, default None): + .. deprecated:: 2.0.0 + ``sampling_method`` parameter is deprecated. Please use ``sample()`` method instead. + Downsampling algorithms to be chosen from, the choices are: "head": This algorithm returns a portion of the data from the beginning. It is fast and requires minimal computations to perform the downsampling; "uniform": This algorithm returns uniform random samples of the data. If set to a value other than None, this will supersede the global config. random_state (int, default None): + .. deprecated:: 2.0.0 + ``random_state`` parameter is deprecated. Please use ``sample()`` method instead. + The seed for the uniform downsampling algorithm. If provided, the uniform method may take longer to execute and require more computation. If set to a value other than None, this will supersede the global config. ordered (bool, default True): Determines whether the resulting pandas series will be ordered. In some cases, unordered may result in a faster-executing query. - + dry_run (bool, default False): + If this argument is true, this method will not process the data. Instead, it returns + a Pandas Series containing dry run job statistics + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large query results + over the default size limit of 10 GB. Returns: pandas.Series: A pandas Series with all rows of this Series if the data_sampling_threshold_mb - is not exceeded; otherwise, a pandas Series with downsampled rows of the DataFrame. + is not exceeded; otherwise, a pandas Series with downsampled rows of the DataFrame. If dry_run + is set to True, a pandas Series containing dry run statistics will be returned. """ + if max_download_size is not None: + msg = bfe.format_message( + "DEPRECATED: The `max_download_size` parameters for `Series.to_pandas()` " + "are deprecated and will be removed soon. Please use `Series.to_pandas_batches()`." + ) + warnings.warn(msg, category=FutureWarning) + if sampling_method is not None or random_state is not None: + msg = bfe.format_message( + "DEPRECATED: The `sampling_method` and `random_state` parameters for " + "`Series.to_pandas()` are deprecated and will be removed soon. " + "Please use `Series.sample().to_pandas()` instead for sampling." + ) + warnings.warn(msg, category=FutureWarning) + + if dry_run: + dry_run_stats, dry_run_job = self._block._compute_dry_run( + max_download_size=max_download_size, + sampling_method=sampling_method, + random_state=random_state, + ordered=ordered, + ) + + self._set_internal_query_job(dry_run_job) + return dry_run_stats + + # Repeat the to_pandas() call to make mypy deduce type correctly, because mypy cannot resolve + # Literal[True/False] to bool df, query_job = self._block.to_pandas( max_download_size=max_download_size, sampling_method=sampling_method, random_state=random_state, ordered=ordered, + allow_large_results=allow_large_results, ) - self._set_internal_query_job(query_job) + + if query_job: + self._set_internal_query_job(query_job) + series = df.squeeze(axis=1) series.name = self._name return series + def to_pandas_batches( + self, + page_size: Optional[int] = None, + max_results: Optional[int] = None, + *, + allow_large_results: Optional[bool] = None, + ) -> Iterable[pandas.Series]: + """Stream Series results to an iterable of pandas Series. + + page_size and max_results determine the size and number of batches, + see https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJob#google_cloud_bigquery_job_QueryJob_result + + **Examples:** + + >>> s = bpd.Series([4, 3, 2, 2, 3]) + + Iterate through the results in batches, limiting the total rows yielded + across all batches via `max_results`: + + >>> for s_batch in s.to_pandas_batches(max_results=3): + ... print(s_batch) + 0 4 + 1 3 + 2 2 + dtype: Int64 + + Alternatively, control the approximate size of each batch using `page_size` + and fetch batches manually using `next()`: + + >>> it = s.to_pandas_batches(page_size=2) + >>> next(it) + 0 4 + 1 3 + dtype: Int64 + >>> next(it) + 2 2 + 3 2 + dtype: Int64 + + Args: + page_size (int, default None): + The maximum number of rows of each batch. Non-positive values are ignored. + max_results (int, default None): + The maximum total number of rows of all batches. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large query results + over the default size limit of 10 GB. + + Returns: + Iterable[pandas.Series]: + An iterable of smaller Series which combine to + form the original Series. Results stream from bigquery, + see https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.table.RowIterator#google_cloud_bigquery_table_RowIterator_to_arrow_iterable + """ + batches = self._block.to_pandas_batches( + page_size=page_size, + max_results=max_results, + allow_large_results=allow_large_results, + ) + return map(lambda df: cast(pandas.Series, df.squeeze(1)), batches) + def _compute_dry_run(self) -> bigquery.QueryJob: - return self._block._compute_dry_run((self._value_column,)) + _, query_job = self._block._compute_dry_run((self._value_column,)) + return query_job def drop( self, @@ -517,7 +905,7 @@ def cumsum(self) -> Series: @validations.requires_ordering() def ffill(self, *, limit: typing.Optional[int] = None) -> Series: - window = windows.rows(preceding=limit, following=0) + window = windows.rows(start=None if limit is None else -limit, end=0) return self._apply_window_op(agg_ops.LastNonNullOp(), window) pad = ffill @@ -525,7 +913,7 @@ def ffill(self, *, limit: typing.Optional[int] = None) -> Series: @validations.requires_ordering() def bfill(self, *, limit: typing.Optional[int] = None) -> Series: - window = windows.rows(preceding=0, following=limit) + window = windows.rows(start=0, end=limit) return self._apply_window_op(agg_ops.FirstNonNullOp(), window) @validations.requires_ordering() @@ -564,8 +952,11 @@ def rank( numeric_only=False, na_option: str = "keep", ascending: bool = True, + pct: bool = False, ) -> Series: - return Series(block_ops.rank(self._block, method, na_option, ascending)) + return Series( + block_ops.rank(self._block, method, na_option, ascending, pct=pct) + ) def fillna(self, value=None) -> Series: return self._apply_binary_op(value, ops.fillna_op) @@ -685,7 +1076,9 @@ def head(self, n: int = 5) -> Series: def tail(self, n: int = 5) -> Series: return typing.cast(Series, self.iloc[-n:]) - def peek(self, n: int = 5, *, force: bool = True) -> pandas.Series: + def peek( + self, n: int = 5, *, force: bool = True, allow_large_results=None + ) -> pandas.Series: """ Preview n arbitrary elements from the series without guarantees about row selection or ordering. @@ -699,17 +1092,22 @@ def peek(self, n: int = 5, *, force: bool = True) -> pandas.Series: force (bool, default True): If the data cannot be peeked efficiently, the series will instead be fully materialized as part of the operation if ``force=True``. If ``force=False``, the operation will throw a ValueError. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large query results + over the default size limit of 10 GB. Returns: pandas.Series: A pandas Series with n rows. Raises: ValueError: If force=False and data cannot be efficiently peeked. """ - maybe_result = self._block.try_peek(n) + maybe_result = self._block.try_peek(n, allow_large_results=allow_large_results) if maybe_result is None: if force: self._cached() - maybe_result = self._block.try_peek(n, force=True) + maybe_result = self._block.try_peek( + n, force=True, allow_large_results=allow_large_results + ) assert maybe_result is not None else: raise ValueError( @@ -719,6 +1117,10 @@ def peek(self, n: int = 5, *, force: bool = True) -> pandas.Series: as_series.name = self.name return as_series + def item(self): + # Docstring is in third_party/bigframes_vendored/pandas/core/series.py + return self.peek(2).item() + def nlargest(self, n: int = 5, keep: str = "first") -> Series: if keep not in ("first", "last", "all"): raise ValueError("'keep must be one of 'first', 'last', or 'all'") @@ -737,9 +1139,11 @@ def nsmallest(self, n: int = 5, keep: str = "first") -> Series: block_ops.nsmallest(self._block, n, [self._value_column], keep=keep) ) - def isin(self, values) -> "Series" | None: - if isinstance(values, (Series,)): + def isin(self, values) -> "Series": + if isinstance(values, Series): return Series(self._block.isin(values._block)) + if isinstance(values, indexes.Index): + return Series(self._block.isin(values.to_series()._block)) if not _is_list_like(values): raise TypeError( "only list-like objects are allowed to be passed to " @@ -782,20 +1186,20 @@ def __xor__(self, other: bool | int | Series) -> Series: __rxor__ = __xor__ - def __add__(self, other: float | int | Series) -> Series: + def __add__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.add(other) __add__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__add__) - def __radd__(self, other: float | int | Series) -> Series: + def __radd__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.radd(other) __radd__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__radd__) - def add(self, other: float | int | Series) -> Series: + def add(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.add_op) - def radd(self, other: float | int | Series) -> Series: + def radd(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.add_op, reverse=True) def __sub__(self, other: float | int | Series) -> Series: @@ -836,20 +1240,20 @@ def rmul(self, other: float | int | Series) -> Series: multiply = mul multiply.__doc__ = inspect.getdoc(vendored_pandas_series.Series.mul) - def __truediv__(self, other: float | int | Series) -> Series: + def __truediv__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.truediv(other) __truediv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__truediv__) - def __rtruediv__(self, other: float | int | Series) -> Series: + def __rtruediv__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.rtruediv(other) __rtruediv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rtruediv__) - def truediv(self, other: float | int | Series) -> Series: + def truediv(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.div_op) - def rtruediv(self, other: float | int | Series) -> Series: + def rtruediv(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.div_op, reverse=True) truediv.__doc__ = inspect.getdoc(vendored_pandas_series.Series.truediv) @@ -858,20 +1262,20 @@ def rtruediv(self, other: float | int | Series) -> Series: rdiv = rtruediv rdiv.__doc__ = inspect.getdoc(vendored_pandas_series.Series.rtruediv) - def __floordiv__(self, other: float | int | Series) -> Series: + def __floordiv__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.floordiv(other) __floordiv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__floordiv__) - def __rfloordiv__(self, other: float | int | Series) -> Series: + def __rfloordiv__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.rfloordiv(other) __rfloordiv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rfloordiv__) - def floordiv(self, other: float | int | Series) -> Series: + def floordiv(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.floordiv_op) - def rfloordiv(self, other: float | int | Series) -> Series: + def rfloordiv(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.floordiv_op, reverse=True) def __pow__(self, other: float | int | Series) -> Series: @@ -967,6 +1371,8 @@ def update(self, other: Union[Series, Sequence, Mapping]) -> None: def __abs__(self) -> Series: return self.abs() + __abs__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.abs) + def abs(self) -> Series: return self._apply_unary_op(ops.abs_op) @@ -1030,7 +1436,7 @@ def agg(self, func: str | typing.Sequence[str]) -> scalars.Scalar | Series: raise NotImplementedError( f"Multiple aggregations only supported on numeric series. {constants.FEEDBACK_LINK}" ) - aggregations = [agg_ops.lookup_agg_func(f) for f in func] + aggregations = [agg_ops.lookup_agg_func(f)[0] for f in func] return Series( self._block.summarize( [self._value_column], @@ -1038,13 +1444,16 @@ def agg(self, func: str | typing.Sequence[str]) -> scalars.Scalar | Series: ) ) else: - return self._apply_aggregation( - agg_ops.lookup_agg_func(typing.cast(str, func)) - ) + return self._apply_aggregation(agg_ops.lookup_agg_func(func)[0]) aggregate = agg aggregate.__doc__ = inspect.getdoc(vendored_pandas_series.Series.agg) + def describe(self) -> Series: + from bigframes.pandas.core.methods import describe + + return cast(Series, describe.describe(self, include="all")) + def skew(self): count = self.count() if count < 3: @@ -1083,13 +1492,15 @@ def kurt(self): def mode(self) -> Series: block = self._block # Approach: Count each value, return each value for which count(x) == max(counts)) - block, agg_ids = block.aggregate( + block = block.aggregate( by_column_ids=[self._value_column], aggregations=( - ex.UnaryAggregation(agg_ops.count_op, ex.deref(self._value_column)), + agg_expressions.UnaryAggregation( + agg_ops.count_op, ex.deref(self._value_column) + ), ), ) - value_count_col_id = agg_ids[0] + value_count_col_id = block.value_columns[0] block, max_value_count_col_id = block.apply_window_op( value_count_col_id, agg_ops.max_op, @@ -1179,21 +1590,40 @@ def items(self): for item in batch_df.squeeze(axis=1).items(): yield item + def _apply_callable(self, condition): + """ "Executes the possible callable condition as needed.""" + if callable(condition): + # When it's a bigframes function. + if hasattr(condition, "bigframes_bigquery_function"): + return self.apply(condition) + # When it's a plain Python function. + else: + return self.apply(condition, by_row=False) + + # When it's not a callable. + return condition + def where(self, cond, other=None): + cond = self._apply_callable(cond) + other = self._apply_callable(other) + value_id, cond_id, other_id, block = self._align3(cond, other) block, result_id = block.project_expr( ops.where_op.as_expr(value_id, cond_id, other_id) ) return Series(block.select_column(result_id).with_column_labels([self.name])) - def clip(self, lower, upper): + def clip(self, lower=None, upper=None): if lower is None and upper is None: return self if lower is None: return self._apply_binary_op(upper, ops.minimum_op, alignment="left") if upper is None: return self._apply_binary_op(lower, ops.maximum_op, alignment="left") - value_id, lower_id, upper_id, block = self._align3(lower, upper) + # special rule to coerce scalar string args to date + value_id, lower_id, upper_id, block = self._align3( + lower, upper, cast_scalars=(bigframes.dtypes.is_date_like(self.dtype)) + ) block, result_id = block.project_expr( ops.clip_op.as_expr(value_id, lower_id, upper_id), ) @@ -1325,7 +1755,7 @@ def __getattr__(self, key: str): raise AttributeError(key) elif hasattr(pandas.Series, key): log_adapter.submit_pandas_labels( - self._block.expr.session.bqclient, self.__class__.__name__, key + self._block.session.bqclient, self.__class__.__name__, key ) raise AttributeError( textwrap.dedent( @@ -1340,17 +1770,27 @@ def __getattr__(self, key: str): else: raise AttributeError(key) + def __setitem__(self, key, value) -> None: + """Set item using direct assignment, delegating to .loc indexer.""" + self.loc[key] = value + def _apply_aggregation( self, op: agg_ops.UnaryAggregateOp | agg_ops.NullaryAggregateOp ) -> Any: return self._block.get_stat(self._value_column, op) - def _apply_window_op(self, op: agg_ops.WindowOp, window_spec: windows.WindowSpec): + def _apply_window_op( + self, op: agg_ops.UnaryWindowOp, window_spec: windows.WindowSpec + ): block = self._block block, result_id = block.apply_window_op( self._value_column, op, window_spec=window_spec, result_label=self.name ) - return Series(block.select_column(result_id)) + result = Series(block.select_column(result_id)) + if op.skips_nulls: + return result.where(self.notna(), None) + else: + return result def value_counts( self, @@ -1365,13 +1805,43 @@ def value_counts( [self._value_column], normalize=normalize, ascending=ascending, - dropna=dropna, + drop_na=dropna, ) return Series(block) + @typing.overload # type: ignore[override] def sort_values( - self, *, axis=0, ascending=True, kind: str = "quicksort", na_position="last" + self, + *, + axis=..., + inplace: Literal[True] = ..., + ascending: bool | typing.Sequence[bool] = ..., + kind: str = ..., + na_position: typing.Literal["first", "last"] = ..., + ) -> None: + ... + + @typing.overload + def sort_values( + self, + *, + axis=..., + inplace: Literal[False] = ..., + ascending: bool | typing.Sequence[bool] = ..., + kind: str = ..., + na_position: typing.Literal["first", "last"] = ..., ) -> Series: + ... + + def sort_values( + self, + *, + axis=0, + inplace: bool = False, + ascending=True, + kind: str = "quicksort", + na_position: typing.Literal["first", "last"] = "last", + ) -> Optional[Series]: if axis != 0 and axis != "index": raise ValueError(f"No axis named {axis} for object type Series") if na_position not in ["first", "last"]: @@ -1383,10 +1853,28 @@ def sort_values( else order.descending_over(self._value_column, (na_position == "last")) ], ) - return Series(block) + if inplace: + self._set_block(block) + return None + else: + return Series(block) + + @typing.overload # type: ignore[override] + def sort_index( + self, *, axis=..., inplace: Literal[False] = ..., ascending=..., na_position=... + ) -> Series: + ... + + @typing.overload + def sort_index( + self, *, axis=0, inplace: Literal[True] = ..., ascending=..., na_position=... + ) -> None: + ... @validations.requires_index - def sort_index(self, *, axis=0, ascending=True, na_position="last") -> Series: + def sort_index( + self, *, axis=0, inplace: bool = False, ascending=True, na_position="last" + ) -> Optional[Series]: # TODO(tbergeron): Support level parameter once multi-index introduced. if axis != 0 and axis != "index": raise ValueError(f"No axis named {axis} for object type Series") @@ -1401,16 +1889,35 @@ def sort_index(self, *, axis=0, ascending=True, na_position="last") -> Series: for column in block.index_columns ] block = block.order_by(ordering) - return Series(block) + if inplace: + self._set_block(block) + return None + else: + return Series(block) @validations.requires_ordering() - def rolling(self, window: int, min_periods=None) -> bigframes.core.window.Window: - # To get n size window, need current row and n-1 preceding rows. - window_spec = windows.rows( - preceding=window - 1, following=0, min_periods=min_periods or window - ) - return bigframes.core.window.Window( - self._block, window_spec, self._block.value_columns, is_series=True + def rolling( + self, + window: int | pandas.Timedelta | numpy.timedelta64 | datetime.timedelta | str, + min_periods: int | None = None, + closed: Literal["right", "left", "both", "neither"] = "right", + ) -> bigframes.core.window.Window: + if isinstance(window, int): + # Rows rolling + window_spec = windows.WindowSpec( + bounds=windows.RowsWindowBounds.from_window_size(window, closed), + min_periods=window if min_periods is None else min_periods, + ) + return bigframes.core.window.Window( + self._block, window_spec, self._block.value_columns, is_series=True + ) + + return rolling.create_range_window( + block=self._block, + window=window, + min_periods=min_periods, + closed=closed, + is_series=True, ) @validations.requires_ordering() @@ -1454,12 +1961,18 @@ def _groupby_level( level: int | str | typing.Sequence[int] | typing.Sequence[str], dropna: bool = True, ) -> bigframes.core.groupby.SeriesGroupBy: + if utils.is_list_like(level): + by_key_is_singular = False + else: + by_key_is_singular = True + return groupby.SeriesGroupBy( self._block, self._value_column, by_col_ids=self._resolve_levels(level), value_name=self.name, dropna=dropna, + by_key_is_singular=by_key_is_singular, ) def _groupby_values( @@ -1471,8 +1984,10 @@ def _groupby_values( ) -> bigframes.core.groupby.SeriesGroupBy: if not isinstance(by, Series) and _is_list_like(by): by = list(by) + by_key_is_singular = False else: by = [typing.cast(typing.Union[blocks.Label, Series], by)] + by_key_is_singular = True block = self._block grouping_cols: typing.Sequence[str] = [] @@ -1504,100 +2019,122 @@ def _groupby_values( by_col_ids=grouping_cols, value_name=self.name, dropna=dropna, + by_key_is_singular=by_key_is_singular, ) def apply( - self, func, by_row: typing.Union[typing.Literal["compat"], bool] = "compat" + self, + func, + by_row: typing.Union[typing.Literal["compat"], bool] = "compat", + *, + args: typing.Tuple = (), ) -> Series: - # TODO(shobs, b/274645634): Support convert_dtype, args, **kwargs + # Note: This signature differs from pandas.Series.apply. Specifically, + # `args` is keyword-only and `by_row` is a custom parameter here. Full + # alignment would involve breaking changes. However, given that by_row + # is not frequently used, we defer any such changes until there is a + # clear need based on user feedback. + # + # See pandas docs for reference: + # https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.apply.html + + # TODO(shobs, b/274645634): Support convert_dtype, **kwargs # is actually a ternary op if by_row not in ["compat", False]: raise ValueError("Param by_row must be one of 'compat' or False") - if not callable(func): + if not callable(func) and not isinstance(func, numpy.ufunc): raise ValueError( - "Only a ufunc (a function that applies to the entire Series) or a remote function that only works on single values are supported." + "Only a ufunc (a function that applies to the entire Series) or" + " a BigFrames BigQuery function that only works on single values" + " are supported." ) - if not hasattr(func, "bigframes_remote_function"): - # It is not a remote function - # Then it must be a vectorized function that applies to the Series - # as a whole - if by_row: - raise ValueError( - "A vectorized non-remote function can be provided only with by_row=False." - " For element-wise operation it must be a remote function." + if isinstance(func, bigframes.functions.BigqueryCallableRoutine): + # We are working with bigquery function at this point + if args: + result_series = self._apply_nary_op( + ops.NaryRemoteFunctionOp(function_def=func.udf_def), args + ) + # TODO(jialuo): Investigate why `_apply_nary_op` drops the series + # `name`. Manually reassigning it here as a temporary fix. + result_series.name = self.name + else: + result_series = self._apply_unary_op( + ops.RemoteFunctionOp(function_def=func.udf_def, apply_on_null=True) ) + result_series = func._post_process_series(result_series) - try: - return func(self) - except Exception as ex: - # This could happen if any of the operators in func is not - # supported on a Series. Let's guide the customer to use a - # remote function instead - if hasattr(ex, "message"): - ex.message += f"\n{_remote_function_recommendation_message}" - raise - - # We are working with remote function at this point - result_series = self._apply_unary_op( - ops.RemoteFunctionOp(func=func, apply_on_null=True) - ) + return result_series - # if the output is an array, reconstruct it from the json serialized - # string form - if bigframes.dtypes.is_array_like(func.output_dtype): - import bigframes.bigquery as bbq + bf_op = python_ops.python_callable_to_op(func) + if bf_op and isinstance(bf_op, ops.UnaryOp): + return self._apply_unary_op(bf_op) - result_dtype = bigframes.dtypes.arrow_dtype_to_bigframes_dtype( - func.output_dtype.pyarrow_dtype.value_type - ) - result_series = bbq.json_extract_string_array( - result_series, value_dtype=result_dtype + # It is neither a remote function nor a managed function. + # Then it must be a vectorized function that applies to the Series + # as a whole. + if by_row: + raise ValueError( + "You have passed a function as-is. If your intention is to " + "apply this function in a vectorized way (i.e. to the " + "entire Series as a whole, and you are sure that it " + "performs only the operations that are implemented for a " + "Series (e.g. a chain of arithmetic/logical operations, " + "such as `def foo(s): return s % 2 == 1`), please also " + "specify `by_row=False`. If your function contains " + "arbitrary code, it can only be applied to every element " + "in the Series individually, in which case you must " + "convert it to a BigFrames BigQuery function using " + "`bigframes.pandas.udf`, " + "or `bigframes.pandas.remote_function` before passing." ) - return result_series + try: + return func(self) # type: ignore + except Exception as ex: + # This could happen if any of the operators in func is not + # supported on a Series. Let's guide the customer to use a + # bigquery function instead + if hasattr(ex, "message"): + ex.message += f"\n{_bigquery_function_recommendation_message}" + raise def combine( self, other, func, ) -> Series: - if not callable(func): + if not callable(func) and not isinstance(func, numpy.ufunc): raise ValueError( - "Only a ufunc (a function that applies to the entire Series) or a remote function that only works on single values are supported." + "Only a ufunc (a function that applies to the entire Series) or" + " a BigFrames BigQuery function that only works on single values" + " are supported." ) - if not hasattr(func, "bigframes_remote_function"): - # Keep this in sync with .apply - try: - return func(self, other) - except Exception as ex: - # This could happen if any of the operators in func is not - # supported on a Series. Let's guide the customer to use a - # remote function instead - if hasattr(ex, "message"): - ex.message += f"\n{_remote_function_recommendation_message}" - raise - - result_series = self._apply_binary_op( - other, ops.BinaryRemoteFunctionOp(func=func) - ) - - # if the output is an array, reconstruct it from the json serialized - # string form - if bigframes.dtypes.is_array_like(func.output_dtype): - import bigframes.bigquery as bbq - - result_dtype = bigframes.dtypes.arrow_dtype_to_bigframes_dtype( - func.output_dtype.pyarrow_dtype.value_type - ) - result_series = bbq.json_extract_string_array( - result_series, value_dtype=result_dtype + if isinstance(func, bigframes.functions.BigqueryCallableRoutine): + result_series = self._apply_binary_op( + other, ops.BinaryRemoteFunctionOp(function_def=func.udf_def) ) - - return result_series + result_series = func._post_process_series(result_series) + return result_series + + bf_op = python_ops.python_callable_to_op(func) + if bf_op and isinstance(bf_op, ops.BinaryOp): + result_series = self._apply_binary_op(other, bf_op) + return result_series + + # Keep this in sync with .apply + try: + return func(self, other) + except Exception as ex: + # This could happen if any of the operators in func is not + # supported on a Series. Let's guide the customer to use a + # bigquery function instead + if hasattr(ex, "message"): + ex.message += f"\n{_bigquery_function_recommendation_message}" + raise @validations.requires_index def add_prefix(self, prefix: str, axis: int | str | None = None) -> Series: @@ -1607,6 +2144,13 @@ def add_prefix(self, prefix: str, axis: int | str | None = None) -> Series: def add_suffix(self, suffix: str, axis: int | str | None = None) -> Series: return Series(self._get_block().add_suffix(suffix)) + def take( + self, indices: typing.Sequence[int], axis: int | str | None = 0, **kwargs + ) -> Series: + if not utils.is_list_like(indices): + raise ValueError("indices should be a list-like object.") + return typing.cast(Series, self.iloc[indices]) + def filter( self, items: typing.Optional[typing.Iterable] = None, @@ -1689,8 +2233,6 @@ def reindex_like(self, other: Series, *, validate: typing.Optional[bool] = None) return self.reindex(other.index, validate=validate) def drop_duplicates(self, *, keep: str = "first") -> Series: - if keep is not False: - validations.enforce_ordered(self, "drop_duplicates(keep != False)") block = block_ops.drop_duplicates(self._block, (self._value_column,), keep) return Series(block) @@ -1698,17 +2240,19 @@ def unique(self, keep_order=True) -> Series: if keep_order: validations.enforce_ordered(self, "unique(keep_order != False)") return self.drop_duplicates() - block, result = self._block.aggregate( + block = self._block.aggregate( + [ + agg_expressions.UnaryAggregation( + agg_ops.AnyValueOp(), ex.deref(self._value_column) + ) + ], [self._value_column], - [ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(self._value_column))], column_labels=self._block.column_labels, dropna=False, ) - return Series(block.select_columns(result).reset_index()) + return Series(block.reset_index()) def duplicated(self, keep: str = "first") -> Series: - if keep is not False: - validations.enforce_ordered(self, "duplicated(keep != False)") block, indicator = block_ops.indicate_duplicates( self._block, (self._value_column,), keep ) @@ -1719,13 +2263,8 @@ def duplicated(self, keep: str = "first") -> Series: ) def mask(self, cond, other=None) -> Series: - if callable(cond): - if hasattr(cond, "bigframes_remote_function"): - cond = self.apply(cond) - else: - # For non-remote function assume that it is applicable on Series - cond = self.apply(cond, by_row=False) - + cond = self._apply_callable(cond) + other = self._apply_callable(other) if not isinstance(cond, Series): raise TypeError( f"Only bigframes series condition is supported, received {type(cond).__name__}. " @@ -1748,22 +2287,36 @@ def to_csv( *, header: bool = True, index: bool = True, + allow_large_results: Optional[bool] = None, ) -> Optional[str]: if utils.is_gcs_path(path_or_buf): return self.to_frame().to_csv( - path_or_buf, sep=sep, header=header, index=index + path_or_buf, + sep=sep, + header=header, + index=index, + allow_large_results=allow_large_results, ) else: - pd_series = self.to_pandas() + pd_series = self.to_pandas(allow_large_results=allow_large_results) return pd_series.to_csv( path_or_buf=path_or_buf, sep=sep, header=header, index=index ) - def to_dict(self, into: type[dict] = dict) -> typing.Mapping: - return typing.cast(dict, self.to_pandas().to_dict(into)) # type: ignore + def to_dict( + self, + into: type[dict] = dict, + *, + allow_large_results: Optional[bool] = None, + ) -> typing.Mapping: + return typing.cast(dict, self.to_pandas(allow_large_results=allow_large_results).to_dict(into=into)) # type: ignore - def to_excel(self, excel_writer, sheet_name="Sheet1", **kwargs) -> None: - return self.to_pandas().to_excel(excel_writer, sheet_name, **kwargs) + def to_excel( + self, excel_writer, sheet_name="Sheet1", *, allow_large_results=None, **kwargs + ) -> None: + return self.to_pandas(allow_large_results=allow_large_results).to_excel( + excel_writer, sheet_name, **kwargs + ) def to_json( self, @@ -1774,26 +2327,42 @@ def to_json( *, lines: bool = False, index: bool = True, + allow_large_results: Optional[bool] = None, ) -> Optional[str]: if utils.is_gcs_path(path_or_buf): return self.to_frame().to_json( - path_or_buf=path_or_buf, orient=orient, lines=lines, index=index + path_or_buf=path_or_buf, + orient=orient, + lines=lines, + index=index, + allow_large_results=allow_large_results, ) else: - pd_series = self.to_pandas() + pd_series = self.to_pandas(allow_large_results=allow_large_results) return pd_series.to_json( path_or_buf=path_or_buf, orient=orient, lines=lines, index=index # type: ignore ) def to_latex( - self, buf=None, columns=None, header=True, index=True, **kwargs + self, + buf=None, + columns=None, + header=True, + index=True, + *, + allow_large_results=None, + **kwargs, ) -> typing.Optional[str]: - return self.to_pandas().to_latex( + return self.to_pandas(allow_large_results=allow_large_results).to_latex( buf, columns=columns, header=header, index=index, **kwargs ) - def tolist(self) -> _list: - return self.to_pandas().to_list() + def tolist( + self, + *, + allow_large_results: Optional[bool] = None, + ) -> _list: + return self.to_pandas(allow_large_results=allow_large_results).to_list() to_list = tolist to_list.__doc__ = inspect.getdoc(vendored_pandas_series.Series.tolist) @@ -1803,22 +2372,36 @@ def to_markdown( buf: typing.IO[str] | None = None, mode: str = "wt", index: bool = True, + *, + allow_large_results: Optional[bool] = None, **kwargs, ) -> typing.Optional[str]: - return self.to_pandas().to_markdown(buf, mode=mode, index=index, **kwargs) # type: ignore + return self.to_pandas(allow_large_results=allow_large_results).to_markdown(buf, mode=mode, index=index, **kwargs) # type: ignore def to_numpy( - self, dtype=None, copy=False, na_value=None, **kwargs + self, + dtype=None, + copy=False, + na_value=pd_ext.no_default, + *, + allow_large_results=None, + **kwargs, ) -> numpy.ndarray: - return self.to_pandas().to_numpy(dtype, copy, na_value, **kwargs) + return self.to_pandas(allow_large_results=allow_large_results).to_numpy( + dtype, copy, na_value, **kwargs + ) - def __array__(self, dtype=None) -> numpy.ndarray: + def __array__(self, dtype=None, copy: Optional[bool] = None) -> numpy.ndarray: + if copy is False: + raise ValueError("Cannot convert to array without copy.") return self.to_numpy(dtype=dtype) __array__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__array__) - def to_pickle(self, path, **kwargs) -> None: - return self.to_pandas().to_pickle(path, **kwargs) + def to_pickle(self, path, *, allow_large_results=None, **kwargs) -> None: + return self.to_pandas(allow_large_results=allow_large_results).to_pickle( + path, **kwargs + ) def to_string( self, @@ -1832,8 +2415,10 @@ def to_string( name=False, max_rows=None, min_rows=None, + *, + allow_large_results=None, ) -> typing.Optional[str]: - return self.to_pandas().to_string( + return self.to_pandas(allow_large_results=allow_large_results).to_string( buf, na_rep, float_format, @@ -1846,8 +2431,12 @@ def to_string( min_rows, ) - def to_xarray(self): - return self.to_pandas().to_xarray() + def to_xarray( + self, + *, + allow_large_results: Optional[bool] = None, + ): + return self.to_pandas(allow_large_results=allow_large_results).to_xarray() def _throw_if_index_contains_duplicates( self, error_message: typing.Optional[str] = None @@ -1862,7 +2451,7 @@ def _throw_if_index_contains_duplicates( def map( self, - arg: typing.Union[Mapping, Series], + arg: typing.Union[Mapping, Series, Callable], na_action: Optional[str] = None, *, verify_integrity: bool = False, @@ -1884,6 +2473,7 @@ def map( ) map_df = map_df.set_index("keys") elif callable(arg): + # This is for remote function and managed funtion. return self.apply(arg) else: # Mirroring pandas, call the uncallable object @@ -1921,7 +2511,7 @@ def explode(self, *, ignore_index: Optional[bool] = False) -> Series: ) @validations.requires_ordering() - def _resample( + def resample( self, rule: str, *, @@ -1935,46 +2525,6 @@ def _resample( Literal["epoch", "start", "start_day", "end", "end_day"], ] = "start_day", ) -> bigframes.core.groupby.SeriesGroupBy: - """Internal function to support resample. Resample time-series data. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None - - >>> data = { - ... "timestamp_col": pd.date_range( - ... start="2021-01-01 13:00:00", periods=30, freq="1s" - ... ), - ... "int64_col": range(30), - ... } - >>> s = bpd.DataFrame(data).set_index("timestamp_col") - >>> s._resample(rule="7s", origin="epoch").min() - int64_col - 2021-01-01 12:59:56 0 - 2021-01-01 13:00:03 3 - 2021-01-01 13:00:10 10 - 2021-01-01 13:00:17 17 - 2021-01-01 13:00:24 24 - - [5 rows x 1 columns] - - - Args: - rule (str): - The offset string representing target conversion. - level (str or int, default None): - For a MultiIndex, level (name or number) to use for resampling. - level must be datetime-like. - origin(str, default 'start_day'): - The timestamp on which to adjust the grouping. Must be one of the following: - 'epoch': origin is 1970-01-01 - 'start': origin is the first value of the timeseries - 'start_day': origin is the first day at midnight of the timeseries - Returns: - SeriesGroupBy: SeriesGroupBy object. - """ block = self._block._generate_resample_label( rule=rule, closed=closed, @@ -2054,8 +2604,8 @@ def _slice( start: typing.Optional[int] = None, stop: typing.Optional[int] = None, step: typing.Optional[int] = None, - ) -> bigframes.series.Series: - return bigframes.series.Series( + ) -> Series: + return Series( self._block.slice( start=start, stop=stop, step=step if (step is not None) else 1 ).select_column(self._value_column), @@ -2081,7 +2631,183 @@ def _cached(self, *, force: bool = True, session_aware: bool = True) -> Series: # confusing type checker by overriding str @property def str(self) -> strings.StringMethods: - return strings.StringMethods(self._block) + import bigframes.operations.strings as strings + + return strings.StringMethods(self) + + @property + def _value_column(self) -> __builtins__.str: + return self._block.value_columns[0] + + @property + def _name(self) -> blocks.Label: + return self._block.column_labels[0] + + @property + def _dtype(self): + return self._block.dtypes[0] + + def _set_block(self, block: blocks.Block): + self._block = block + + def _get_block(self) -> blocks.Block: + return self._block + + def _apply_unary_op( + self, + op: ops.UnaryOp, + ) -> Series: + """Applies a unary operator to the series.""" + block, result_id = self._block.apply_unary_op( + self._value_column, + op, + ) + return Series(block.select_column(result_id), name=self.name) # type: ignore + + def _apply_binary_op( + self, + other: typing.Any, + op: ops.BinaryOp, + alignment: typing.Literal["outer", "left"] = "outer", + reverse: bool = False, + ) -> Series: + """Applies a binary operator to the series and other.""" + if bigframes.core.convert.can_convert_to_series(other): + self_index = indexes.Index(self._block) + other_series = bigframes.core.convert.to_bf_series( + other, self_index, self._block.session + ) + (self_col, other_col, block) = self._align(other_series, how=alignment) + + name = self._name + # Drop name if both objects have name attr, but they don't match + if ( + hasattr(other, "name") + and other_series.name != self._name + and alignment == "outer" + ): + name = None + expr = op.as_expr( + other_col if reverse else self_col, self_col if reverse else other_col + ) + block, result_id = block.project_expr(expr) + block = block.select_column(result_id).with_column_labels([name]) + return Series(block) # type: ignore + + else: # Scalar binop + name = self._name + expr = op.as_expr( + ex.const(other) if reverse else self._value_column, + self._value_column if reverse else ex.const(other), + ) + block, result_id = self._block.project_expr(expr) + block = block.select_column(result_id).with_column_labels([name]) + return Series(block) # type: ignore + + def _apply_nary_op( + self, + op: ops.NaryOp, + others: Sequence[typing.Union[Series, scalars.Scalar]], + ignore_self=False, + ): + """Applies an n-ary operator to the series and others.""" + values, block = self._align_n( + others, ignore_self=ignore_self, cast_scalars=False + ) + block, result_id = block.project_expr(op.as_expr(*values)) + return Series(block.select_column(result_id)) + + def _apply_binary_aggregation( + self, other: Series, stat: agg_ops.BinaryAggregateOp + ) -> float: + (left, right, block) = self._align(other, how="outer") + assert isinstance(left, ex.DerefOp) + assert isinstance(right, ex.DerefOp) + return block.get_binary_stat(left.id.name, right.id.name, stat) + + AlignedExprT = Union[ex.ScalarConstantExpression, ex.DerefOp] + + @typing.overload + def _align( + self, other: Series, how="outer" + ) -> tuple[ex.DerefOp, ex.DerefOp, blocks.Block,]: + ... + + @typing.overload + def _align( + self, other: typing.Union[Series, scalars.Scalar], how="outer" + ) -> tuple[ex.DerefOp, AlignedExprT, blocks.Block,]: + ... + + def _align( + self, other: typing.Union[Series, scalars.Scalar], how="outer" + ) -> tuple[ex.DerefOp, AlignedExprT, blocks.Block,]: + """Aligns the series value with another scalar or series object. Returns new left column id, right column id and joined tabled expression.""" + values, block = self._align_n( + [ + other, + ], + how, + ) + return (typing.cast(ex.DerefOp, values[0]), values[1], block) + + def _align3(self, other1: Series | scalars.Scalar, other2: Series | scalars.Scalar, how="left", cast_scalars: bool = True) -> tuple[ex.DerefOp, AlignedExprT, AlignedExprT, blocks.Block]: # type: ignore + """Aligns the series value with 2 other scalars or series objects. Returns new values and joined tabled expression.""" + values, index = self._align_n([other1, other2], how, cast_scalars=cast_scalars) + return ( + typing.cast(ex.DerefOp, values[0]), + values[1], + values[2], + index, + ) + + def _align_n( + self, + others: typing.Sequence[typing.Union[Series, scalars.Scalar]], + how="outer", + ignore_self=False, + cast_scalars: bool = False, + ) -> tuple[ + typing.Sequence[Union[ex.ScalarConstantExpression, ex.DerefOp]], + blocks.Block, + ]: + if ignore_self: + value_ids: List[Union[ex.ScalarConstantExpression, ex.DerefOp]] = [] + else: + value_ids = [ex.deref(self._value_column)] + + block = self._block + for other in others: + if isinstance(other, Series): + block, ( + get_column_left, + get_column_right, + ) = block.join(other._block, how=how) + rebindings = { + ids.ColumnId(old): ids.ColumnId(new) + for old, new in get_column_left.items() + } + remapped_value_ids = ( + value.remap_column_refs(rebindings) for value in value_ids + ) + value_ids = [ + *remapped_value_ids, # type: ignore + ex.deref(get_column_right[other._value_column]), + ] + else: + # Will throw if can't interpret as scalar. + dtype = typing.cast(bigframes.dtypes.Dtype, self._dtype) + value_ids = [ + *value_ids, + ex.const(other, dtype=dtype if cast_scalars else None), + ] + return (value_ids, block) + + def _throw_if_null_index(self, opname: __builtins__.str): + if len(self._block.index_columns) == 0: + raise bigframes.exceptions.NullIndexError( + f"Series cannot perform {opname} as it has no index. Set an index using set_index." + ) def _is_list_like(obj: typing.Any) -> typing_extensions.TypeGuard[typing.Sequence]: diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index c8c44be40b..4f32514652 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -16,9 +16,14 @@ from __future__ import annotations +from collections import abc +import datetime +import fnmatch +import inspect import logging import os import secrets +import threading import typing from typing import ( Any, @@ -29,6 +34,7 @@ Literal, MutableSequence, Optional, + overload, Sequence, Tuple, Union, @@ -37,13 +43,13 @@ import weakref import bigframes_vendored.constants as constants +import bigframes_vendored.google_cloud_bigquery.retry as third_party_gcb_retry import bigframes_vendored.ibis.backends.bigquery as ibis_bigquery # noqa import bigframes_vendored.pandas.io.gbq as third_party_pandas_gbq import bigframes_vendored.pandas.io.parquet as third_party_pandas_parquet import bigframes_vendored.pandas.io.parsers.readers as third_party_pandas_readers import bigframes_vendored.pandas.io.pickle as third_party_pandas_pickle import google.cloud.bigquery as bigquery -import google.cloud.storage as storage # type: ignore import numpy as np import pandas from pandas._typing import ( @@ -54,34 +60,28 @@ ) import pyarrow as pa +from bigframes import exceptions as bfe +from bigframes import version +import bigframes._config import bigframes._config.bigquery_options as bigquery_options import bigframes.clients -import bigframes.core.blocks as blocks -import bigframes.core.compile -import bigframes.core.guid -import bigframes.core.pruning - -# Even though the ibis.backends.bigquery import is unused, it's needed -# to register new and replacement ops with the Ibis BigQuery backend. -import bigframes.dataframe -import bigframes.dtypes -import bigframes.exceptions -import bigframes.exceptions as bfe +import bigframes.constants +import bigframes.core +from bigframes.core import blocks, log_adapter, utils +import bigframes.core.events +import bigframes.core.indexes +import bigframes.core.indexes.multi +import bigframes.core.pyformat +import bigframes.formatting_helpers import bigframes.functions._function_session as bff_session import bigframes.functions.function as bff +from bigframes.session import bigquery_session, bq_caching_executor, executor import bigframes.session._io.bigquery as bf_io_bigquery import bigframes.session.clients -import bigframes.session.executor -import bigframes.session.loader -import bigframes.session.metrics -import bigframes.session.planner -import bigframes.session.temp_storage import bigframes.session.validation -import bigframes.version # Avoid circular imports. if typing.TYPE_CHECKING: - import bigframes.core.indexes import bigframes.dataframe as dataframe import bigframes.series import bigframes.streaming.dataframe as streaming_dataframe @@ -107,23 +107,8 @@ logger = logging.getLogger(__name__) -# Excludes geography and nested (array, struct) datatypes -INLINABLE_DTYPES: Sequence[bigframes.dtypes.Dtype] = ( - pandas.BooleanDtype(), - pandas.Float64Dtype(), - pandas.Int64Dtype(), - pandas.StringDtype(storage="pyarrow"), - pandas.ArrowDtype(pa.binary()), - pandas.ArrowDtype(pa.date32()), - pandas.ArrowDtype(pa.time64("us")), - pandas.ArrowDtype(pa.timestamp("us")), - pandas.ArrowDtype(pa.timestamp("us", tz="UTC")), - pandas.ArrowDtype(pa.decimal128(38, 9)), - pandas.ArrowDtype(pa.decimal256(76, 38)), - pandas.ArrowDtype(pa.duration("us")), -) - +@log_adapter.class_logger class Session( third_party_pandas_gbq.GBQIOMixin, third_party_pandas_parquet.ParquetIOMixin, @@ -147,12 +132,25 @@ def __init__( context: Optional[bigquery_options.BigQueryOptions] = None, clients_provider: Optional[bigframes.session.clients.ClientsProvider] = None, ): + # Address circular imports in doctest due to bigframes/session/__init__.py + # containing a lot of logic and samples. + from bigframes.session import anonymous_dataset, clients, loader, metrics + + _warn_if_bf_version_is_obsolete() + + # Publisher needs to be created before the other objects, especially + # the executors, because they access it. + self._publisher = bigframes.core.events.Publisher() + self._publisher.subscribe(bigframes.formatting_helpers.progress_callback) + if context is None: context = bigquery_options.BigQueryOptions() if context.location is None: self._location = "US" - msg = f"No explicit location is set, so using location {self._location} for the session." + msg = bfe.format_message( + f"No explicit location is set, so using location {self._location} for the session." + ) # User's code # -> get_global_session() # -> connect() @@ -178,7 +176,7 @@ def __init__( if clients_provider: self._clients_provider = clients_provider else: - self._clients_provider = bigframes.session.clients.ClientsProvider( + self._clients_provider = clients.ClientsProvider( project=context.project, location=self._location, use_regional_endpoints=context.use_regional_endpoints, @@ -186,6 +184,7 @@ def __init__( application_name=context.application_name, bq_kms_key_name=self._bq_kms_key_name, client_endpoints_override=context.client_endpoints_override, + requests_transport_adapters=context.requests_transport_adapters, ) # TODO(shobs): Remove this logic after https://github.com/ibis-project/ibis/issues/8494 @@ -194,18 +193,6 @@ def __init__( # the ibis client has been created original_default_query_job_config = self.bqclient.default_query_job_config - # Only used to fetch remote function metadata. - # TODO: Remove in favor of raw bq client - - self.ibis_client = typing.cast( - ibis_bigquery.Backend, - ibis_bigquery.Backend().connect( - project_id=context.project, - client=self.bqclient, - storage_client=self.bqstoragereadclient, - ), - ) - self.bqclient.default_query_job_config = original_default_query_job_config # Resolve the BQ connection for remote function and Vertex AI integration @@ -222,6 +209,9 @@ def __init__( self._session_id: str = "session" + secrets.token_hex(3) # store table ids and delete them when the session is closed + self._api_methods: list[str] = [] + self._api_methods_lock = threading.Lock() + self._objects: list[ weakref.ReferenceType[ Union[ @@ -234,10 +224,6 @@ def __init__( # Whether this session treats objects as totally ordered. # Will expose as feature later, only False for internal testing self._strictly_ordered: bool = context.ordering_mode != "partial" - if not self._strictly_ordered: - msg = "Partial ordering mode is a preview feature and is subject to change." - warnings.warn(msg, bfe.OrderingModePartialPreviewWarning) - self._allow_ambiguity = not self._strictly_ordered self._default_index_type = ( bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64 @@ -245,33 +231,48 @@ def __init__( else bigframes.enums.DefaultIndexKind.NULL ) - self._metrics = bigframes.session.metrics.ExecutionMetrics() + self._metrics = metrics.ExecutionMetrics() self._function_session = bff_session.FunctionSession() - self._temp_storage_manager = ( - bigframes.session.temp_storage.TemporaryGbqStorageManager( - self._clients_provider.bqclient, - location=self._location, - session_id=self._session_id, - kms_key=self._bq_kms_key_name, - ) + self._anon_dataset_manager = anonymous_dataset.AnonymousDatasetManager( + self._clients_provider.bqclient, + location=self._location, + session_id=self._session_id, + kms_key=self._bq_kms_key_name, + publisher=self._publisher, ) - self._executor: bigframes.session.executor.Executor = ( - bigframes.session.executor.BigQueryCachingExecutor( - bqclient=self._clients_provider.bqclient, - bqstoragereadclient=self._clients_provider.bqstoragereadclient, - storage_manager=self._temp_storage_manager, - strictly_ordered=self._strictly_ordered, - metrics=self._metrics, + # Session temp tables don't support specifying kms key, so use anon dataset if kms key specified + self._session_resource_manager = ( + bigquery_session.SessionResourceManager( + self.bqclient, + self._location, + publisher=self._publisher, ) + if (self._bq_kms_key_name is None) + else None + ) + self._temp_storage_manager = ( + self._session_resource_manager or self._anon_dataset_manager ) - self._loader = bigframes.session.loader.GbqDataLoader( + self._loader = loader.GbqDataLoader( session=self, bqclient=self._clients_provider.bqclient, storage_manager=self._temp_storage_manager, + write_client=self._clients_provider.bqstoragewriteclient, default_index_type=self._default_index_type, scan_index_uniqueness=self._strictly_ordered, force_total_order=self._strictly_ordered, metrics=self._metrics, + publisher=self._publisher, + ) + self._executor: executor.Executor = bq_caching_executor.BigQueryCachingExecutor( + bqclient=self._clients_provider.bqclient, + bqstoragereadclient=self._clients_provider.bqstoragereadclient, + loader=self._loader, + storage_manager=self._temp_storage_manager, + strictly_ordered=self._strictly_ordered, + metrics=self._metrics, + enable_polars_execution=context.enable_polars_execution, + publisher=self._publisher, ) def __del__(self): @@ -324,6 +325,15 @@ def bqconnectionmanager(self): ) return self._bq_connection_manager + @property + def options(self) -> bigframes._config.Options: + """Options for configuring BigQuery DataFrames. + + Included for compatibility between bpd and Session. + """ + # TODO(tswast): Consider making a separate session-level options object. + return bigframes._config.options + @property def session_id(self): return self._session_id @@ -361,7 +371,7 @@ def _allows_ambiguity(self) -> bool: @property def _anonymous_dataset(self): - return self._temp_storage_manager.dataset + return self._anon_dataset_manager.dataset def __hash__(self): # Stable hash needed to use in expression tree @@ -374,16 +384,58 @@ def close(self): # Protect against failure when the Session is a fake for testing or # failed to initialize. - temp_storage_manager = getattr(self, "_temp_storage_manager", None) - if temp_storage_manager: - self._temp_storage_manager.clean_up_tables() + if anon_dataset_manager := getattr(self, "_anon_dataset_manager", None): + anon_dataset_manager.close() + + if session_resource_manager := getattr(self, "_session_resource_manager", None): + session_resource_manager.close() remote_function_session = getattr(self, "_function_session", None) if remote_function_session: - self._function_session.clean_up( + remote_function_session.clean_up( self.bqclient, self.cloudfunctionsclient, self.session_id ) + publisher_session = getattr(self, "_publisher", None) + if publisher_session: + publisher_session.publish( + bigframes.core.events.SessionClosed(self.session_id) + ) + + @overload + def read_gbq( # type: ignore[overload-overlap] + self, + query_or_table: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + use_cache: Optional[bool] = ..., + col_order: Iterable[str] = ..., + dry_run: Literal[False] = ..., + allow_large_results: Optional[bool] = ..., + ) -> dataframe.DataFrame: + ... + + @overload + def read_gbq( + self, + query_or_table: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + use_cache: Optional[bool] = ..., + col_order: Iterable[str] = ..., + dry_run: Literal[True] = ..., + allow_large_results: Optional[bool] = ..., + ) -> pandas.Series: + ... + def read_gbq( self, query_or_table: str, @@ -395,8 +447,9 @@ def read_gbq( filters: third_party_pandas_gbq.FiltersType = (), use_cache: Optional[bool] = None, col_order: Iterable[str] = (), - # Add a verify index argument that fails if the index is not unique. - ) -> dataframe.DataFrame: + dry_run: bool = False, + allow_large_results: Optional[bool] = None, + ) -> dataframe.DataFrame | pandas.Series: # TODO(b/281571214): Generate prompt to show the progress of read_gbq. if columns and col_order: raise ValueError( @@ -405,16 +458,20 @@ def read_gbq( elif col_order: columns = col_order + if allow_large_results is None: + allow_large_results = bigframes._config.options._allow_large_results + if bf_io_bigquery.is_query(query_or_table): - return self._loader.read_gbq_query( + return self._loader.read_gbq_query( # type: ignore # for dry_run overload query_or_table, index_col=index_col, columns=columns, configuration=configuration, max_results=max_results, - api_name="read_gbq", use_cache=use_cache, filters=filters, + dry_run=dry_run, + allow_large_results=allow_large_results, ) else: if configuration is not None: @@ -424,14 +481,14 @@ def read_gbq( "'configuration' or use a query." ) - return self._loader.read_gbq_table( + return self._loader.read_gbq_table( # type: ignore # for dry_run overload query_or_table, index_col=index_col, columns=columns, max_results=max_results, - api_name="read_gbq", use_cache=use_cache if use_cache is not None else True, filters=filters, + dry_run=dry_run, ) def _register_object( @@ -442,6 +499,105 @@ def _register_object( ): self._objects.append(weakref.ref(object)) + @overload + def _read_gbq_colab( + self, + query: str, + *, + pyformat_args: Optional[Dict[str, Any]] = None, + dry_run: Literal[False] = ..., + ) -> dataframe.DataFrame: + ... + + @overload + def _read_gbq_colab( + self, + query: str, + *, + pyformat_args: Optional[Dict[str, Any]] = None, + dry_run: Literal[True] = ..., + ) -> pandas.Series: + ... + + @log_adapter.log_name_override("read_gbq_colab") + def _read_gbq_colab( + self, + query: str, + # TODO: Add a callback parameter that takes some kind of Event object. + *, + pyformat_args: Optional[Dict[str, Any]] = None, + dry_run: bool = False, + ) -> Union[dataframe.DataFrame, pandas.Series]: + """A version of read_gbq that has the necessary default values for use in colab integrations. + + This includes, no ordering, no index, no progress bar, always use string + formatting for embedding local variables / dataframes. + + Args: + query (str): + A SQL query string to execute. Results (if any) are turned into + a DataFrame. + pyformat_args (dict): + A dictionary of potential variables to replace in ``query``. + Note: strings are _not_ escaped. Use query parameters for these, + instead. Note: unlike read_gbq / read_gbq_query, even if set to + None, this function always assumes {var} refers to a variable + that is supposed to be supplied in this dictionary. + """ + if pyformat_args is None: + pyformat_args = {} + + allow_large_results = bigframes._config.options._allow_large_results + + query = bigframes.core.pyformat.pyformat( + query, + pyformat_args=pyformat_args, + session=self, + dry_run=dry_run, + ) + + return self._loader.read_gbq_query( + query=query, + index_col=bigframes.enums.DefaultIndexKind.NULL, + force_total_order=False, + dry_run=typing.cast(Union[Literal[False], Literal[True]], dry_run), + allow_large_results=allow_large_results, + ) + + @overload + def read_gbq_query( # type: ignore[overload-overlap] + self, + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + use_cache: Optional[bool] = ..., + col_order: Iterable[str] = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + dry_run: Literal[False] = ..., + allow_large_results: Optional[bool] = ..., + ) -> dataframe.DataFrame: + ... + + @overload + def read_gbq_query( + self, + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + use_cache: Optional[bool] = ..., + col_order: Iterable[str] = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + dry_run: Literal[True] = ..., + allow_large_results: Optional[bool] = ..., + ) -> pandas.Series: + ... + def read_gbq_query( self, query: str, @@ -453,7 +609,9 @@ def read_gbq_query( use_cache: Optional[bool] = None, col_order: Iterable[str] = (), filters: third_party_pandas_gbq.FiltersType = (), - ) -> dataframe.DataFrame: + dry_run: bool = False, + allow_large_results: Optional[bool] = None, + ) -> dataframe.DataFrame | pandas.Series: """Turn a SQL query into a DataFrame. Note: Because the results are written to a temporary table, ordering by @@ -463,11 +621,9 @@ def read_gbq_query( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Simple query input: + >>> import bigframes.pandas as bpd >>> df = bpd.read_gbq_query(''' ... SELECT ... pitcherFirstName, @@ -502,9 +658,48 @@ def read_gbq_query( See also: :meth:`Session.read_gbq`. + Args: + query (str): + A SQL query to execute. + index_col (Iterable[str] or str, optional): + The column(s) to use as the index for the DataFrame. This can be + a single column name or a list of column names. If not provided, + a default index will be used. + columns (Iterable[str], optional): + The columns to read from the query result. If not + specified, all columns will be read. + configuration (dict, optional): + A dictionary of query job configuration options. See the + BigQuery REST API documentation for a list of available options: + https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.query + max_results (int, optional): + The maximum number of rows to retrieve from the query + result. If not specified, all rows will be loaded. + use_cache (bool, optional): + Whether to use cached results for the query. Defaults to ``True``. + Setting this to ``False`` will force a re-execution of the query. + col_order (Iterable[str], optional): + The desired order of columns in the resulting DataFrame. This + parameter is deprecated and will be removed in a future version. + Use ``columns`` instead. + filters (list[tuple], optional): + A list of filters to apply to the data. Filters are specified + as a list of tuples, where each tuple contains a column name, + an operator (e.g., '==', '!='), and a value. + dry_run (bool, optional): + If ``True``, the function will not actually execute the query but + will instead return statistics about the query. Defaults to + ``False``. + allow_large_results (bool, optional): + Whether to allow large query results. If ``True``, the query + results can be larger than the maximum response size. + Defaults to ``bpd.options.compute.allow_large_results``. + Returns: - bigframes.pandas.DataFrame: - A DataFrame representing results of the query or table. + bigframes.pandas.DataFrame or pandas.Series: + A DataFrame representing the result of the query. If ``dry_run`` + is ``True``, a ``pandas.Series`` containing query statistics is + returned. Raises: ValueError: @@ -519,17 +714,51 @@ def read_gbq_query( elif col_order: columns = col_order - return self._loader.read_gbq_query( + if allow_large_results is None: + allow_large_results = bigframes._config.options._allow_large_results + + return self._loader.read_gbq_query( # type: ignore # for dry_run overload query=query, index_col=index_col, columns=columns, configuration=configuration, max_results=max_results, - api_name="read_gbq_query", use_cache=use_cache, filters=filters, + dry_run=dry_run, + allow_large_results=allow_large_results, ) + @overload + def read_gbq_table( # type: ignore[overload-overlap] + self, + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + max_results: Optional[int] = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + use_cache: bool = ..., + col_order: Iterable[str] = ..., + dry_run: Literal[False] = ..., + ) -> dataframe.DataFrame: + ... + + @overload + def read_gbq_table( + self, + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + max_results: Optional[int] = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + use_cache: bool = ..., + col_order: Iterable[str] = ..., + dry_run: Literal[True] = ..., + ) -> pandas.Series: + ... + def read_gbq_table( self, query: str, @@ -540,23 +769,53 @@ def read_gbq_table( filters: third_party_pandas_gbq.FiltersType = (), use_cache: bool = True, col_order: Iterable[str] = (), - ) -> dataframe.DataFrame: + dry_run: bool = False, + ) -> dataframe.DataFrame | pandas.Series: """Turn a BigQuery table into a DataFrame. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Read a whole table, with arbitrary ordering or ordering corresponding to the primary key(s). + >>> import bigframes.pandas as bpd >>> df = bpd.read_gbq_table("bigquery-public-data.ml_datasets.penguins") See also: :meth:`Session.read_gbq`. + Args: + table_id (str): + The identifier of the BigQuery table to read. + index_col (Iterable[str] or str, optional): + The column(s) to use as the index for the DataFrame. This can be + a single column name or a list of column names. If not provided, + a default index will be used. + columns (Iterable[str], optional): + The columns to read from the table. If not specified, all + columns will be read. + max_results (int, optional): + The maximum number of rows to retrieve from the table. If not + specified, all rows will be loaded. + filters (list[tuple], optional): + A list of filters to apply to the data. Filters are specified + as a list of tuples, where each tuple contains a column name, + an operator (e.g., '==', '!='), and a value. + use_cache (bool, optional): + Whether to use cached results for the query. Defaults to ``True``. + Setting this to ``False`` will force a re-execution of the query. + col_order (Iterable[str], optional): + The desired order of columns in the resulting DataFrame. This + parameter is deprecated and will be removed in a future version. + Use ``columns`` instead. + dry_run (bool, optional): + If ``True``, the function will not actually execute the query but + will instead return statistics about the table. Defaults to + ``False``. + Returns: - bigframes.pandas.DataFrame: - A DataFrame representing results of the query or table. + bigframes.pandas.DataFrame or pandas.Series: + A DataFrame representing the contents of the table. If + ``dry_run`` is ``True``, a ``pandas.Series`` containing table + statistics is returned. Raises: ValueError: @@ -571,14 +830,14 @@ def read_gbq_table( elif col_order: columns = col_order - return self._loader.read_gbq_table( - query=query, + return self._loader.read_gbq_table( # type: ignore # for dry_run overload + table_id=query, index_col=index_col, columns=columns, max_results=max_results, - api_name="read_gbq_table", use_cache=use_cache, filters=filters, + dry_run=dry_run, ) def read_gbq_table_streaming( @@ -593,8 +852,6 @@ def read_gbq_table_streaming( **Examples:** >>> import bigframes.streaming as bst - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> sdf = bst.read_gbq_table("bigquery-public-data.ml_datasets.penguins") @@ -602,14 +859,15 @@ def read_gbq_table_streaming( bigframes.streaming.dataframe.StreamingDataFrame: A StreamingDataFrame representing results of the table. """ - msg = "The bigframes.streaming module is a preview feature, and subject to change." + msg = bfe.format_message( + "The bigframes.streaming module is a preview feature, and subject to change." + ) warnings.warn(msg, stacklevel=1, category=bfe.PreviewWarning) import bigframes.streaming.dataframe as streaming_dataframe df = self._loader.read_gbq_table( table, - api_name="read_gbq_table_steaming", enable_snapshot=False, index_col=bigframes.enums.DefaultIndexKind.NULL, ) @@ -621,11 +879,9 @@ def read_gbq_model(self, model_name: str): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Read an existing BigQuery ML model. + >>> import bigframes.pandas as bpd >>> model_name = "bigframes-dev.bqml_tutorial.penguins_model" >>> model = bpd.read_gbq_model(model_name) @@ -691,9 +947,6 @@ def read_pandas( **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> d = {'col1': [1, 2], 'col2': [3, 4]} >>> pandas_df = pd.DataFrame(data=d) @@ -726,7 +979,9 @@ def read_pandas( workload is such that you exhaust the BigQuery load job quota and your data cannot be embedded in SQL due to size or data type limitations. - + * "bigquery_write": + [Preview] Use the BigQuery Storage Write API. This feature + is in public preview. Returns: An equivalent bigframes.pandas.(DataFrame/Series/Index) object @@ -740,7 +995,6 @@ def read_pandas( if isinstance(pandas_dataframe, pandas.Series): bf_df = self._read_pandas( pandas.DataFrame(pandas_dataframe), - "read_pandas", write_engine=write_engine, ) bf_series = series.Series(bf_df._block) @@ -750,13 +1004,10 @@ def read_pandas( if isinstance(pandas_dataframe, pandas.Index): return self._read_pandas( pandas.DataFrame(index=pandas_dataframe), - "read_pandas", write_engine=write_engine, ).index if isinstance(pandas_dataframe, pandas.DataFrame): - return self._read_pandas( - pandas_dataframe, "read_pandas", write_engine=write_engine - ) + return self._read_pandas(pandas_dataframe, write_engine=write_engine) else: raise ValueError( f"read_pandas() expects a pandas.DataFrame, but got a {type(pandas_dataframe)}" @@ -765,7 +1016,6 @@ def read_pandas( def _read_pandas( self, pandas_dataframe: pandas.DataFrame, - api_name: str, *, write_engine: constants.WriteEngineType = "default", ) -> dataframe.DataFrame: @@ -777,79 +1027,57 @@ def _read_pandas( "bigframes.pandas.DataFrame." ) + mem_usage = pandas_dataframe.memory_usage(deep=True).sum() if write_engine == "default": - inline_df = self._read_pandas_inline(pandas_dataframe, should_raise=False) - if inline_df is not None: - return inline_df - return self._read_pandas_load_job(pandas_dataframe, api_name) - elif write_engine == "bigquery_inline": - # Regarding the type: ignore, with should_raise=True, this should never return None. - return self._read_pandas_inline(pandas_dataframe, should_raise=True) # type: ignore + write_engine = ( + "bigquery_load" + if mem_usage > bigframes.constants.MAX_INLINE_BYTES + else "bigquery_inline" + ) + + if write_engine == "bigquery_inline": + if mem_usage > bigframes.constants.MAX_INLINE_BYTES: + raise ValueError( + f"DataFrame size ({mem_usage} bytes) exceeds the maximum allowed " + f"for inline data ({bigframes.constants.MAX_INLINE_BYTES} bytes)." + ) + return self._read_pandas_inline(pandas_dataframe) elif write_engine == "bigquery_load": - return self._read_pandas_load_job(pandas_dataframe, api_name) + return self._loader.read_pandas(pandas_dataframe, method="load") elif write_engine == "bigquery_streaming": - return self._read_pandas_streaming(pandas_dataframe) + return self._loader.read_pandas(pandas_dataframe, method="stream") + elif write_engine == "bigquery_write": + return self._loader.read_pandas(pandas_dataframe, method="write") + elif write_engine == "_deferred": + import bigframes.dataframe as dataframe + + return dataframe.DataFrame(blocks.Block.from_local(pandas_dataframe, self)) else: raise ValueError(f"Got unexpected write_engine '{write_engine}'") def _read_pandas_inline( - self, pandas_dataframe: pandas.DataFrame, should_raise=False - ) -> Optional[dataframe.DataFrame]: + self, pandas_dataframe: pandas.DataFrame + ) -> dataframe.DataFrame: import bigframes.dataframe as dataframe - if pandas_dataframe.memory_usage(deep=True).sum() > MAX_INLINE_DF_BYTES: - return None - - try: - local_block = blocks.Block.from_local(pandas_dataframe, self) - inline_df = dataframe.DataFrame(local_block) - except ( - pa.ArrowInvalid, # Thrown by arrow for unsupported types, such as geo. - pa.ArrowTypeError, # Thrown by arrow for types without mapping (geo). - ValueError, # Thrown by ibis for some unhandled types - TypeError, # Not all types handleable by local code path - ) as exc: - if should_raise: - raise ValueError( - f"Could not convert with a BigQuery type: `{exc}`. " - ) from exc - else: - return None + local_block = blocks.Block.from_local(pandas_dataframe, self) + return dataframe.DataFrame(local_block) - inline_types = inline_df._block.expr.schema.dtypes - - # Make sure all types are inlinable to avoid escaping errors. - noninlinable_types = [ - dtype for dtype in inline_types if dtype not in INLINABLE_DTYPES - ] - if len(noninlinable_types) == 0: - return inline_df + def read_arrow(self, pa_table: pa.Table) -> bigframes.dataframe.DataFrame: + """Load a PyArrow Table to a BigQuery DataFrames DataFrame. - if should_raise: - raise ValueError( - f"Could not inline with a BigQuery type: `{noninlinable_types}`. " - f"{constants.FEEDBACK_LINK}" - ) - else: - return None + Args: + pa_table (pyarrow.Table): + PyArrow table to load data from. - def _read_pandas_load_job( - self, - pandas_dataframe: pandas.DataFrame, - api_name: str, - ) -> dataframe.DataFrame: - try: - return self._loader.read_pandas_load_job(pandas_dataframe, api_name) - except (pa.ArrowInvalid, pa.ArrowTypeError) as exc: - raise ValueError( - f"Could not convert with a BigQuery type: `{exc}`." - ) from exc + Returns: + bigframes.dataframe.DataFrame: + A new DataFrame representing the data from the PyArrow table. + """ + import bigframes.dataframe as dataframe - def _read_pandas_streaming( - self, - pandas_dataframe: pandas.DataFrame, - ) -> dataframe.DataFrame: - return self._loader.read_pandas_streaming(pandas_dataframe) + local_block = blocks.Block.from_pyarrow(pa_table, self) + return dataframe.DataFrame(local_block) def read_csv( self, @@ -892,105 +1120,25 @@ def read_csv( engine=engine, write_engine=write_engine, ) - table = self._temp_storage_manager._random_table() - if engine is not None and engine == "bigquery": - if any(param is not None for param in (dtype, names)): - not_supported = ("dtype", "names") - raise NotImplementedError( - f"BigQuery engine does not support these arguments: {not_supported}. " - f"{constants.FEEDBACK_LINK}" - ) - - # TODO(b/338089659): Looks like we can relax this 1 column - # restriction if we check the contents of an iterable are strings - # not integers. - if ( - # Empty tuples, None, and False are allowed and falsey. - index_col - and not isinstance(index_col, bigframes.enums.DefaultIndexKind) - and not isinstance(index_col, str) - ): - raise NotImplementedError( - "BigQuery engine only supports a single column name for `index_col`, " - f"got: {repr(index_col)}. {constants.FEEDBACK_LINK}" - ) - - # None and False cannot be passed to read_gbq. - # TODO(b/338400133): When index_col is None, we should be using the - # first column of the CSV as the index to be compatible with the - # pandas engine. According to the pandas docs, only "False" - # indicates a default sequential index. - if not index_col: - index_col = () - - index_col = typing.cast( - Union[ - Sequence[str], # Falsey values - bigframes.enums.DefaultIndexKind, - str, - ], - index_col, - ) - - # usecols should only be an iterable of strings (column names) for use as columns in read_gbq. - columns: Tuple[Any, ...] = tuple() - if usecols is not None: - if isinstance(usecols, Iterable) and all( - isinstance(col, str) for col in usecols - ): - columns = tuple(col for col in usecols) - else: - raise NotImplementedError( - "BigQuery engine only supports an iterable of strings for `usecols`. " - f"{constants.FEEDBACK_LINK}" - ) - - if encoding is not None and encoding not in _VALID_ENCODINGS: - raise NotImplementedError( - f"BigQuery engine only supports the following encodings: {_VALID_ENCODINGS}. " - f"{constants.FEEDBACK_LINK}" - ) - - job_config = bigquery.LoadJobConfig() - job_config.create_disposition = bigquery.CreateDisposition.CREATE_IF_NEEDED - job_config.source_format = bigquery.SourceFormat.CSV - job_config.write_disposition = bigquery.WriteDisposition.WRITE_EMPTY - job_config.autodetect = True - job_config.field_delimiter = sep - job_config.encoding = encoding - job_config.labels = {"bigframes-api": "read_csv"} - - # We want to match pandas behavior. If header is 0, no rows should be skipped, so we - # do not need to set `skip_leading_rows`. If header is None, then there is no header. - # Setting skip_leading_rows to 0 does that. If header=N and N>0, we want to skip N rows. - if header is None: - job_config.skip_leading_rows = 0 - elif header > 0: - job_config.skip_leading_rows = header - - return self._loader._read_bigquery_load_job( + if engine != "bigquery": + # Using pandas.read_csv by default and warning about potential issues with + # large files. + return self._read_csv_w_pandas_engines( filepath_or_buffer, - table, - job_config=job_config, + sep=sep, + header=header, + names=names, index_col=index_col, - columns=columns, + usecols=usecols, # type: ignore + dtype=dtype, + engine=engine, + encoding=encoding, + write_engine=write_engine, + **kwargs, ) else: - if isinstance(index_col, bigframes.enums.DefaultIndexKind): - raise NotImplementedError( - f"With index_col={repr(index_col)}, only engine='bigquery' is supported. " - f"{constants.FEEDBACK_LINK}" - ) - if any(arg in kwargs for arg in ("chunksize", "iterator")): - raise NotImplementedError( - "'chunksize' and 'iterator' arguments are not supported. " - f"{constants.FEEDBACK_LINK}" - ) - - if isinstance(filepath_or_buffer, str): - self._check_file_size(filepath_or_buffer) - pandas_df = pandas.read_csv( + return self._read_csv_w_bigquery_engine( filepath_or_buffer, sep=sep, header=header, @@ -998,11 +1146,145 @@ def read_csv( index_col=index_col, usecols=usecols, # type: ignore dtype=dtype, - engine=engine, encoding=encoding, - **kwargs, ) - return self._read_pandas(pandas_df, api_name="read_csv", write_engine=write_engine) # type: ignore + + def _read_csv_w_pandas_engines( + self, + filepath_or_buffer, + *, + sep, + header, + names, + index_col, + usecols, + dtype, + engine, + encoding, + write_engine, + **kwargs, + ) -> dataframe.DataFrame: + """Reads a CSV file using pandas engines into a BigQuery DataFrames. + + This method serves as the implementation backend for read_csv when the + specified engine is one supported directly by pandas ('c', 'python', + 'pyarrow'). + """ + if isinstance(index_col, bigframes.enums.DefaultIndexKind): + raise NotImplementedError( + f"With index_col={repr(index_col)}, only engine='bigquery' is supported. " + f"{constants.FEEDBACK_LINK}" + ) + if any(arg in kwargs for arg in ("chunksize", "iterator")): + raise NotImplementedError( + "'chunksize' and 'iterator' arguments are not supported. " + f"{constants.FEEDBACK_LINK}" + ) + if isinstance(filepath_or_buffer, str): + self._check_file_size(filepath_or_buffer) + + pandas_df = pandas.read_csv( + filepath_or_buffer, + sep=sep, + header=header, + names=names, + index_col=index_col, + usecols=usecols, # type: ignore + dtype=dtype, + engine=engine, + encoding=encoding, + **kwargs, + ) + return self._read_pandas(pandas_df, write_engine=write_engine) # type: ignore + + def _read_csv_w_bigquery_engine( + self, + filepath_or_buffer, + *, + sep, + header, + names, + index_col, + usecols, + dtype, + encoding, + ) -> dataframe.DataFrame: + """Reads a CSV file using the BigQuery engine into a BigQuery DataFrames. + + This method serves as the implementation backend for read_csv when the + 'bigquery' engine is specified or inferred. It leverages BigQuery's + native CSV loading capabilities, making it suitable for large datasets + that may not fit into local memory. + """ + if dtype is not None and not utils.is_dict_like(dtype): + raise ValueError("dtype should be a dict-like object.") + + if names is not None: + if len(names) != len(set(names)): + raise ValueError("Duplicated names are not allowed.") + if not ( + bigframes.core.utils.is_list_like(names, allow_sets=False) + or isinstance(names, abc.KeysView) + ): + raise ValueError("Names should be an ordered collection.") + + if index_col is True: + raise ValueError("The value of index_col couldn't be 'True'") + + # None and False cannot be passed to read_gbq. + if index_col is None or index_col is False: + index_col = () + + # usecols should only be an iterable of strings (column names) for use as columns in read_gbq. + columns: Tuple[Any, ...] = tuple() + if usecols is not None: + if isinstance(usecols, Iterable) and all( + isinstance(col, str) for col in usecols + ): + columns = tuple(col for col in usecols) + else: + raise NotImplementedError( + "BigQuery engine only supports an iterable of strings for `usecols`. " + f"{constants.FEEDBACK_LINK}" + ) + + if encoding is not None and encoding not in _VALID_ENCODINGS: + raise NotImplementedError( + f"BigQuery engine only supports the following encodings: {_VALID_ENCODINGS}. " + f"{constants.FEEDBACK_LINK}" + ) + + job_config = bigquery.LoadJobConfig() + job_config.source_format = bigquery.SourceFormat.CSV + job_config.autodetect = True + job_config.field_delimiter = sep + job_config.encoding = encoding + job_config.labels = {"bigframes-api": "read_csv"} + + # b/409070192: When header > 0, pandas and BigFrames returns different column naming. + + # We want to match pandas behavior. If header is 0, no rows should be skipped, so we + # do not need to set `skip_leading_rows`. If header is None, then there is no header. + # Setting skip_leading_rows to 0 does that. If header=N and N>0, we want to skip N rows. + if header is None: + job_config.skip_leading_rows = 0 + elif header > 0: + job_config.skip_leading_rows = header + 1 + + table_id = self._loader.load_file(filepath_or_buffer, job_config=job_config) + df = self._loader.read_gbq_table( + table_id, + index_col=index_col, + columns=columns, + names=names, + index_col_in_columns=True, + ) + + if dtype is not None: + for column, dtype in dtype.items(): + if column in df.columns: + df[column] = df[column].astype(dtype) + return df def read_pickle( self, @@ -1021,11 +1303,9 @@ def read_pickle( if isinstance(pandas_obj, pandas.Series): if pandas_obj.name is None: pandas_obj.name = 0 - bigframes_df = self._read_pandas(pandas_obj.to_frame(), "read_pickle") + bigframes_df = self._read_pandas(pandas_obj.to_frame()) return bigframes_df[bigframes_df.columns[0]] - return self._read_pandas( - pandas_obj, api_name="read_pickle", write_engine=write_engine - ) + return self._read_pandas(pandas_obj, write_engine=write_engine) def read_parquet( self, @@ -1038,18 +1318,19 @@ def read_parquet( engine=engine, write_engine=write_engine, ) - table = self._temp_storage_manager._random_table() - if engine == "bigquery": job_config = bigquery.LoadJobConfig() - job_config.create_disposition = bigquery.CreateDisposition.CREATE_IF_NEEDED job_config.source_format = bigquery.SourceFormat.PARQUET - job_config.write_disposition = bigquery.WriteDisposition.WRITE_EMPTY - job_config.labels = {"bigframes-api": "read_parquet"} - return self._loader._read_bigquery_load_job( - path, table, job_config=job_config - ) + # Ensure we can load pyarrow.list_ / BQ ARRAY type. + # See internal issue 414374215. + parquet_options = bigquery.ParquetOptions() + parquet_options.enable_list_inference = True + job_config.parquet_options = parquet_options + + job_config.labels = {"bigframes-api": "read_parquet"} + table_id = self._loader.load_file(path, job_config=job_config) + return self._loader.read_gbq_table(table_id) else: if "*" in path: raise ValueError( @@ -1070,9 +1351,7 @@ def read_parquet( engine=engine, # type: ignore **read_parquet_kwargs, ) - return self._read_pandas( - pandas_obj, api_name="read_parquet", write_engine=write_engine - ) + return self._read_pandas(pandas_obj, write_engine=write_engine) def read_json( self, @@ -1092,8 +1371,6 @@ def read_json( engine=engine, write_engine=write_engine, ) - table = self._temp_storage_manager._random_table() - if engine == "bigquery": if dtype is not None: @@ -1117,18 +1394,13 @@ def read_json( ) job_config = bigquery.LoadJobConfig() - job_config.create_disposition = bigquery.CreateDisposition.CREATE_IF_NEEDED job_config.source_format = bigquery.SourceFormat.NEWLINE_DELIMITED_JSON - job_config.write_disposition = bigquery.WriteDisposition.WRITE_EMPTY job_config.autodetect = True job_config.encoding = encoding job_config.labels = {"bigframes-api": "read_json"} - return self._loader._read_bigquery_load_job( - path_or_buf, - table, - job_config=job_config, - ) + table_id = self._loader.load_file(path_or_buf, job_config=job_config) + return self._loader.read_gbq_table(table_id) else: if any(arg in kwargs for arg in ("chunksize", "iterator")): raise NotImplementedError( @@ -1158,19 +1430,29 @@ def read_json( engine=engine, **kwargs, ) - return self._read_pandas( - pandas_df, api_name="read_json", write_engine=write_engine - ) + return self._read_pandas(pandas_df, write_engine=write_engine) def _check_file_size(self, filepath: str): max_size = 1024 * 1024 * 1024 # 1 GB in bytes if filepath.startswith("gs://"): # GCS file path - client = storage.Client() - bucket_name, blob_name = filepath.split("/", 3)[2:] + bucket_name, blob_path = filepath.split("/", 3)[2:] + + client = self._clients_provider.storageclient bucket = client.bucket(bucket_name) - blob = bucket.blob(blob_name) - blob.reload() - file_size = blob.size + + list_blobs_params = inspect.signature(bucket.list_blobs).parameters + if "match_glob" in list_blobs_params: + # Modern, efficient method for new library versions + matching_blobs = bucket.list_blobs(match_glob=blob_path) + file_size = sum(blob.size for blob in matching_blobs) + else: + # Fallback method for older library versions + prefix = blob_path.split("*", 1)[0] + all_blobs = bucket.list_blobs(prefix=prefix) + matching_blobs = [ + blob for blob in all_blobs if fnmatch.fnmatch(blob.name, blob_path) + ] + file_size = sum(blob.size for blob in matching_blobs) elif os.path.exists(filepath): # local file path file_size = os.path.getsize(filepath) else: @@ -1186,26 +1468,69 @@ def _check_file_size(self, filepath: str): "for large files to avoid loading the file into local memory." ) + def deploy_remote_function( + self, + func, + **kwargs, + ): + """Orchestrates the creation of a BigQuery remote function that deploys immediately. + + This method ensures that the remote function is created and available for + use in BigQuery as soon as this call is made. + + Args: + func: + Function to deploy. + kwargs: + All arguments are passed directly to + :meth:`~bigframes.session.Session.remote_function`. Please see + its docstring for parameter details. + + Returns: + A wrapped remote function, usable in + :meth:`~bigframes.series.Series.apply`. + """ + return self._function_session.deploy_remote_function( + func, + # Session-provided arguments. + session=self, + bigquery_client=self._clients_provider.bqclient, + bigquery_connection_client=self._clients_provider.bqconnectionclient, + cloud_functions_client=self._clients_provider.cloudfunctionsclient, + resource_manager_client=self._clients_provider.resourcemanagerclient, + # User-provided arguments. + **kwargs, + ) + def remote_function( self, + # Make sure that the input/output types, and dataset can be used + # positionally. This avoids the worst of the breaking change from 1.x to + # 2.x while still preventing possible mixups between consecutive str + # parameters. input_types: Union[None, type, Sequence[type]] = None, output_type: Optional[type] = None, dataset: Optional[str] = None, + *, bigquery_connection: Optional[str] = None, reuse: bool = True, name: Optional[str] = None, packages: Optional[Sequence[str]] = None, - cloud_function_service_account: Optional[str] = None, + cloud_function_service_account: str, cloud_function_kms_key_name: Optional[str] = None, cloud_function_docker_repository: Optional[str] = None, max_batching_rows: Optional[int] = 1000, cloud_function_timeout: Optional[int] = 600, cloud_function_max_instances: Optional[int] = None, cloud_function_vpc_connector: Optional[str] = None, + cloud_function_vpc_connector_egress_settings: Optional[ + Literal["all", "private-ranges-only", "unspecified"] + ] = None, cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" - ] = "all", + ] = "internal-only", + cloud_build_service_account: Optional[str] = None, ): """Decorator to turn a user defined function into a BigQuery remote function. Check out the code samples at: https://cloud.google.com/bigquery/docs/remote-functions#bigquery-dataframes. @@ -1215,6 +1540,14 @@ def remote_function( supports dataframe with column types ``Int64``/``Float64``/``boolean``/ ``string``/``binary[pyarrow]``. + .. warning:: + To use remote functions with Bigframes 2.0 and onwards, please (preferred) + set an explicit user-managed ``cloud_function_service_account`` or (discouraged) + set ``cloud_function_service_account`` to use the Compute Engine service account + by setting it to `"default"`. + + See, https://cloud.google.com/functions/docs/securing/function-identity. + .. note:: Please make sure following is setup before using this API: @@ -1305,8 +1638,8 @@ def remote_function( Explicit name of the external package dependencies. Each dependency is added to the `requirements.txt` as is, and can be of the form supported in https://pip.pypa.io/en/stable/reference/requirements-file-format/. - cloud_function_service_account (str, Optional): - Service account to use for the cloud functions. If not provided + cloud_function_service_account (str): + Service account to use for the cloud functions. If "default" provided then the default service account would be used. See https://cloud.google.com/functions/docs/securing/function-identity for more details. Please make sure the service account has the @@ -1358,6 +1691,13 @@ def remote_function( function. This is useful if your code needs access to data or service(s) that are on a VPC network. See for more details https://cloud.google.com/functions/docs/networking/connecting-vpc. + cloud_function_vpc_connector_egress_settings (str, Optional): + Egress settings for the VPC connector, controlling what outbound + traffic is routed through the VPC connector. + Options are: `all`, `private-ranges-only`, or `unspecified`. + If not specified, `private-ranges-only` is used by default. + See for more details + https://cloud.google.com/run/docs/configuring/vpc-connectors#egress-job. cloud_function_memory_mib (int, Optional): The amounts of memory (in mebibytes) to allocate for the cloud function (2nd gen) created. This also dictates a corresponding @@ -1369,9 +1709,20 @@ def remote_function( https://cloud.google.com/functions/docs/configuring/memory. cloud_function_ingress_settings (str, Optional): Ingress settings controls dictating what traffic can reach the - function. By default `all` will be used. It must be one of: - `all`, `internal-only`, `internal-and-gclb`. See for more details + function. Options are: `all`, `internal-only`, or `internal-and-gclb`. + If no setting is provided, `internal-only` will be used by default. + See for more details https://cloud.google.com/functions/docs/networking/network-settings#ingress_settings. + cloud_build_service_account (str, Optional): + Service account in the fully qualified format + `projects/PROJECT_ID/serviceAccounts/SERVICE_ACCOUNT_EMAIL`, or + just the SERVICE_ACCOUNT_EMAIL. The latter would be interpreted + as belonging to the BigQuery DataFrames session project. This is + to be used by Cloud Build to build the function source code into + a deployable artifact. If not provided, the default Cloud Build + service account is used. See + https://cloud.google.com/build/docs/cloud-build-service-account + for more details. Returns: collections.abc.Callable: A remote function object pointing to the cloud assets created @@ -1383,9 +1734,15 @@ def remote_function( `bigframes_remote_function` - The bigquery remote function capable of calling into `bigframes_cloud_function`. """ return self._function_session.remote_function( - input_types, - output_type, + # Session-provided arguments. session=self, + bigquery_client=self._clients_provider.bqclient, + bigquery_connection_client=self._clients_provider.bqconnectionclient, + cloud_functions_client=self._clients_provider.cloudfunctionsclient, + resource_manager_client=self._clients_provider.resourcemanagerclient, + # User-provided arguments. + input_types=input_types, + output_type=output_type, dataset=dataset, bigquery_connection=bigquery_connection, reuse=reuse, @@ -1398,8 +1755,213 @@ def remote_function( cloud_function_timeout=cloud_function_timeout, cloud_function_max_instances=cloud_function_max_instances, cloud_function_vpc_connector=cloud_function_vpc_connector, + cloud_function_vpc_connector_egress_settings=cloud_function_vpc_connector_egress_settings, cloud_function_memory_mib=cloud_function_memory_mib, cloud_function_ingress_settings=cloud_function_ingress_settings, + cloud_build_service_account=cloud_build_service_account, + ) + + def deploy_udf( + self, + func, + **kwargs, + ): + """Orchestrates the creation of a BigQuery UDF that deploys immediately. + + This method ensures that the UDF is created and available for + use in BigQuery as soon as this call is made. + + Args: + func: + Function to deploy. + kwargs: + All arguments are passed directly to + :meth:`~bigframes.session.Session.udf`. Please see + its docstring for parameter details. + + Returns: + A wrapped Python user defined function, usable in + :meth:`~bigframes.series.Series.apply`. + """ + return self._function_session.deploy_udf( + func, + # Session-provided arguments. + session=self, + bigquery_client=self._clients_provider.bqclient, + # User-provided arguments. + **kwargs, + ) + + def udf( + self, + *, + input_types: Union[None, type, Sequence[type]] = None, + output_type: Optional[type] = None, + dataset: str, + bigquery_connection: Optional[str] = None, + name: str, + packages: Optional[Sequence[str]] = None, + max_batching_rows: Optional[int] = None, + container_cpu: Optional[float] = None, + container_memory: Optional[str] = None, + ): + """Decorator to turn a Python user defined function (udf) into a + [BigQuery managed user-defined function](https://cloud.google.com/bigquery/docs/user-defined-functions-python). + + .. note:: + This feature is in preview. The code in the udf must be + (1) self-contained, i.e. it must not contain any + references to an import or variable defined outside the function + body, and + (2) Python 3.11 compatible, as that is the environment + in which the code is executed in the cloud. + + .. note:: + Please have BigQuery Data Editor (roles/bigquery.dataEditor) IAM + role enabled for you. + + **Examples:** + + >>> import datetime + + Turning an arbitrary python function into a BigQuery managed python udf: + + >>> bq_name = datetime.datetime.now().strftime("bigframes_%Y%m%d%H%M%S%f") + >>> @bpd.udf(dataset="bigfranes_testing", name=bq_name) # doctest: +SKIP + ... def minutes_to_hours(x: int) -> float: + ... return x/60 + + >>> minutes = bpd.Series([0, 30, 60, 90, 120]) + >>> minutes + 0 0 + 1 30 + 2 60 + 3 90 + 4 120 + dtype: Int64 + + >>> hours = minutes.apply(minutes_to_hours) # doctest: +SKIP + >>> hours # doctest: +SKIP + 0 0.0 + 1 0.5 + 2 1.0 + 3 1.5 + 4 2.0 + dtype: Float64 + + To turn a user defined function with external package dependencies into + a BigQuery managed python udf, you would provide the names of the + packages (optionally with the package version) via `packages` param. + + >>> bq_name = datetime.datetime.now().strftime("bigframes_%Y%m%d%H%M%S%f") + >>> @bpd.udf( # doctest: +SKIP + ... dataset="bigfranes_testing", + ... name=bq_name, + ... packages=["cryptography"] + ... ) + ... def get_hash(input: str) -> str: + ... from cryptography.fernet import Fernet + ... + ... # handle missing value + ... if input is None: + ... input = "" + ... + ... key = Fernet.generate_key() + ... f = Fernet(key) + ... return f.encrypt(input.encode()).decode() + + >>> names = bpd.Series(["Alice", "Bob"]) + >>> hashes = names.apply(get_hash) # doctest: +SKIP + + You can clean-up the BigQuery functions created above using the BigQuery + client from the BigQuery DataFrames session: + + >>> session = bpd.get_global_session() # doctest: +SKIP + >>> session.bqclient.delete_routine(minutes_to_hours.bigframes_bigquery_function) # doctest: +SKIP + >>> session.bqclient.delete_routine(get_hash.bigframes_bigquery_function) # doctest: +SKIP + + Args: + input_types (type or sequence(type), Optional): + For scalar user defined function it should be the input type or + sequence of input types. The supported scalar input types are + `bool`, `bytes`, `float`, `int`, `str`. + output_type (type, Optional): + Data type of the output in the user defined function. If the + user defined function returns an array, then `list[type]` should + be specified. The supported output types are `bool`, `bytes`, + `float`, `int`, `str`, `list[bool]`, `list[float]`, `list[int]` + and `list[str]`. + dataset (str): + Dataset in which to create a BigQuery managed function. It + should be in `.` or `` + format. + bigquery_connection (str, Optional): + Name of the BigQuery connection. It is used to provide an + identity to the serverless instances running the user code. It + helps BigQuery manage and track the resources used by the udf. + This connection is required for internet access and for + interacting with other GCP services. To access GCP services, the + appropriate IAM permissions must also be granted to the + connection's Service Account. When it defaults to None, the udf + will be created without any connection. A udf without a + connection has no internet access and no access to other GCP + services. + name (str): + Explicit name of the persisted BigQuery managed function. Use it + with caution, because more than one users working in the same + project and dataset could overwrite each other's managed + functions if they use the same persistent name. Please note that + any session specific clean up ( + ``bigframes.session.Session.close``/ + ``bigframes.pandas.close_session``/ + ``bigframes.pandas.reset_session``/ + ``bigframes.pandas.clean_up_by_session_id``) does not clean up + this function, and leaves it for the user to manage the function + directly. + packages (str[], Optional): + Explicit name of the external package dependencies. Each + dependency is added to the `requirements.txt` as is, and can be + of the form supported in + https://pip.pypa.io/en/stable/reference/requirements-file-format/. + max_batching_rows (int, Optional): + The maximum number of rows in each batch. If you specify + max_batching_rows, BigQuery determines the number of rows in a + batch, up to the max_batching_rows limit. If max_batching_rows + is not specified, the number of rows to batch is determined + automatically. + container_cpu (float, Optional): + The CPU limits for containers that run Python UDFs. By default, + the CPU allocated is 0.33 vCPU. See details at + https://cloud.google.com/bigquery/docs/user-defined-functions-python#configure-container-limits. + container_memory (str, Optional): + The memory limits for containers that run Python UDFs. By + default, the memory allocated to each container instance is + 512 MiB. See details at + https://cloud.google.com/bigquery/docs/user-defined-functions-python#configure-container-limits. + Returns: + collections.abc.Callable: + A managed function object pointing to the cloud assets created + in the background to support the remote execution. The cloud + ssets can be located through the following properties set in the + object: + + `bigframes_bigquery_function` - The bigquery managed function + deployed for the user defined code. + """ + return self._function_session.udf( + # Session-provided arguments. + session=self, + bigquery_client=self._clients_provider.bqclient, + # User-provided arguments. + input_types=input_types, + output_type=output_type, + dataset=dataset, + bigquery_connection=bigquery_connection, + name=name, + packages=packages, + max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, ) def read_gbq_function( @@ -1422,12 +1984,10 @@ def read_gbq_function( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Use the [cw_lower_case_ascii_only](https://github.com/GoogleCloudPlatform/bigquery-utils/blob/master/udfs/community/README.md#cw_lower_case_ascii_onlystr-string) function from Community UDFs. + >>> import bigframes.pandas as bpd >>> func = bpd.read_gbq_function("bqutil.fn.cw_lower_case_ascii_only") You can run it on scalar input. Usually you would do so to verify that @@ -1487,13 +2047,13 @@ def read_gbq_function( Another use case is to define your own remote function and use it later. For example, define the remote function: - >>> @bpd.remote_function() + >>> @bpd.remote_function(cloud_function_service_account="default") # doctest: +SKIP ... def tenfold(num: int) -> float: ... return num * 10 Then, read back the deployed BQ remote function: - >>> tenfold_ref = bpd.read_gbq_function( + >>> tenfold_ref = bpd.read_gbq_function( # doctest: +SKIP ... tenfold.bigframes_remote_function, ... ) @@ -1505,7 +2065,7 @@ def read_gbq_function( [2 rows x 3 columns] - >>> df['a'].apply(tenfold_ref) + >>> df['a'].apply(tenfold_ref) # doctest: +SKIP 0 10.0 1 20.0 Name: a, dtype: Float64 @@ -1514,11 +2074,11 @@ def read_gbq_function( note, row processor implies that the function has only one input parameter. - >>> @bpd.remote_function() - ... def row_sum(s: bpd.Series) -> float: + >>> @bpd.remote_function(cloud_function_service_account="default") # doctest: +SKIP + ... def row_sum(s: pd.Series) -> float: ... return s['a'] + s['b'] + s['c'] - >>> row_sum_ref = bpd.read_gbq_function( + >>> row_sum_ref = bpd.read_gbq_function( # doctest: +SKIP ... row_sum.bigframes_remote_function, ... is_row_processor=True, ... ) @@ -1531,7 +2091,7 @@ def read_gbq_function( [2 rows x 3 columns] - >>> df.apply(row_sum_ref, axis=1) + >>> df.apply(row_sum_ref, axis=1) # doctest: +SKIP 0 9.0 1 12.0 dtype: Float64 @@ -1593,14 +2153,24 @@ def _start_query_ml_ddl( # so we must reset any encryption set in the job config # https://cloud.google.com/bigquery/docs/customer-managed-encryption#encrypt-model job_config.destination_encryption_configuration = None - - return bf_io_bigquery.start_query_with_client( - self.bqclient, sql, job_config=job_config, metrics=self._metrics + iterator, query_job = bf_io_bigquery.start_query_with_client( + self.bqclient, + sql, + job_config=job_config, + metrics=self._metrics, + location=None, + project=None, + timeout=None, + query_with_job=True, + job_retry=third_party_gcb_retry.DEFAULT_ML_JOB_RETRY, + publisher=self._publisher, + session=self, ) + return iterator, query_job def _create_object_table(self, path: str, connection: str) -> str: """Create a random id Object Table from the input path and connection.""" - table = str(self._loader._storage_manager._random_table()) + table = str(self._anon_dataset_manager.generate_unique_resource_id()) import textwrap @@ -1618,10 +2188,28 @@ def _create_object_table(self, path: str, connection: str) -> str: sql, job_config=bigquery.QueryJobConfig(), metrics=self._metrics, + location=None, + project=None, + timeout=None, + query_with_job=True, + publisher=self._publisher, + session=self, ) return table + def _create_temp_view(self, sql: str) -> bigquery.TableReference: + """Create a random id view from the sql string.""" + return self._anon_dataset_manager.create_temp_view(sql) + + def _create_temp_table( + self, schema: Sequence[bigquery.SchemaField], cluster_cols: Sequence[str] = [] + ) -> bigquery.TableReference: + """Allocate a random temporary table with the desired schema.""" + return self._temp_storage_manager.create_temp_table( + schema=schema, cluster_cols=cluster_cols + ) + def from_glob_path( self, path: str, *, connection: Optional[str] = None, name: Optional[str] = None ) -> dataframe.DataFrame: @@ -1630,7 +2218,10 @@ def from_glob_path( If you have an existing BQ Object Table, use read_gbq_object_table(). .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. + BigFrames Blob is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). Args: path (str): @@ -1645,27 +2236,25 @@ def from_glob_path( bigframes.pandas.DataFrame: Result BigFrames DataFrame. """ - if not bigframes.options.experiments.blob: - raise NotImplementedError() - # TODO(garrettwu): switch to pseudocolumn when b/374988109 is done. - connection = self._create_bq_connection( - connection=connection, iam_role="storage.objectUser" - ) + connection = self._create_bq_connection(connection=connection) table = self._create_object_table(path, connection) - s = self.read_gbq(table)["uri"].str.to_blob(connection) + s = self._loader.read_gbq_table(table)["uri"].str.to_blob(connection) return s.rename(name).to_frame() def _create_bq_connection( - self, iam_role: str, *, connection: Optional[str] = None + self, + *, + connection: Optional[str] = None, + iam_role: Optional[str] = None, ) -> str: """Create the connection with the session settings and try to attach iam role to the connection SA. If any of project, location or connection isn't specified, use the session defaults. Returns fully-qualified connection name.""" connection = self._bq_connection if not connection else connection - connection = bigframes.clients.resolve_full_bq_connection_name( - connection_name=connection, + connection = bigframes.clients.get_canonical_bq_connection_id( + connection_id=connection, default_project=self._project, default_location=self._location, ) @@ -1688,7 +2277,10 @@ def read_gbq_object_table( This function dosen't retrieve the object table data. If you want to read the data, use read_gbq() instead. .. note:: - BigFrames Blob is still under experiments. It may not work and subject to change in the future. + BigFrames Blob is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). Args: object_table (str): name of the object table of form ... @@ -1698,16 +2290,134 @@ def read_gbq_object_table( bigframes.pandas.DataFrame: Result BigFrames DataFrame. """ - if not bigframes.options.experiments.blob: - raise NotImplementedError() - # TODO(garrettwu): switch to pseudocolumn when b/374988109 is done. table = self.bqclient.get_table(object_table) connection = table._properties["externalDataConfiguration"]["connectionId"] - s = self.read_gbq(object_table)["uri"].str.to_blob(connection) + s = self._loader.read_gbq_table(object_table)["uri"].str.to_blob(connection) return s.rename(name).to_frame() + # ========================================================================= + # bigframes.pandas attributes + # + # These are included so that Session and bigframes.pandas can be used + # interchangeably. + # ========================================================================= + def cut(self, *args, **kwargs) -> bigframes.series.Series: + """Cuts a BigQuery DataFrames object. + + Included for compatibility between bpd and Session. + + See :func:`bigframes.pandas.cut` for full documentation. + """ + import bigframes.core.reshape.tile + + return bigframes.core.reshape.tile.cut( + *args, + session=self, + **kwargs, + ) + + def crosstab(self, *args, **kwargs) -> dataframe.DataFrame: + """Compute a simple cross tabulation of two (or more) factors. + + Included for compatibility between bpd and Session. + + See :func:`bigframes.pandas.crosstab` for full documentation. + """ + import bigframes.core.reshape.pivot + + return bigframes.core.reshape.pivot.crosstab( + *args, + session=self, + **kwargs, + ) + + def DataFrame(self, *args, **kwargs): + """Constructs a DataFrame. + + Included for compatibility between bpd and Session. + + See :class:`bigframes.pandas.DataFrame` for full documentation. + """ + import bigframes.dataframe + + return bigframes.dataframe.DataFrame(*args, session=self, **kwargs) + + @property + def MultiIndex(self) -> bigframes.core.indexes.multi.MultiIndexAccessor: + """Constructs a MultiIndex. + + Included for compatibility between bpd and Session. + + See :class:`bigframes.pandas.MulitIndex` for full documentation. + """ + import bigframes.core.indexes.multi + + return bigframes.core.indexes.multi.MultiIndexAccessor(self) + + def Index(self, *args, **kwargs): + """Constructs a Index. + + Included for compatibility between bpd and Session. + + See :class:`bigframes.pandas.Index` for full documentation. + """ + import bigframes.core.indexes + + return bigframes.core.indexes.Index(*args, session=self, **kwargs) + + def Series(self, *args, **kwargs): + """Constructs a Series. + + Included for compatibility between bpd and Session. + + See :class:`bigframes.pandas.Series` for full documentation. + """ + import bigframes.series + + return bigframes.series.Series(*args, session=self, **kwargs) + + def to_datetime( + self, *args, **kwargs + ) -> Union[pandas.Timestamp, datetime.datetime, bigframes.series.Series]: + """Converts a BigQuery DataFrames object to datetime dtype. + + Included for compatibility between bpd and Session. + + See :func:`bigframes.pandas.to_datetime` for full documentation. + """ + import bigframes.core.tools + + return bigframes.core.tools.to_datetime( + *args, + session=self, + **kwargs, + ) + + def to_timedelta(self, *args, **kwargs): + """Converts a BigQuery DataFrames object to timedelta/duration dtype. + + Included for compatibility between bpd and Session. + + See :func:`bigframes.pandas.to_timedelta` for full documentation. + """ + import bigframes.pandas.core.tools.timedeltas + + return bigframes.pandas.core.tools.timedeltas.to_timedelta( + *args, + session=self, + **kwargs, + ) + def connect(context: Optional[bigquery_options.BigQueryOptions] = None) -> Session: return Session(context) + + +def _warn_if_bf_version_is_obsolete(): + today = datetime.datetime.today() + release_date = datetime.datetime.strptime(version.__release_date__, "%Y-%m-%d") + if today - release_date > datetime.timedelta(days=365): + msg = f"Your BigFrames version {version.__version__} is more than 1 year old. Please update to the lastest version." + warnings.warn(msg, bfe.ObsoleteVersionWarning) diff --git a/bigframes/session/_io/bigquery/__init__.py b/bigframes/session/_io/bigquery/__init__.py index 8fcc36b4d3..9114770224 100644 --- a/bigframes/session/_io/bigquery/__init__.py +++ b/bigframes/session/_io/bigquery/__init__.py @@ -22,24 +22,26 @@ import textwrap import types import typing -from typing import Dict, Iterable, Mapping, Optional, Tuple, Union +from typing import Dict, Iterable, Literal, Mapping, Optional, overload, Tuple, Union +import bigframes_vendored.google_cloud_bigquery.retry as third_party_gcb_retry import bigframes_vendored.pandas.io.gbq as third_party_pandas_gbq import google.api_core.exceptions +import google.api_core.retry import google.cloud.bigquery as bigquery +import google.cloud.bigquery._job_helpers import google.cloud.bigquery.table from bigframes.core import log_adapter import bigframes.core.compile.googlesql as googlesql +import bigframes.core.events import bigframes.core.sql -import bigframes.formatting_helpers as formatting_helpers import bigframes.session.metrics CHECK_DRIVE_PERMISSIONS = "\nCheck https://cloud.google.com/bigquery/docs/query-drive-data#Google_Drive_permissions." IO_ORDERING_ID = "bqdf_row_nums" -MAX_LABELS_COUNT = 64 - 8 _LIST_TABLES_LIMIT = 10000 # calls to bqclient.list_tables # will be limited to this many tables @@ -49,7 +51,6 @@ def create_job_configs_labels( job_configs_labels: Optional[Dict[str, str]], api_methods: typing.List[str], - api_name: Optional[str] = None, ) -> Dict[str, str]: if job_configs_labels is None: job_configs_labels = {} @@ -59,9 +60,6 @@ def create_job_configs_labels( for key, value in bigframes.options.compute.extra_query_labels.items(): job_configs_labels[key] = value - if api_name is not None: - job_configs_labels["bigframes-api"] = api_name - if api_methods and "bigframes-api" not in job_configs_labels: job_configs_labels["bigframes-api"] = api_methods[0] del api_methods[0] @@ -78,7 +76,12 @@ def create_job_configs_labels( ) ) values = list(itertools.chain(job_configs_labels.values(), api_methods)) - return dict(zip(labels[:MAX_LABELS_COUNT], values[:MAX_LABELS_COUNT])) + return dict( + zip( + labels[: log_adapter.MAX_LABELS_COUNT], + values[: log_adapter.MAX_LABELS_COUNT], + ) + ) def create_export_data_statement( @@ -123,6 +126,7 @@ def create_temp_table( schema: Optional[Iterable[bigquery.SchemaField]] = None, cluster_columns: Optional[list[str]] = None, kms_key: Optional[str] = None, + session=None, ) -> str: """Create an empty table with an expiration in the desired session. @@ -144,6 +148,29 @@ def create_temp_table( return f"{table_ref.project}.{table_ref.dataset_id}.{table_ref.table_id}" +def create_temp_view( + bqclient: bigquery.Client, + table_ref: bigquery.TableReference, + *, + expiration: datetime.datetime, + sql: str, + session=None, +) -> str: + """Create an empty table with an expiration in the desired session. + + The table will be deleted when the session is closed or the expiration + is reached. + """ + destination = bigquery.Table(table_ref) + destination.expires = expiration + destination.view_query = sql + + # Ok if already exists, since this will only happen from retries internal to this method + # as the requested table id has a random UUID4 component. + bqclient.create_table(destination, exists_ok=True) + return f"{table_ref.project}.{table_ref.dataset_id}.{table_ref.table_id}" + + def set_table_expiration( bqclient: bigquery.Client, table_ref: bigquery.TableReference, @@ -203,66 +230,191 @@ def format_option(key: str, value: Union[bool, str]) -> str: return f"{key}={repr(value)}" -def add_and_trim_labels(job_config, api_name: Optional[str] = None): +def add_and_trim_labels(job_config, session=None): """ Add additional labels to the job configuration and trim the total number of labels - to ensure they do not exceed the maximum limit allowed by BigQuery, which is 64 - labels per job. + to ensure they do not exceed MAX_LABELS_COUNT labels per job. """ - api_methods = log_adapter.get_and_reset_api_methods(dry_run=job_config.dry_run) + api_methods = log_adapter.get_and_reset_api_methods( + dry_run=job_config.dry_run, session=session + ) job_config.labels = create_job_configs_labels( job_configs_labels=job_config.labels, api_methods=api_methods, - api_name=api_name, ) +def create_bq_event_callback(publisher): + def publish_bq_event(event): + if isinstance(event, google.cloud.bigquery._job_helpers.QueryFinishedEvent): + bf_event = bigframes.core.events.BigQueryFinishedEvent.from_bqclient(event) + elif isinstance(event, google.cloud.bigquery._job_helpers.QueryReceivedEvent): + bf_event = bigframes.core.events.BigQueryReceivedEvent.from_bqclient(event) + elif isinstance(event, google.cloud.bigquery._job_helpers.QueryRetryEvent): + bf_event = bigframes.core.events.BigQueryRetryEvent.from_bqclient(event) + elif isinstance(event, google.cloud.bigquery._job_helpers.QuerySentEvent): + bf_event = bigframes.core.events.BigQuerySentEvent.from_bqclient(event) + else: + bf_event = bigframes.core.events.BigQueryUnknownEvent(event) + + publisher.publish(bf_event) + + return publish_bq_event + + +@overload def start_query_with_client( bq_client: bigquery.Client, sql: str, - job_config: bigquery.job.QueryJobConfig, + *, + job_config: bigquery.QueryJobConfig, + location: Optional[str], + project: Optional[str], + timeout: Optional[float], + metrics: Optional[bigframes.session.metrics.ExecutionMetrics], + query_with_job: Literal[True], + publisher: bigframes.core.events.Publisher, + session=None, +) -> Tuple[google.cloud.bigquery.table.RowIterator, bigquery.QueryJob]: + ... + + +@overload +def start_query_with_client( + bq_client: bigquery.Client, + sql: str, + *, + job_config: bigquery.QueryJobConfig, + location: Optional[str], + project: Optional[str], + timeout: Optional[float], + metrics: Optional[bigframes.session.metrics.ExecutionMetrics], + query_with_job: Literal[False], + publisher: bigframes.core.events.Publisher, + session=None, +) -> Tuple[google.cloud.bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: + ... + + +@overload +def start_query_with_client( + bq_client: bigquery.Client, + sql: str, + *, + job_config: bigquery.QueryJobConfig, + location: Optional[str], + project: Optional[str], + timeout: Optional[float], + metrics: Optional[bigframes.session.metrics.ExecutionMetrics], + query_with_job: Literal[True], + job_retry: google.api_core.retry.Retry, + publisher: bigframes.core.events.Publisher, + session=None, +) -> Tuple[google.cloud.bigquery.table.RowIterator, bigquery.QueryJob]: + ... + + +@overload +def start_query_with_client( + bq_client: bigquery.Client, + sql: str, + *, + job_config: bigquery.QueryJobConfig, + location: Optional[str], + project: Optional[str], + timeout: Optional[float], + metrics: Optional[bigframes.session.metrics.ExecutionMetrics], + query_with_job: Literal[False], + job_retry: google.api_core.retry.Retry, + publisher: bigframes.core.events.Publisher, + session=None, +) -> Tuple[google.cloud.bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: + ... + + +def start_query_with_client( + bq_client: bigquery.Client, + sql: str, + *, + job_config: bigquery.QueryJobConfig, location: Optional[str] = None, project: Optional[str] = None, - max_results: Optional[int] = None, - page_size: Optional[int] = None, timeout: Optional[float] = None, - api_name: Optional[str] = None, metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, -) -> Tuple[bigquery.table.RowIterator, bigquery.QueryJob]: + query_with_job: bool = True, + # TODO(tswast): We can stop providing our own default once we use a + # google-cloud-bigquery version with + # https://github.com/googleapis/python-bigquery/pull/2256 merged, likely + # version 3.36.0 or later. + job_retry: google.api_core.retry.Retry = third_party_gcb_retry.DEFAULT_JOB_RETRY, + publisher: bigframes.core.events.Publisher, + session=None, +) -> Tuple[google.cloud.bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: """ Starts query job and waits for results. """ + # Note: Ensure no additional labels are added to job_config after this + # point, as `add_and_trim_labels` ensures the label count does not + # exceed MAX_LABELS_COUNT. + add_and_trim_labels(job_config, session=session) + try: - # Note: Ensure no additional labels are added to job_config after this point, - # as `add_and_trim_labels` ensures the label count does not exceed 64. - add_and_trim_labels(job_config, api_name=api_name) + if not query_with_job: + results_iterator = bq_client._query_and_wait_bigframes( + sql, + job_config=job_config, + location=location, + project=project, + api_timeout=timeout, + job_retry=job_retry, + callback=create_bq_event_callback(publisher), + ) + if metrics is not None: + metrics.count_job_stats(row_iterator=results_iterator) + return results_iterator, None + query_job = bq_client.query( sql, job_config=job_config, location=location, project=project, timeout=timeout, + job_retry=job_retry, ) except google.api_core.exceptions.Forbidden as ex: if "Drive credentials" in ex.message: ex.message += CHECK_DRIVE_PERMISSIONS raise - opts = bigframes.options.display - if opts.progress_bar is not None and not query_job.configuration.dry_run: - results_iterator = formatting_helpers.wait_for_query_job( - query_job, - max_results=max_results, - progress_bar=opts.progress_bar, - page_size=page_size, + if not query_job.configuration.dry_run: + publisher.publish( + bigframes.core.events.BigQuerySentEvent( + sql, + billing_project=query_job.project, + location=query_job.location, + job_id=query_job.job_id, + request_id=None, + ) ) - else: - results_iterator = query_job.result( - max_results=max_results, page_size=page_size + results_iterator = query_job.result() + if not query_job.configuration.dry_run: + publisher.publish( + bigframes.core.events.BigQueryFinishedEvent( + billing_project=query_job.project, + location=query_job.location, + job_id=query_job.job_id, + destination=query_job.destination, + total_rows=results_iterator.total_rows, + total_bytes_processed=query_job.total_bytes_processed, + slot_millis=query_job.slot_millis, + created=query_job.created, + started=query_job.started, + ended=query_job.ended, + ) ) if metrics is not None: - metrics.count_job_stats(query_job) + metrics.count_job_stats(query_job=query_job) return results_iterator, query_job @@ -299,9 +451,10 @@ def delete_tables_matching_session_id( def create_bq_dataset_reference( bq_client: bigquery.Client, - location=None, - project=None, - api_name: str = "unknown", + location: Optional[str] = None, + project: Optional[str] = None, + *, + publisher: bigframes.core.events.Publisher, ) -> bigquery.DatasetReference: """Create and identify dataset(s) for temporary BQ resources. @@ -330,7 +483,10 @@ def create_bq_dataset_reference( location=location, job_config=job_config, project=project, - api_name=api_name, + timeout=None, + metrics=None, + query_with_job=True, + publisher=publisher, ) # The anonymous dataset is used by BigQuery to write query results and diff --git a/bigframes/session/_io/bigquery/read_gbq_query.py b/bigframes/session/_io/bigquery/read_gbq_query.py new file mode 100644 index 0000000000..b650266a0d --- /dev/null +++ b/bigframes/session/_io/bigquery/read_gbq_query.py @@ -0,0 +1,143 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Private helpers for implementing read_gbq_query.""" + +from __future__ import annotations + +from typing import cast, Iterable, Optional, Tuple + +from google.cloud import bigquery +import google.cloud.bigquery.table +import pandas + +from bigframes import dataframe +from bigframes.core import local_data, pyarrow_utils +import bigframes.core as core +import bigframes.core.blocks as blocks +import bigframes.core.guid +import bigframes.core.schema as schemata +import bigframes.enums +import bigframes.session + + +def should_return_query_results(query_job: bigquery.QueryJob) -> bool: + """Returns True if query_job is the kind of query we expect results from. + + If the query was DDL or DML, return some job metadata. See + https://cloud.google.com/bigquery/docs/reference/rest/v2/Job#JobStatistics2.FIELDS.statement_type + for possible statement types. Note that destination table does exist + for some DDL operations such as CREATE VIEW, but we don't want to + read from that. See internal issue b/444282709. + """ + + if query_job.statement_type == "SELECT": + return True + + if query_job.statement_type == "SCRIPT": + # Try to determine if the last statement is a SELECT. Alternatively, we + # could do a jobs.list request using query_job as the parent job and + # try to determine the statement type of the last child job. + return query_job.destination != query_job.ddl_target_table + + return False + + +def create_dataframe_from_query_job_stats( + query_job: Optional[bigquery.QueryJob], *, session: bigframes.session.Session +) -> dataframe.DataFrame: + """Convert a QueryJob into a DataFrame with key statistics about the query. + + Any changes you make here, please try to keep in sync with pandas-gbq. + """ + return dataframe.DataFrame( + data=pandas.DataFrame( + { + "statement_type": [ + query_job.statement_type if query_job else "unknown" + ], + "job_id": [query_job.job_id if query_job else "unknown"], + "location": [query_job.location if query_job else "unknown"], + } + ), + session=session, + ) + + +def create_dataframe_from_row_iterator( + rows: google.cloud.bigquery.table.RowIterator, + *, + session: bigframes.session.Session, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind, + columns: Iterable[str], +) -> dataframe.DataFrame: + """Convert a RowIterator into a DataFrame wrapping a LocalNode. + + This allows us to create a DataFrame from query results, even in the + 'jobless' case where there's no destination table. + """ + pa_table = rows.to_arrow() + bq_schema = list(rows.schema) + is_default_index = not index_col or isinstance( + index_col, bigframes.enums.DefaultIndexKind + ) + + if is_default_index: + # We get a sequential index for free, so use that if no index is specified. + # TODO(tswast): Use array_value.promote_offsets() instead once that node is + # supported by the local engine. + offsets_col = bigframes.core.guid.generate_guid() + pa_table = pyarrow_utils.append_offsets(pa_table, offsets_col=offsets_col) + bq_schema += [bigquery.SchemaField(offsets_col, "INTEGER")] + index_columns: Tuple[str, ...] = (offsets_col,) + index_labels: Tuple[Optional[str], ...] = (None,) + elif isinstance(index_col, str): + index_columns = (index_col,) + index_labels = (index_col,) + else: + index_col = cast(Iterable[str], index_col) + index_columns = tuple(index_col) + index_labels = cast(Tuple[Optional[str], ...], tuple(index_col)) + + # We use the ManagedArrowTable constructor directly, because the + # results of to_arrow() should be the source of truth with regards + # to canonical formats since it comes from either the BQ Storage + # Read API or has been transformed by google-cloud-bigquery to look + # like the output of the BQ Storage Read API. + mat = local_data.ManagedArrowTable( + pa_table, + schemata.ArraySchema.from_bq_schema(bq_schema), + ) + mat.validate() + + column_labels = [ + field.name for field in rows.schema if field.name not in index_columns + ] + + array_value = core.ArrayValue.from_managed(mat, session) + block = blocks.Block( + array_value, + index_columns=index_columns, + column_labels=column_labels, + index_labels=index_labels, + ) + df = dataframe.DataFrame(block) + + if columns: + df = df[list(columns)] + + if not is_default_index: + df = df.sort_index() + + return df diff --git a/bigframes/session/_io/bigquery/read_gbq_table.py b/bigframes/session/_io/bigquery/read_gbq_table.py index ed68762ee8..e12fe502c0 100644 --- a/bigframes/session/_io/bigquery/read_gbq_table.py +++ b/bigframes/session/_io/bigquery/read_gbq_table.py @@ -26,57 +26,142 @@ import bigframes_vendored.constants as constants import google.api_core.exceptions import google.cloud.bigquery as bigquery +import google.cloud.bigquery.table -import bigframes.clients -import bigframes.core.compile -import bigframes.core.compile.default_ordering -import bigframes.core.sql -import bigframes.dtypes +import bigframes.core +import bigframes.core.events import bigframes.exceptions as bfe import bigframes.session._io.bigquery -import bigframes.session.clients -import bigframes.version # Avoid circular imports. if typing.TYPE_CHECKING: import bigframes.session +def _convert_information_schema_table_id_to_table_reference( + table_id: str, + default_project: Optional[str], +) -> bigquery.TableReference: + """Squeeze an INFORMATION_SCHEMA reference into a TableReference. + This is kind-of a hack. INFORMATION_SCHEMA is a view that isn't available + via the tables.get REST API. + """ + parts = table_id.split(".") + parts_casefold = [part.casefold() for part in parts] + dataset_index = parts_casefold.index("INFORMATION_SCHEMA".casefold()) + + if dataset_index == 0: + project = default_project + else: + project = ".".join(parts[:dataset_index]) + + if project is None: + message = ( + "Could not determine project ID. " + "Please provide a project or region in your INFORMATION_SCHEMA table ID, " + "For example, 'region-REGION_NAME.INFORMATION_SCHEMA.JOBS'." + ) + raise ValueError(message) + + dataset = "INFORMATION_SCHEMA" + table_id_short = ".".join(parts[dataset_index + 1 :]) + return bigquery.TableReference( + bigquery.DatasetReference(project, dataset), + table_id_short, + ) + + +def get_information_schema_metadata( + bqclient: bigquery.Client, + table_id: str, + default_project: Optional[str], +) -> bigquery.Table: + job_config = bigquery.QueryJobConfig(dry_run=True) + job = bqclient.query( + f"SELECT * FROM `{table_id}`", + job_config=job_config, + ) + table_ref = _convert_information_schema_table_id_to_table_reference( + table_id=table_id, + default_project=default_project, + ) + table = bigquery.Table.from_api_repr( + { + "tableReference": table_ref.to_api_repr(), + "location": job.location, + # Prevent ourselves from trying to read the table with the BQ + # Storage API. + "type": "VIEW", + } + ) + table.schema = job.schema + return table + + def get_table_metadata( bqclient: bigquery.Client, - table_ref: google.cloud.bigquery.table.TableReference, - bq_time: datetime.datetime, *, - cache: Dict[bigquery.TableReference, Tuple[datetime.datetime, bigquery.Table]], + table_id: str, + default_project: Optional[str], + bq_time: datetime.datetime, + cache: Dict[str, Tuple[datetime.datetime, bigquery.Table]], use_cache: bool = True, + publisher: bigframes.core.events.Publisher, ) -> Tuple[datetime.datetime, google.cloud.bigquery.table.Table]: """Get the table metadata, either from cache or via REST API.""" - cached_table = cache.get(table_ref) + cached_table = cache.get(table_id) if use_cache and cached_table is not None: - snapshot_timestamp, _ = cached_table - - # Cache hit could be unexpected. See internal issue 329545805. - # Raise a warning with more information about how to avoid the - # problems with the cache. - msg = ( - f"Reading cached table from {snapshot_timestamp} to avoid " - "incompatibilies with previous reads of this table. To read " - "the latest version, set `use_cache=False` or close the " - "current session with Session.close() or " - "bigframes.pandas.close_session()." - ) - # There are many layers before we get to (possibly) the user's code: - # pandas.read_gbq_table - # -> with_default_session - # -> Session.read_gbq_table - # -> _read_gbq_table - # -> _get_snapshot_sql_and_primary_key - # -> get_snapshot_datetime_and_table_metadata - warnings.warn(msg, stacklevel=7) + snapshot_timestamp, table = cached_table + + if is_time_travel_eligible( + bqclient=bqclient, + table=table, + columns=None, + snapshot_time=snapshot_timestamp, + filter_str=None, + # Don't warn, because that will already have been taken care of. + should_warn=False, + should_dry_run=False, + publisher=publisher, + ): + # This warning should only happen if the cached snapshot_time will + # have any effect on bigframes (b/437090788). For example, with + # cached query results, such as after re-running a query, time + # travel won't be applied and thus this check is irrelevent. + # + # In other cases, such as an explicit read_gbq_table(), Cache hit + # could be unexpected. See internal issue 329545805. Raise a + # warning with more information about how to avoid the problems + # with the cache. + msg = bfe.format_message( + f"Reading cached table from {snapshot_timestamp} to avoid " + "incompatibilies with previous reads of this table. To read " + "the latest version, set `use_cache=False` or close the " + "current session with Session.close() or " + "bigframes.pandas.close_session()." + ) + # There are many layers before we get to (possibly) the user's code: + # pandas.read_gbq_table + # -> with_default_session + # -> Session.read_gbq_table + # -> _read_gbq_table + # -> _get_snapshot_sql_and_primary_key + # -> get_snapshot_datetime_and_table_metadata + warnings.warn(msg, category=bfe.TimeTravelCacheWarning, stacklevel=7) + return cached_table - table = bqclient.get_table(table_ref) + if is_information_schema(table_id): + table = get_information_schema_metadata( + bqclient=bqclient, table_id=table_id, default_project=default_project + ) + else: + table_ref = google.cloud.bigquery.table.TableReference.from_string( + table_id, default_project=default_project + ) + table = bqclient.get_table(table_ref) + # local time will lag a little bit do to network latency # make sure it is at least table creation time. # This is relevant if the table was created immediately before loading it here. @@ -84,35 +169,92 @@ def get_table_metadata( bq_time = table.created cached_table = (bq_time, table) - cache[table_ref] = cached_table + cache[table_id] = cached_table return cached_table -def validate_table( +def is_information_schema(table_id: str): + table_id_casefold = table_id.casefold() + # Include the "."s to ensure we don't have false positives for some user + # defined dataset like MY_INFORMATION_SCHEMA or tables called + # INFORMATION_SCHEMA. + return ( + ".INFORMATION_SCHEMA.".casefold() in table_id_casefold + or table_id_casefold.startswith("INFORMATION_SCHEMA.".casefold()) + ) + + +def is_time_travel_eligible( bqclient: bigquery.Client, - table: bigquery.table.Table, + table: google.cloud.bigquery.table.Table, columns: Optional[Sequence[str]], snapshot_time: datetime.datetime, filter_str: Optional[str] = None, -) -> bool: - """Validates that the table can be read, returns True iff snapshot is supported.""" + *, + should_warn: bool, + should_dry_run: bool, + publisher: bigframes.core.events.Publisher, +): + """Check if a table is eligible to use time-travel. + + + Args: + table: BigQuery table to check. + should_warn: + If true, raises a warning when time travel is disabled and the + underlying table is likely mutable. + + Return: + bool: + True if there is a chance that time travel may be supported on this + table. If ``should_dry_run`` is True, then this is validated with a + ``dry_run`` query. + """ + + # user code + # -> pandas.read_gbq_table + # -> with_default_session + # -> session.read_gbq_table + # -> session._read_gbq_table + # -> loader.read_gbq_table + # -> is_time_travel_eligible + stacklevel = 7 - time_travel_not_found = False # Anonymous dataset, does not support snapshot ever if table.dataset_id.startswith("_"): - pass + return False + # Only true tables support time travel + if table.table_id.endswith("*"): + if should_warn: + msg = bfe.format_message( + "Wildcard tables do not support FOR SYSTEM_TIME AS OF queries. " + "Attempting query without time travel. Be aware that " + "modifications to the underlying data may result in errors or " + "unexpected behavior." + ) + warnings.warn( + msg, category=bfe.TimeTravelDisabledWarning, stacklevel=stacklevel + ) + return False elif table.table_type != "TABLE": if table.table_type == "MATERIALIZED_VIEW": - msg = ( - "Materialized views do not support FOR SYSTEM_TIME AS OF queries. " - "Attempting query without time travel. Be aware that as materialized views " - "are updated periodically, modifications to the underlying data in the view may " - "result in errors or unexpected behavior." - ) - warnings.warn(msg, category=bfe.TimeTravelDisabledWarning) - else: - # table might support time travel, lets do a dry-run query with time travel + if should_warn: + msg = bfe.format_message( + "Materialized views do not support FOR SYSTEM_TIME AS OF queries. " + "Attempting query without time travel. Be aware that as materialized views " + "are updated periodically, modifications to the underlying data in the view may " + "result in errors or unexpected behavior." + ) + warnings.warn( + msg, category=bfe.TimeTravelDisabledWarning, stacklevel=stacklevel + ) + return False + elif table.table_type == "VIEW": + return False + + # table might support time travel, lets do a dry-run query with time travel + if should_dry_run: snapshot_sql = bigframes.session._io.bigquery.to_query( query_or_table=f"{table.reference.project}.{table.reference.dataset_id}.{table.reference.table_id}", columns=columns or (), @@ -120,44 +262,45 @@ def validate_table( time_travel_timestamp=snapshot_time, ) try: - # If this succeeds, we don't need to query without time travel, that would surely succeed - bqclient.query_and_wait( - snapshot_sql, job_config=bigquery.QueryJobConfig(dry_run=True) + # If this succeeds, we know that time travel will for sure work. + bigframes.session._io.bigquery.start_query_with_client( + bq_client=bqclient, + sql=snapshot_sql, + job_config=bigquery.QueryJobConfig(dry_run=True), + location=None, + project=None, + timeout=None, + metrics=None, + query_with_job=False, + publisher=publisher, ) return True + except google.api_core.exceptions.NotFound: - # note that a notfound caused by a simple typo will be - # caught above when the metadata is fetched, not here - time_travel_not_found = True - - # At this point, time travel is known to fail, but can we query without time travel? - snapshot_sql = bigframes.session._io.bigquery.to_query( - query_or_table=f"{table.reference.project}.{table.reference.dataset_id}.{table.reference.table_id}", - columns=columns or (), - sql_predicate=filter_str, - time_travel_timestamp=None, - ) - # Any erorrs here should just be raised to user - bqclient.query_and_wait( - snapshot_sql, job_config=bigquery.QueryJobConfig(dry_run=True) - ) - if time_travel_not_found: - msg = ( - "NotFound error when reading table with time travel." - " Attempting query without time travel. Warning: Without" - " time travel, modifications to the underlying table may" - " result in errors or unexpected behavior." - ) - warnings.warn(msg, category=bfe.TimeTravelDisabledWarning) - return False + # If system time isn't supported, it returns NotFound error? + # Note that a notfound caused by a simple typo will be + # caught above when the metadata is fetched, not here. + if should_warn: + msg = bfe.format_message( + "NotFound error when reading table with time travel." + " Attempting query without time travel. Warning: Without" + " time travel, modifications to the underlying table may" + " result in errors or unexpected behavior." + ) + warnings.warn( + msg, category=bfe.TimeTravelDisabledWarning, stacklevel=stacklevel + ) + + # If we make it to here, we know for sure that time travel won't work. + return False + else: + # We haven't validated it, but there's a chance that time travel could work. + return True def infer_unique_columns( - bqclient: bigquery.Client, - table: bigquery.table.Table, + table: google.cloud.bigquery.table.Table, index_cols: List[str], - api_name: str, - metadata_only: bool = False, ) -> Tuple[str, ...]: """Return a set of columns that can provide a unique row key or empty if none can be inferred. @@ -171,15 +314,37 @@ def infer_unique_columns( # Essentially, just reordering the primary key to match the index col order return tuple(index_col for index_col in index_cols if index_col in primary_keys) - if primary_keys or metadata_only or (not index_cols): - # Sometimes not worth scanning data to check uniqueness + if primary_keys: return primary_keys + + return () + + +def check_if_index_columns_are_unique( + bqclient: bigquery.Client, + table: google.cloud.bigquery.table.Table, + index_cols: List[str], + *, + publisher: bigframes.core.events.Publisher, +) -> Tuple[str, ...]: + import bigframes.core.sql + import bigframes.session._io.bigquery + # TODO(b/337925142): Avoid a "SELECT *" subquery here by ensuring # table_expression only selects just index_cols. is_unique_sql = bigframes.core.sql.is_distinct_sql(index_cols, table.reference) job_config = bigquery.QueryJobConfig() - job_config.labels["bigframes-api"] = api_name - results = bqclient.query_and_wait(is_unique_sql, job_config=job_config) + results, _ = bigframes.session._io.bigquery.start_query_with_client( + bq_client=bqclient, + sql=is_unique_sql, + job_config=job_config, + timeout=None, + location=None, + project=None, + metrics=None, + query_with_job=False, + publisher=publisher, + ) row = next(iter(results)) if row["total_count"] == row["distinct_count"]: @@ -188,7 +353,7 @@ def infer_unique_columns( def _get_primary_keys( - table: bigquery.table.Table, + table: google.cloud.bigquery.table.Table, ) -> List[str]: """Get primary keys from table if they are set.""" @@ -206,7 +371,7 @@ def _get_primary_keys( def _is_table_clustered_or_partitioned( - table: bigquery.table.Table, + table: google.cloud.bigquery.table.Table, ) -> bool: """Returns True if the table is clustered or partitioned.""" @@ -229,17 +394,26 @@ def _is_table_clustered_or_partitioned( def get_index_cols( - table: bigquery.table.Table, - index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind, + table: google.cloud.bigquery.table.Table, + index_col: Iterable[str] + | str + | Iterable[int] + | int + | bigframes.enums.DefaultIndexKind, + *, + rename_to_schema: Optional[Dict[str, str]] = None, + default_index_type: bigframes.enums.DefaultIndexKind = bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64, ) -> List[str]: """ If we can get a total ordering from the table, such as via primary key column(s), then return those too so that ordering generation can be avoided. """ - # Transform index_col -> index_cols so we have a variable that is # always a list of column names (possibly empty). + schema_len = len(table.schema) + + index_cols: List[str] = [] if isinstance(index_col, bigframes.enums.DefaultIndexKind): if index_col == bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64: # User has explicity asked for a default, sequential index. @@ -255,9 +429,39 @@ def get_index_cols( f"Got unexpected index_col {repr(index_col)}. {constants.FEEDBACK_LINK}" ) elif isinstance(index_col, str): - index_cols: List[str] = [index_col] + if rename_to_schema is not None: + index_col = rename_to_schema.get(index_col, index_col) + index_cols = [index_col] + elif isinstance(index_col, int): + if not 0 <= index_col < schema_len: + raise ValueError( + f"Integer index {index_col} is out of bounds " + f"for table with {schema_len} columns (must be >= 0 and < {schema_len})." + ) + index_cols = [table.schema[index_col].name] + elif isinstance(index_col, Iterable): + for item in index_col: + if isinstance(item, str): + if rename_to_schema is not None: + item = rename_to_schema.get(item, item) + index_cols.append(item) + elif isinstance(item, int): + if not 0 <= item < schema_len: + raise ValueError( + f"Integer index {item} is out of bounds " + f"for table with {schema_len} columns (must be >= 0 and < {schema_len})." + ) + index_cols.append(table.schema[item].name) + else: + raise TypeError( + "If index_col is an iterable, it must contain either strings " + "(column names) or integers (column positions)." + ) else: - index_cols = list(index_col) + raise TypeError( + f"Unsupported type for index_col: {type(index_col).__name__}. Expected" + "an integer, an string, an iterable of strings, or an iterable of integers." + ) # If the isn't an index selected, use the primary keys of the table as the # index. If there are no primary keys, we'll return an empty list. @@ -268,8 +472,12 @@ def get_index_cols( # find index_cols to use. This is to avoid unexpected performance and # resource utilization because of the default sequential index. See # internal issue 335727141. - if _is_table_clustered_or_partitioned(table) and not primary_keys: - msg = ( + if ( + _is_table_clustered_or_partitioned(table) + and not primary_keys + and default_index_type == bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64 + ): + msg = bfe.format_message( f"Table '{str(table.reference)}' is clustered and/or " "partitioned, but BigQuery DataFrames was not able to find a " "suitable index. To avoid this warning, set at least one of: " diff --git a/bigframes/session/_io/pandas.py b/bigframes/session/_io/pandas.py index a1549238b3..9340e060ac 100644 --- a/bigframes/session/_io/pandas.py +++ b/bigframes/session/_io/pandas.py @@ -18,9 +18,7 @@ from typing import Collection, Union import bigframes_vendored.constants as constants -import db_dtypes # type: ignore import geopandas # type: ignore -import numpy as np import pandas import pandas.arrays import pyarrow # type: ignore @@ -28,7 +26,6 @@ import pyarrow.types # type: ignore import bigframes.core.schema -import bigframes.core.utils as utils import bigframes.dtypes import bigframes.features @@ -81,7 +78,10 @@ def arrow_to_pandas( if dtype == geopandas.array.GeometryDtype(): series = geopandas.GeoSeries.from_wkt( - column, + # Use `to_pylist()` is a workaround for TypeError: object of type + # 'pyarrow.lib.StringScalar' has no len() on older pyarrow, + # geopandas, shapely combinations. + column.to_pylist(), # BigQuery geography type is based on the WGS84 reference ellipsoid. crs="EPSG:4326", ) @@ -125,57 +125,9 @@ def arrow_to_pandas( ) elif isinstance(dtype, pandas.ArrowDtype): series = _arrow_to_pandas_arrowdtype(column, dtype) - elif isinstance(dtype, db_dtypes.JSONDtype): - series = db_dtypes.JSONArray(column) else: series = column.to_pandas(types_mapper=lambda _: dtype) serieses[field.name] = series return pandas.DataFrame(serieses) - - -def pandas_to_bq_compatible(pandas_dataframe: pandas.DataFrame) -> DataFrameAndLabels: - """Convert a pandas DataFrame into something compatible with uploading to a - BigQuery table (without flexible column names enabled). - """ - col_index = pandas_dataframe.columns.copy() - col_labels, idx_labels = ( - col_index.to_list(), - pandas_dataframe.index.names, - ) - new_col_ids, new_idx_ids = utils.get_standardized_ids( - col_labels, - idx_labels, - # Loading parquet files into BigQuery with special column names - # is only supported under an allowlist. - strict=True, - ) - - # Add order column to pandas DataFrame to preserve order in BigQuery - ordering_col = "rowid" - columns = frozenset(col_labels + idx_labels) - suffix = 2 - while ordering_col in columns: - ordering_col = f"rowid_{suffix}" - suffix += 1 - - pandas_dataframe_copy = pandas_dataframe.copy() - pandas_dataframe_copy.index.names = new_idx_ids - pandas_dataframe_copy.columns = pandas.Index(new_col_ids) - pandas_dataframe_copy[ordering_col] = np.arange(pandas_dataframe_copy.shape[0]) - - timedelta_cols = utils.replace_timedeltas_with_micros(pandas_dataframe_copy) - json_cols = utils.replace_json_with_string(pandas_dataframe_copy) - col_type_overrides: typing.Dict[str, bigframes.dtypes.Dtype] = { - **{col: bigframes.dtypes.TIMEDELTA_DTYPE for col in timedelta_cols}, - **{col: bigframes.dtypes.JSON_DTYPE for col in json_cols}, - } - - return DataFrameAndLabels( - df=pandas_dataframe_copy, - column_labels=col_labels, - index_labels=idx_labels, - ordering_col=ordering_col, - col_type_overrides=col_type_overrides, - ) diff --git a/bigframes/session/anonymous_dataset.py b/bigframes/session/anonymous_dataset.py new file mode 100644 index 0000000000..bdc6e7f59c --- /dev/null +++ b/bigframes/session/anonymous_dataset.py @@ -0,0 +1,188 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import threading +from typing import List, Optional, Sequence +import uuid +import warnings + +from google.api_core import retry as api_core_retry +import google.cloud.bigquery as bigquery + +from bigframes import constants +import bigframes.core.events +import bigframes.exceptions as bfe +from bigframes.session import temporary_storage +import bigframes.session._io.bigquery as bf_io_bigquery + +_TEMP_TABLE_ID_FORMAT = "bqdf{date}_{session_id}_{random_id}" +# UDFs older than this many days are considered stale and will be deleted +# from the anonymous dataset before creating a new UDF. +_UDF_CLEANUP_THRESHOLD_DAYS = 3 + + +class AnonymousDatasetManager(temporary_storage.TemporaryStorageManager): + """ + Responsible for allocating and cleaning up temporary gbq tables used by a BigFrames session. + """ + + def __init__( + self, + bqclient: bigquery.Client, + location: str, + session_id: str, + *, + kms_key: Optional[str] = None, + publisher: bigframes.core.events.Publisher, + ): + self.bqclient = bqclient + self._location = location + self._publisher = publisher + + self.session_id = session_id + self._table_ids: List[bigquery.TableReference] = [] + self._kms_key = kms_key + + self._dataset_lock = threading.Lock() + self._datset_ref: Optional[bigquery.DatasetReference] = None + + @property + def location(self): + return self._location + + @property + def dataset(self) -> bigquery.DatasetReference: + if self._datset_ref is not None: + return self._datset_ref + with self._dataset_lock: + if self._datset_ref is None: + self._datset_ref = bf_io_bigquery.create_bq_dataset_reference( + self.bqclient, + location=self._location, + publisher=self._publisher, + ) + return self._datset_ref + + def _default_expiration(self): + """When should the table expire automatically?""" + return ( + datetime.datetime.now(datetime.timezone.utc) + constants.DEFAULT_EXPIRATION + ) + + def create_temp_table( + self, schema: Sequence[bigquery.SchemaField], cluster_cols: Sequence[str] = [] + ) -> bigquery.TableReference: + """ + Allocates and and creates a table in the anonymous dataset. + The table will be cleaned up by clean_up_tables. + """ + expiration = self._default_expiration() + table = bf_io_bigquery.create_temp_table( + self.bqclient, + self.allocate_temp_table(), + expiration, + schema=schema, + cluster_columns=list(cluster_cols), + kms_key=self._kms_key, + ) + return bigquery.TableReference.from_string(table) + + def create_temp_view(self, sql: str) -> bigquery.TableReference: + """ + Allocates and and creates a view in the anonymous dataset. + The view will be cleaned up by clean_up_tables. + """ + expiration = self._default_expiration() + table = bf_io_bigquery.create_temp_view( + self.bqclient, + self.allocate_temp_table(), + expiration=expiration, + sql=sql, + ) + return bigquery.TableReference.from_string(table) + + def allocate_temp_table(self) -> bigquery.TableReference: + """ + Allocates a unique table id, but does not create the table. + The table will be cleaned up by clean_up_tables. + """ + table_id = self.generate_unique_resource_id() + self._table_ids.append(table_id) + return table_id + + def generate_unique_resource_id(self) -> bigquery.TableReference: + """Generate a random table ID with BigQuery DataFrames prefix. + + This resource will not be cleaned up by this manager. + + Args: + skip_cleanup (bool, default False): + If True, do not add the generated ID to the list of tables + to clean up when the session is closed. + + Returns: + google.cloud.bigquery.TableReference: + Fully qualified table ID of a table that doesn't exist. + """ + now = datetime.datetime.now(datetime.timezone.utc) + random_id = uuid.uuid4().hex + table_id = _TEMP_TABLE_ID_FORMAT.format( + date=now.strftime("%Y%m%d"), session_id=self.session_id, random_id=random_id + ) + return self.dataset.table(table_id) + + def _cleanup_old_udfs(self): + """Clean up old UDFs in the anonymous dataset.""" + dataset = self.dataset + routines = list(self.bqclient.list_routines(dataset)) + cleanup_cutoff_time = datetime.datetime.now( + datetime.timezone.utc + ) - datetime.timedelta(days=_UDF_CLEANUP_THRESHOLD_DAYS) + + for routine in routines: + if ( + routine.created < cleanup_cutoff_time + and routine._properties["routineType"] == "SCALAR_FUNCTION" + ): + try: + self.bqclient.delete_routine( + routine.reference, + not_found_ok=True, + retry=api_core_retry.Retry(timeout=0), + ) + except Exception as e: + msg = bfe.format_message( + f"Unable to clean this old UDF '{routine.reference}': {e}" + ) + warnings.warn(msg, category=bfe.CleanupFailedWarning) + + def close(self): + """Delete tables that were created with this session's session_id.""" + for table_ref in self._table_ids: + self.bqclient.delete_table(table_ref, not_found_ok=True) + self._table_ids.clear() + + try: + # Before closing the session, attempt to clean up any uncollected, + # old Python UDFs residing in the anonymous dataset. These UDFs + # accumulate over time and can eventually exceed resource limits. + # See more from b/450913424. + self._cleanup_old_udfs() + except Exception as e: + # Log a warning on the failure, do not interrupt the workflow. + msg = bfe.format_message( + f"Failed to clean up the old Python UDFs before closing the session: {e}" + ) + warnings.warn(msg, category=bfe.CleanupFailedWarning) diff --git a/bigframes/session/bigquery_session.py b/bigframes/session/bigquery_session.py new file mode 100644 index 0000000000..99c13007d8 --- /dev/null +++ b/bigframes/session/bigquery_session.py @@ -0,0 +1,214 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import datetime +import logging +import threading +from typing import Callable, Optional, Sequence +import uuid + +# TODO: Non-ibis implementation +import bigframes_vendored.ibis.backends.bigquery.datatypes as ibis_bq +import google.cloud.bigquery as bigquery + +from bigframes.core.compile import googlesql +import bigframes.core.events +from bigframes.session import temporary_storage +import bigframes.session._io.bigquery as bfbqio + +KEEPALIVE_QUERY_TIMEOUT_SECONDS = 5.0 + +KEEPALIVE_FREQUENCY = datetime.timedelta(hours=6) + + +logger = logging.getLogger(__name__) + + +class SessionResourceManager(temporary_storage.TemporaryStorageManager): + """ + Responsible for allocating and cleaning up temporary gbq tables used by a BigFrames session. + """ + + def __init__( + self, + bqclient: bigquery.Client, + location: str, + *, + publisher: bigframes.core.events.Publisher, + ): + self.bqclient = bqclient + self._location = location + self._session_id: Optional[str] = None + self._sessiondaemon: Optional[RecurringTaskDaemon] = None + self._session_lock = threading.RLock() + self._publisher = publisher + + @property + def location(self): + return self._location + + def create_temp_table( + self, schema: Sequence[bigquery.SchemaField], cluster_cols: Sequence[str] = [] + ) -> bigquery.TableReference: + """Create a temporary session table. Session is an exclusive resource, so throughput is limited""" + # Can't set a table in _SESSION as destination via query job API, so we + # run DDL, instead. + with self._session_lock: + table_ref = bigquery.TableReference( + bigquery.DatasetReference(self.bqclient.project, "_SESSION"), + f"bqdf_{uuid.uuid4()}", + ) + job_config = bigquery.QueryJobConfig( + connection_properties=[ + bigquery.ConnectionProperty("session_id", self._get_session_id()) + ] + ) + + ibis_schema = ibis_bq.BigQuerySchema.to_ibis(list(schema)) + + fields = [ + f"{googlesql.identifier(name)} {ibis_bq.BigQueryType.from_ibis(ibis_type)}" + for name, ibis_type in ibis_schema.fields.items() + ] + fields_string = ",".join(fields) + + cluster_string = "" + if cluster_cols: + cluster_cols_sql = ", ".join( + f"{googlesql.identifier(cluster_col)}" + for cluster_col in cluster_cols + ) + cluster_string = f"\nCLUSTER BY {cluster_cols_sql}" + + ddl = f"CREATE TEMP TABLE `_SESSION`.{googlesql.identifier(table_ref.table_id)} ({fields_string}){cluster_string}" + + _, job = bfbqio.start_query_with_client( + self.bqclient, + ddl, + job_config=job_config, + location=self.location, + project=None, + timeout=None, + metrics=None, + query_with_job=True, + publisher=self._publisher, + ) + job.result() + # return the fully qualified table, so it can be used outside of the session + destination = job.destination + assert destination is not None, "Failure to create temp table." + return destination + + def close(self): + if self._sessiondaemon is not None: + self._sessiondaemon.stop() + + if self._session_id is not None and self.bqclient is not None: + bfbqio.start_query_with_client( + self.bqclient, + f"CALL BQ.ABORT_SESSION('{self._session_id}')", + job_config=bigquery.QueryJobConfig(), + location=self.location, + project=None, + timeout=None, + metrics=None, + query_with_job=False, + publisher=self._publisher, + ) + + def _get_session_id(self) -> str: + if self._session_id: + return self._session_id + with self._session_lock: + if self._session_id is None: + job_config = bigquery.QueryJobConfig(create_session=True) + # Make sure the session is a new one, not one associated with another query. + job_config.use_query_cache = False + _, query_job = bfbqio.start_query_with_client( + self.bqclient, + "SELECT 1", + job_config=job_config, + location=self.location, + project=None, + timeout=None, + metrics=None, + query_with_job=True, + publisher=self._publisher, + ) + query_job.result() # blocks until finished + assert query_job.session_info is not None + assert query_job.session_info.session_id is not None + self._session_id = query_job.session_info.session_id + self._sessiondaemon = RecurringTaskDaemon( + task=self._keep_session_alive, frequency=KEEPALIVE_FREQUENCY + ) + self._sessiondaemon.start() + return query_job.session_info.session_id + else: + return self._session_id + + def _keep_session_alive(self): + # bq sessions will default expire after 24 hours of disuse, but if queried, this is renewed to a maximum of 7 days + with self._session_lock: + job_config = bigquery.QueryJobConfig( + connection_properties=[ + bigquery.ConnectionProperty("session_id", self._get_session_id()) + ] + ) + try: + bfbqio.start_query_with_client( + self.bqclient, + "SELECT 1", + job_config=job_config, + location=self.location, + project=None, + timeout=KEEPALIVE_QUERY_TIMEOUT_SECONDS, + metrics=None, + query_with_job=False, + publisher=self._publisher, + ) + except Exception as e: + logging.warning("BigQuery session keep-alive query errored : %s", e) + + +class RecurringTaskDaemon: + def __init__(self, task: Callable[[], None], frequency: datetime.timedelta): + self._stop_event = threading.Event() + self._frequency = frequency + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._task = task + + def start(self): + """Start the daemon. Cannot be restarted once stopped.""" + if self._stop_event.is_set(): + raise RuntimeError("Cannot restart daemon thread.") + self._thread.start() + + def _run_loop(self): + while True: + self._stop_event.wait(self._frequency.total_seconds()) + if self._stop_event.is_set(): + return + try: + self._task() + except Exception as e: + logging.warning("RecurringTaskDaemon task errorred: %s", e) + + def stop(self, timeout_seconds: Optional[float] = None): + """Stop and cleanup the daemon.""" + if self._thread.is_alive(): + self._stop_event.set() + self._thread.join(timeout=timeout_seconds) diff --git a/bigframes/session/bq_caching_executor.py b/bigframes/session/bq_caching_executor.py new file mode 100644 index 0000000000..ca19d1be86 --- /dev/null +++ b/bigframes/session/bq_caching_executor.py @@ -0,0 +1,741 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import math +import threading +from typing import Literal, Mapping, Optional, Sequence, Tuple +import weakref + +import google.api_core.exceptions +from google.cloud import bigquery +import google.cloud.bigquery.job as bq_job +import google.cloud.bigquery.table as bq_table +import google.cloud.bigquery_storage_v1 + +import bigframes +from bigframes import exceptions as bfe +import bigframes.constants +import bigframes.core +from bigframes.core import bq_data, compile, local_data, rewrite +import bigframes.core.compile.sqlglot.sqlglot_ir as sqlglot_ir +import bigframes.core.events +import bigframes.core.guid +import bigframes.core.identifiers +import bigframes.core.nodes as nodes +import bigframes.core.schema as schemata +import bigframes.core.tree_properties as tree_properties +import bigframes.dtypes +from bigframes.session import ( + executor, + loader, + local_scan_executor, + read_api_execution, + semi_executor, +) +import bigframes.session._io.bigquery as bq_io +import bigframes.session.execution_spec as ex_spec +import bigframes.session.metrics +import bigframes.session.planner +import bigframes.session.temporary_storage + +# Max complexity that should be executed as a single query +QUERY_COMPLEXITY_LIMIT = 1e7 +# Number of times to factor out subqueries before giving up. +MAX_SUBTREE_FACTORINGS = 5 +_MAX_CLUSTER_COLUMNS = 4 +MAX_SMALL_RESULT_BYTES = 10 * 1024 * 1024 * 1024 # 10G + +SourceIdMapping = Mapping[str, str] + + +class ExecutionCache: + def __init__(self): + # current assumption is only 1 cache of a given node + # in future, might have multiple caches, with different layout, localities + self._cached_executions: weakref.WeakKeyDictionary[ + nodes.BigFrameNode, nodes.CachedTableNode + ] = weakref.WeakKeyDictionary() + self._uploaded_local_data: weakref.WeakKeyDictionary[ + local_data.ManagedArrowTable, + tuple[bq_data.BigqueryDataSource, SourceIdMapping], + ] = weakref.WeakKeyDictionary() + + @property + def mapping(self) -> Mapping[nodes.BigFrameNode, nodes.BigFrameNode]: + return self._cached_executions + + def cache_results_table( + self, + original_root: nodes.BigFrameNode, + data: bq_data.BigqueryDataSource, + ): + # Assumption: GBQ cached table uses field name as bq column name + scan_list = nodes.ScanList( + tuple( + nodes.ScanItem(field.id, field.id.sql) for field in original_root.fields + ) + ) + cached_replacement = nodes.CachedTableNode( + source=data, + scan_list=scan_list, + table_session=original_root.session, + original_node=original_root, + ) + assert original_root.schema == cached_replacement.schema + self._cached_executions[original_root] = cached_replacement + + def cache_remote_replacement( + self, + local_data: local_data.ManagedArrowTable, + bq_data: bq_data.BigqueryDataSource, + ): + # bq table has one extra column for offsets, those are implicit for local data + assert len(local_data.schema.items) + 1 == len(bq_data.table.physical_schema) + mapping = { + local_data.schema.items[i].column: bq_data.table.physical_schema[i].name + for i in range(len(local_data.schema)) + } + self._uploaded_local_data[local_data] = (bq_data, mapping) + + +class BigQueryCachingExecutor(executor.Executor): + """Computes BigFrames values using BigQuery Engine. + + This executor can cache expressions. If those expressions are executed later, this session + will re-use the pre-existing results from previous executions. + + This class is not thread-safe. + """ + + def __init__( + self, + bqclient: bigquery.Client, + storage_manager: bigframes.session.temporary_storage.TemporaryStorageManager, + bqstoragereadclient: google.cloud.bigquery_storage_v1.BigQueryReadClient, + loader: loader.GbqDataLoader, + *, + strictly_ordered: bool = True, + metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, + enable_polars_execution: bool = False, + publisher: bigframes.core.events.Publisher, + ): + self.bqclient = bqclient + self.storage_manager = storage_manager + self.strictly_ordered: bool = strictly_ordered + self.cache: ExecutionCache = ExecutionCache() + self.metrics = metrics + self.loader = loader + self.bqstoragereadclient = bqstoragereadclient + self._enable_polars_execution = enable_polars_execution + self._publisher = publisher + + # TODO(tswast): Send events from semi-executors, too. + self._semi_executors: Sequence[semi_executor.SemiExecutor] = ( + read_api_execution.ReadApiSemiExecutor( + bqstoragereadclient=bqstoragereadclient, + project=self.bqclient.project, + ), + local_scan_executor.LocalScanExecutor(), + ) + if enable_polars_execution: + from bigframes.session import polars_executor + + self._semi_executors = ( + *self._semi_executors, + polars_executor.PolarsExecutor(), + ) + self._upload_lock = threading.Lock() + + def to_sql( + self, + array_value: bigframes.core.ArrayValue, + offset_column: Optional[str] = None, + ordered: bool = False, + enable_cache: bool = True, + ) -> str: + if offset_column: + array_value, _ = array_value.promote_offsets() + node = ( + self.prepare_plan(array_value.node, target="simplify") + if enable_cache + else array_value.node + ) + node = self._substitute_large_local_sources(node) + compiled = compile.compile_sql(compile.CompileRequest(node, sort_rows=ordered)) + return compiled.sql + + def execute( + self, + array_value: bigframes.core.ArrayValue, + execution_spec: ex_spec.ExecutionSpec, + ) -> executor.ExecuteResult: + self._publisher.publish(bigframes.core.events.ExecutionStarted()) + + # TODO: Support export jobs in combination with semi executors + if execution_spec.destination_spec is None: + plan = self.prepare_plan(array_value.node, target="simplify") + for exec in self._semi_executors: + maybe_result = exec.execute( + plan, ordered=execution_spec.ordered, peek=execution_spec.peek + ) + if maybe_result: + self._publisher.publish( + bigframes.core.events.ExecutionFinished( + result=maybe_result, + ) + ) + return maybe_result + + if isinstance(execution_spec.destination_spec, ex_spec.TableOutputSpec): + if execution_spec.peek or execution_spec.ordered: + raise NotImplementedError( + "Ordering and peeking not supported for gbq export" + ) + # separate path for export_gbq, as it has all sorts of annoying logic, such as possibly running as dml + result = self._export_gbq(array_value, execution_spec.destination_spec) + self._publisher.publish( + bigframes.core.events.ExecutionFinished( + result=result, + ) + ) + return result + + result = self._execute_plan_gbq( + array_value.node, + ordered=execution_spec.ordered, + peek=execution_spec.peek, + cache_spec=execution_spec.destination_spec + if isinstance(execution_spec.destination_spec, ex_spec.CacheSpec) + else None, + must_create_table=not execution_spec.promise_under_10gb, + ) + # post steps: export + if isinstance(execution_spec.destination_spec, ex_spec.GcsOutputSpec): + self._export_result_gcs(result, execution_spec.destination_spec) + + self._publisher.publish( + bigframes.core.events.ExecutionFinished( + result=result, + ) + ) + return result + + def _export_result_gcs( + self, result: executor.ExecuteResult, gcs_export_spec: ex_spec.GcsOutputSpec + ): + query_job = result.query_job + assert query_job is not None + result_table = query_job.destination + assert result_table is not None + export_data_statement = bq_io.create_export_data_statement( + f"{result_table.project}.{result_table.dataset_id}.{result_table.table_id}", + uri=gcs_export_spec.uri, + format=gcs_export_spec.format, + export_options=dict(gcs_export_spec.export_options), + ) + bq_io.start_query_with_client( + self.bqclient, + export_data_statement, + job_config=bigquery.QueryJobConfig(), + metrics=self.metrics, + project=None, + location=None, + timeout=None, + query_with_job=True, + publisher=self._publisher, + ) + + def _maybe_find_existing_table( + self, spec: ex_spec.TableOutputSpec + ) -> Optional[bigquery.Table]: + # validate destination table + try: + table = self.bqclient.get_table(spec.table) + if spec.if_exists == "fail": + raise ValueError(f"Table already exists: {spec.table.__str__()}") + + if len(spec.cluster_cols) != 0: + if (table.clustering_fields is None) or ( + tuple(table.clustering_fields) != spec.cluster_cols + ): + raise ValueError( + "Table clustering fields cannot be changed after the table has " + f"been created. Requested clustering fields: {spec.cluster_cols}, existing clustering fields: {table.clustering_fields}" + ) + return table + except google.api_core.exceptions.NotFound: + return None + + def _export_gbq( + self, array_value: bigframes.core.ArrayValue, spec: ex_spec.TableOutputSpec + ) -> executor.ExecuteResult: + """ + Export the ArrayValue to an existing BigQuery table. + """ + plan = self.prepare_plan(array_value.node, target="bq_execution") + + # validate destination table + existing_table = self._maybe_find_existing_table(spec) + + compiled = compile.compile_sql(compile.CompileRequest(plan, sort_rows=False)) + sql = compiled.sql + + if (existing_table is not None) and _if_schema_match( + existing_table.schema, array_value.schema + ): + # b/409086472: Uses DML for table appends and replacements to avoid + # BigQuery `RATE_LIMIT_EXCEEDED` errors, as per quota limits: + # https://cloud.google.com/bigquery/quotas#standard_tables + job_config = bigquery.QueryJobConfig() + ir = sqlglot_ir.SQLGlotIR.from_query_string(sql) + if spec.if_exists == "append": + sql = ir.insert(spec.table) + else: # for "replace" + assert spec.if_exists == "replace" + sql = ir.replace(spec.table) + else: + dispositions = { + "fail": bigquery.WriteDisposition.WRITE_EMPTY, + "replace": bigquery.WriteDisposition.WRITE_TRUNCATE, + "append": bigquery.WriteDisposition.WRITE_APPEND, + } + job_config = bigquery.QueryJobConfig( + write_disposition=dispositions[spec.if_exists], + destination=spec.table, + clustering_fields=spec.cluster_cols if spec.cluster_cols else None, + ) + + # TODO(swast): plumb through the api_name of the user-facing api that + # caused this query. + iterator, job = self._run_execute_query( + sql=sql, + job_config=job_config, + session=array_value.session, + ) + + has_timedelta_col = any( + t == bigframes.dtypes.TIMEDELTA_DTYPE for t in array_value.schema.dtypes + ) + + if spec.if_exists != "append" and has_timedelta_col: + # Only update schema if this is not modifying an existing table, and the + # new table contains timedelta columns. + table = self.bqclient.get_table(spec.table) + table.schema = array_value.schema.to_bigquery() + self.bqclient.update_table(table, ["schema"]) + + return executor.EmptyExecuteResult( + bf_schema=array_value.schema, + execution_metadata=executor.ExecutionMetadata.from_iterator_and_job( + iterator, job + ), + ) + + def dry_run( + self, array_value: bigframes.core.ArrayValue, ordered: bool = True + ) -> bigquery.QueryJob: + sql = self.to_sql(array_value, ordered=ordered) + job_config = bigquery.QueryJobConfig(dry_run=True) + query_job = self.bqclient.query(sql, job_config=job_config) + return query_job + + def cached( + self, array_value: bigframes.core.ArrayValue, *, config: executor.CacheConfig + ) -> None: + """Write the block to a session table.""" + # First, see if we can reuse the existing cache + # TODO(b/415105423): Provide feedback to user on whether new caching action was deemed necessary + # TODO(b/415105218): Make cached a deferred action + if config.if_cached == "reuse-any": + if self._is_trivially_executable(array_value): + return + elif config.if_cached == "reuse-strict": + # This path basically exists to make sure that repr in head mode is optimized for subsequent repr operations. + if config.optimize_for == "head": + if tree_properties.can_fast_head(array_value.node): + return + else: + raise NotImplementedError( + "if_cached='reuse-strict' currently only supported with optimize_for='head'" + ) + elif config.if_cached != "replace": + raise ValueError(f"Unexpected 'if_cached' arg: {config.if_cached}") + + if config.optimize_for == "auto": + self._cache_with_session_awareness(array_value) + elif config.optimize_for == "head": + self._cache_with_offsets(array_value) + else: + assert isinstance(config.optimize_for, executor.HierarchicalKey) + self._cache_with_cluster_cols( + array_value, cluster_cols=config.optimize_for.columns + ) + + # Helpers + def _run_execute_query( + self, + sql: str, + job_config: Optional[bq_job.QueryJobConfig] = None, + query_with_job: bool = True, + session=None, + ) -> Tuple[bq_table.RowIterator, Optional[bigquery.QueryJob]]: + """ + Starts BigQuery query job and waits for results. + """ + job_config = bq_job.QueryJobConfig() if job_config is None else job_config + if bigframes.options.compute.maximum_bytes_billed is not None: + job_config.maximum_bytes_billed = ( + bigframes.options.compute.maximum_bytes_billed + ) + + if not self.strictly_ordered: + job_config.labels["bigframes-mode"] = "unordered" + + try: + # Trick the type checker into thinking we got a literal. + if query_with_job: + return bq_io.start_query_with_client( + self.bqclient, + sql, + job_config=job_config, + metrics=self.metrics, + project=None, + location=None, + timeout=None, + query_with_job=True, + publisher=self._publisher, + session=session, + ) + else: + return bq_io.start_query_with_client( + self.bqclient, + sql, + job_config=job_config, + metrics=self.metrics, + project=None, + location=None, + timeout=None, + query_with_job=False, + publisher=self._publisher, + session=session, + ) + + except google.api_core.exceptions.BadRequest as e: + # Unfortunately, this error type does not have a separate error code or exception type + if "Resources exceeded during query execution" in e.message: + new_message = "Computation is too complex to execute as a single query. Try using DataFrame.cache() on intermediate results, or setting bigframes.options.compute.enable_multi_query_execution." + raise bfe.QueryComplexityError(new_message) from e + else: + raise + + def replace_cached_subtrees(self, node: nodes.BigFrameNode) -> nodes.BigFrameNode: + return nodes.top_down(node, lambda x: self.cache.mapping.get(x, x)) + + def _is_trivially_executable(self, array_value: bigframes.core.ArrayValue): + """ + Can the block be evaluated very cheaply? + If True, the array_value probably is not worth caching. + """ + # Once rewriting is available, will want to rewrite before + # evaluating execution cost. + return tree_properties.is_trivially_executable( + self.prepare_plan(array_value.node) + ) + + def prepare_plan( + self, + plan: nodes.BigFrameNode, + target: Literal["simplify", "bq_execution"] = "simplify", + ) -> nodes.BigFrameNode: + """ + Prepare the plan by simplifying it with caches, removing unused operators. Has modes for different contexts. + + "simplify" removes unused operations and subsitutes subtrees with their previously cached equivalents + "bq_execution" is the most heavy option, preparing the plan for bq execution by also caching subtrees, uploading large local sources + """ + # TODO: We should model plan decomposition and data uploading as work steps rather than as plan preparation. + if ( + target == "bq_execution" + and bigframes.options.compute.enable_multi_query_execution + ): + self._simplify_with_caching(plan) + + plan = self.replace_cached_subtrees(plan) + plan = rewrite.column_pruning(plan) + plan = plan.top_down(rewrite.fold_row_counts) + + if target == "bq_execution": + plan = self._substitute_large_local_sources(plan) + + return plan + + def _cache_with_cluster_cols( + self, array_value: bigframes.core.ArrayValue, cluster_cols: Sequence[str] + ): + """Executes the query and uses the resulting table to rewrite future executions.""" + execution_spec = ex_spec.ExecutionSpec( + destination_spec=ex_spec.CacheSpec(cluster_cols=tuple(cluster_cols)) + ) + self.execute( + array_value, + execution_spec=execution_spec, + ) + + def _cache_with_offsets(self, array_value: bigframes.core.ArrayValue): + """Executes the query and uses the resulting table to rewrite future executions.""" + execution_spec = ex_spec.ExecutionSpec( + destination_spec=ex_spec.CacheSpec(cluster_cols=tuple()) + ) + self.execute( + array_value, + execution_spec=execution_spec, + ) + + def _cache_with_session_awareness( + self, + array_value: bigframes.core.ArrayValue, + ) -> None: + session_forest = [obj._block._expr.node for obj in array_value.session.objects] + # These node types are cheap to re-compute + target, cluster_cols = bigframes.session.planner.session_aware_cache_plan( + array_value.node, list(session_forest) + ) + cluster_cols_sql_names = [id.sql for id in cluster_cols] + if len(cluster_cols) > 0: + self._cache_with_cluster_cols( + bigframes.core.ArrayValue(target), cluster_cols_sql_names + ) + elif self.strictly_ordered: + self._cache_with_offsets(bigframes.core.ArrayValue(target)) + else: + self._cache_with_cluster_cols(bigframes.core.ArrayValue(target), []) + + def _simplify_with_caching(self, plan: nodes.BigFrameNode): + """Attempts to handle the complexity by caching duplicated subtrees and breaking the query into pieces.""" + # Apply existing caching first + for _ in range(MAX_SUBTREE_FACTORINGS): + if ( + self.prepare_plan(plan, "simplify").planning_complexity + < QUERY_COMPLEXITY_LIMIT + ): + return + + did_cache = self._cache_most_complex_subtree(plan) + if not did_cache: + return + + def _cache_most_complex_subtree(self, node: nodes.BigFrameNode) -> bool: + # TODO: If query fails, retry with lower complexity limit + selection = tree_properties.select_cache_target( + node, + min_complexity=(QUERY_COMPLEXITY_LIMIT / 500), + max_complexity=QUERY_COMPLEXITY_LIMIT, + cache=dict(self.cache.mapping), + # Heuristic: subtree_compleixty * (copies of subtree)^2 + heuristic=lambda complexity, count: math.log(complexity) + + 2 * math.log(count), + ) + if selection is None: + # No good subtrees to cache, just return original tree + return False + + self._cache_with_cluster_cols(bigframes.core.ArrayValue(selection), []) + return True + + def _substitute_large_local_sources(self, original_root: nodes.BigFrameNode): + """ + Replace large local sources with the uploaded version of those datasources. + """ + # Step 1: Upload all previously un-uploaded data + for leaf in original_root.unique_nodes(): + if isinstance(leaf, nodes.ReadLocalNode): + if ( + leaf.local_data_source.metadata.total_bytes + > bigframes.constants.MAX_INLINE_BYTES + ): + self._upload_local_data(leaf.local_data_source) + + # Step 2: Replace local scans with remote scans + def map_local_scans(node: nodes.BigFrameNode): + if not isinstance(node, nodes.ReadLocalNode): + return node + if node.local_data_source not in self.cache._uploaded_local_data: + return node + bq_source, source_mapping = self.cache._uploaded_local_data[ + node.local_data_source + ] + scan_list = node.scan_list.remap_source_ids(source_mapping) + # offsets_col isn't part of ReadTableNode, so emulate by adding to end of scan_list + if node.offsets_col is not None: + # Offsets are always implicitly the final column of uploaded data + # See: Loader.load_data + scan_list = scan_list.append( + bq_source.table.physical_schema[-1].name, + bigframes.dtypes.INT_DTYPE, + node.offsets_col, + ) + return nodes.ReadTableNode(bq_source, scan_list, node.session) + + return original_root.bottom_up(map_local_scans) + + def _upload_local_data(self, local_table: local_data.ManagedArrowTable): + if local_table in self.cache._uploaded_local_data: + return + # Lock prevents concurrent repeated work, but slows things down. + # Might be better as a queue and a worker thread + with self._upload_lock: + if local_table not in self.cache._uploaded_local_data: + uploaded = self.loader.load_data( + local_table, bigframes.core.guid.generate_guid() + ) + self.cache.cache_remote_replacement(local_table, uploaded) + + def _execute_plan_gbq( + self, + plan: nodes.BigFrameNode, + ordered: bool, + peek: Optional[int] = None, + cache_spec: Optional[ex_spec.CacheSpec] = None, + must_create_table: bool = True, + ) -> executor.ExecuteResult: + """Just execute whatever plan as is, without further caching or decomposition.""" + # TODO(swast): plumb through the api_name of the user-facing api that + # caused this query. + + og_plan = plan + og_schema = plan.schema + + plan = self.prepare_plan(plan, target="bq_execution") + create_table = must_create_table + cluster_cols: Sequence[str] = [] + if cache_spec is not None: + if peek is not None: + raise ValueError("peek is not compatible with caching.") + + create_table = True + if not cache_spec.cluster_cols: + + offsets_id = bigframes.core.identifiers.ColumnId( + bigframes.core.guid.generate_guid() + ) + plan = nodes.PromoteOffsetsNode(plan, offsets_id) + cluster_cols = [offsets_id.sql] + else: + cluster_cols = [ + col + for col in cache_spec.cluster_cols + if bigframes.dtypes.is_clusterable(plan.schema.get_type(col)) + ] + cluster_cols = cluster_cols[:_MAX_CLUSTER_COLUMNS] + + compiled = compile.compile_sql( + compile.CompileRequest( + plan, + sort_rows=ordered, + peek_count=peek, + materialize_all_order_keys=(cache_spec is not None), + ) + ) + # might have more columns than og schema, for hidden ordering columns + compiled_schema = compiled.sql_schema + + destination_table: Optional[bigquery.TableReference] = None + + job_config = bigquery.QueryJobConfig() + if create_table: + destination_table = self.storage_manager.create_temp_table( + compiled_schema, cluster_cols + ) + job_config.destination = destination_table + + iterator, query_job = self._run_execute_query( + sql=compiled.sql, + job_config=job_config, + query_with_job=(destination_table is not None), + session=plan.session, + ) + + # we could actually cache even when caching is not explicitly requested, but being conservative for now + result_bq_data = None + if query_job and query_job.destination: + # we might add extra sql columns in compilation, esp if caching w ordering, infer a bigframes type for them + result_bf_schema = _result_schema(og_schema, list(compiled.sql_schema)) + dst = query_job.destination + result_bq_data = bq_data.BigqueryDataSource( + table=bq_data.GbqTable( + dst.project, + dst.dataset_id, + dst.table_id, + tuple(compiled_schema), + is_physically_stored=True, + cluster_cols=tuple(cluster_cols), + ), + schema=result_bf_schema, + ordering=compiled.row_order, + n_rows=iterator.total_rows, + ) + + if cache_spec is not None: + assert result_bq_data is not None + assert compiled.row_order is not None + self.cache.cache_results_table(og_plan, result_bq_data) + + execution_metadata = executor.ExecutionMetadata.from_iterator_and_job( + iterator, query_job + ) + result_mostly_cached = ( + hasattr(iterator, "_is_almost_completely_cached") + and iterator._is_almost_completely_cached() + ) + if result_bq_data is not None and not result_mostly_cached: + return executor.BQTableExecuteResult( + data=result_bq_data, + project_id=self.bqclient.project, + storage_client=self.bqstoragereadclient, + execution_metadata=execution_metadata, + selected_fields=tuple((col, col) for col in og_schema.names), + ) + else: + return executor.LocalExecuteResult( + data=iterator.to_arrow().select(og_schema.names), + bf_schema=plan.schema, + execution_metadata=execution_metadata, + ) + + +def _result_schema( + logical_schema: schemata.ArraySchema, sql_schema: list[bigquery.SchemaField] +) -> schemata.ArraySchema: + inferred_schema = bigframes.dtypes.bf_type_from_type_kind(sql_schema) + inferred_schema.update(logical_schema._mapping) + return schemata.ArraySchema( + tuple(schemata.SchemaItem(col, dtype) for col, dtype in inferred_schema.items()) + ) + + +def _if_schema_match( + table_schema: Tuple[bigquery.SchemaField, ...], schema: schemata.ArraySchema +) -> bool: + if len(table_schema) != len(schema.items): + return False + for field in table_schema: + if field.name not in schema.names: + return False + if bigframes.dtypes.convert_schema_field(field)[1] != schema.get_type( + field.name + ): + return False + return True diff --git a/bigframes/session/clients.py b/bigframes/session/clients.py index 1b487d0277..2a5c9d6499 100644 --- a/bigframes/session/clients.py +++ b/bigframes/session/clients.py @@ -15,44 +15,58 @@ """Clients manages the connection to Google APIs.""" import os +import threading import typing -from typing import Optional +from typing import Optional, Sequence, Tuple import google.api_core.client_info import google.api_core.client_options -import google.api_core.exceptions import google.api_core.gapic_v1.client_info import google.auth.credentials +import google.auth.transport.requests import google.cloud.bigquery as bigquery import google.cloud.bigquery_connection_v1 import google.cloud.bigquery_storage_v1 import google.cloud.functions_v2 import google.cloud.resourcemanager_v3 -import pydata_google_auth +import google.cloud.storage # type: ignore +import requests +import bigframes._config.auth import bigframes.constants import bigframes.version +from . import environment + _ENV_DEFAULT_PROJECT = "GOOGLE_CLOUD_PROJECT" _APPLICATION_NAME = f"bigframes/{bigframes.version.__version__} ibis/9.2.0" -_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"] # BigQuery is a REST API, which requires the protocol as part of the URL. -_BIGQUERY_LOCATIONAL_ENDPOINT = "https://{location}-bigquery.googleapis.com" _BIGQUERY_REGIONAL_ENDPOINT = "https://bigquery.{location}.rep.googleapis.com" # BigQuery Connection and Storage are gRPC APIs, which don't support the # https:// protocol in the API endpoint URL. -_BIGQUERYCONNECTION_LOCATIONAL_ENDPOINT = "{location}-bigqueryconnection.googleapis.com" -_BIGQUERYSTORAGE_LOCATIONAL_ENDPOINT = "{location}-bigquerystorage.googleapis.com" -_BIGQUERYSTORAGE_REGIONAL_ENDPOINT = ( - "https://bigquerystorage.{location}.rep.googleapis.com" -) +_BIGQUERYSTORAGE_REGIONAL_ENDPOINT = "bigquerystorage.{location}.rep.googleapis.com" def _get_default_credentials_with_project(): - return pydata_google_auth.default(scopes=_SCOPES, use_local_webserver=False) + return bigframes._config.auth.get_default_credentials_with_project() + + +def _get_application_names(): + apps = [_APPLICATION_NAME] + + if environment.is_vscode(): + apps.append("vscode") + if environment.is_vscode_google_cloud_code_extension_installed(): + apps.append(environment.GOOGLE_CLOUD_CODE_EXTENSION_NAME) + elif environment.is_jupyter(): + apps.append("jupyter") + if environment.is_jupyter_bigquery_plugin_installed(): + apps.append(environment.BIGQUERY_JUPYTER_PLUGIN_NAME) + + return " ".join(apps) class ClientsProvider: @@ -67,6 +81,10 @@ def __init__( application_name: Optional[str] = None, bq_kms_key_name: Optional[str] = None, client_endpoints_override: dict = {}, + *, + requests_transport_adapters: Sequence[ + Tuple[str, requests.adapters.BaseAdapter] + ] = (), ): credentials_project = None if credentials is None: @@ -89,60 +107,104 @@ def __init__( ) self._application_name = ( - f"{_APPLICATION_NAME} {application_name}" + f"{_get_application_names()} {application_name}" if application_name - else _APPLICATION_NAME + else _get_application_names() ) self._project = project + + if use_regional_endpoints: + if location is None: + raise ValueError(bigframes.constants.LOCATION_NEEDED_FOR_REP_MESSAGE) + elif ( + location.lower() + not in bigframes.constants.REP_ENABLED_BIGQUERY_LOCATIONS + ): + raise ValueError( + bigframes.constants.REP_NOT_SUPPORTED_MESSAGE.format( + location=location + ) + ) self._location = location self._use_regional_endpoints = use_regional_endpoints + self._requests_transport_adapters = requests_transport_adapters + self._credentials = credentials self._bq_kms_key_name = bq_kms_key_name self._client_endpoints_override = client_endpoints_override # cloud clients initialized for lazy load + self._bqclient_lock = threading.Lock() self._bqclient = None + + self._bqconnectionclient_lock = threading.Lock() self._bqconnectionclient: Optional[ google.cloud.bigquery_connection_v1.ConnectionServiceClient ] = None + + self._bqstoragereadclient_lock = threading.Lock() self._bqstoragereadclient: Optional[ google.cloud.bigquery_storage_v1.BigQueryReadClient ] = None + + self._bqstoragewriteclient_lock = threading.Lock() + self._bqstoragewriteclient: Optional[ + google.cloud.bigquery_storage_v1.BigQueryWriteClient + ] = None + + self._cloudfunctionsclient_lock = threading.Lock() self._cloudfunctionsclient: Optional[ google.cloud.functions_v2.FunctionServiceClient ] = None + + self._resourcemanagerclient_lock = threading.Lock() self._resourcemanagerclient: Optional[ google.cloud.resourcemanager_v3.ProjectsClient ] = None + self._storageclient_lock = threading.Lock() + self._storageclient: Optional[google.cloud.storage.Client] = None + def _create_bigquery_client(self): bq_options = None - if self._use_regional_endpoints: - bq_options = google.api_core.client_options.ClientOptions( - api_endpoint=( - _BIGQUERY_REGIONAL_ENDPOINT - if self._location is not None - and self._location.lower() - in bigframes.constants.REP_ENABLED_BIGQUERY_LOCATIONS - else _BIGQUERY_LOCATIONAL_ENDPOINT - ).format(location=self._location), - ) if "bqclient" in self._client_endpoints_override: bq_options = google.api_core.client_options.ClientOptions( api_endpoint=self._client_endpoints_override["bqclient"] ) + elif self._use_regional_endpoints: + bq_options = google.api_core.client_options.ClientOptions( + api_endpoint=_BIGQUERY_REGIONAL_ENDPOINT.format(location=self._location) + ) bq_info = google.api_core.client_info.ClientInfo( user_agent=self._application_name ) + requests_session = google.auth.transport.requests.AuthorizedSession( + self._credentials + ) + for prefix, adapter in self._requests_transport_adapters: + requests_session.mount(prefix, adapter) + bq_client = bigquery.Client( client_info=bq_info, client_options=bq_options, - credentials=self._credentials, project=self._project, location=self._location, + # Use _http so that users can override + # requests options with transport adapters. See internal issue + # b/419106112. + _http=requests_session, + credentials=self._credentials, ) + + # If a new enough client library is available, we opt-in to the faster + # backend behavior. This only affects code paths where query_and_wait is + # used, which doesn't expose a query job directly. See internal issue + # b/417985981. + if hasattr(bq_client, "default_job_creation_mode"): + bq_client.default_job_creation_mode = "JOB_CREATION_OPTIONAL" + if self._bq_kms_key_name: # Note: Key configuration only applies automatically to load and query jobs, not copy jobs. encryption_config = bigquery.EncryptionConfiguration( @@ -163,96 +225,140 @@ def _create_bigquery_client(self): @property def bqclient(self): - if not self._bqclient: - self._bqclient = self._create_bigquery_client() + with self._bqclient_lock: + if not self._bqclient: + self._bqclient = self._create_bigquery_client() return self._bqclient @property def bqconnectionclient(self): - if not self._bqconnectionclient: - bqconnection_options = None - if self._use_regional_endpoints: - bqconnection_options = google.api_core.client_options.ClientOptions( - api_endpoint=_BIGQUERYCONNECTION_LOCATIONAL_ENDPOINT.format( - location=self._location + with self._bqconnectionclient_lock: + if not self._bqconnectionclient: + bqconnection_options = None + if "bqconnectionclient" in self._client_endpoints_override: + bqconnection_options = google.api_core.client_options.ClientOptions( + api_endpoint=self._client_endpoints_override[ + "bqconnectionclient" + ] ) - ) - if "bqconnectionclient" in self._client_endpoints_override: - bqconnection_options = google.api_core.client_options.ClientOptions( - api_endpoint=self._client_endpoints_override["bqconnectionclient"] - ) - bqconnection_info = google.api_core.gapic_v1.client_info.ClientInfo( - user_agent=self._application_name - ) - self._bqconnectionclient = ( - google.cloud.bigquery_connection_v1.ConnectionServiceClient( - client_info=bqconnection_info, - client_options=bqconnection_options, - credentials=self._credentials, + bqconnection_info = google.api_core.gapic_v1.client_info.ClientInfo( + user_agent=self._application_name + ) + self._bqconnectionclient = ( + google.cloud.bigquery_connection_v1.ConnectionServiceClient( + client_info=bqconnection_info, + client_options=bqconnection_options, + credentials=self._credentials, + ) ) - ) return self._bqconnectionclient @property def bqstoragereadclient(self): - if not self._bqstoragereadclient: - bqstorage_options = None - if self._use_regional_endpoints: - bqstorage_options = google.api_core.client_options.ClientOptions( - api_endpoint=( - _BIGQUERYSTORAGE_REGIONAL_ENDPOINT - if self._location is not None - and self._location.lower() - in bigframes.constants.REP_ENABLED_BIGQUERY_LOCATIONS - else _BIGQUERYSTORAGE_LOCATIONAL_ENDPOINT - ).format(location=self._location), - ) + with self._bqstoragereadclient_lock: + if not self._bqstoragereadclient: + bqstorage_options = None + if "bqstoragereadclient" in self._client_endpoints_override: + bqstorage_options = google.api_core.client_options.ClientOptions( + api_endpoint=self._client_endpoints_override[ + "bqstoragereadclient" + ] + ) + elif self._use_regional_endpoints: + bqstorage_options = google.api_core.client_options.ClientOptions( + api_endpoint=_BIGQUERYSTORAGE_REGIONAL_ENDPOINT.format( + location=self._location + ) + ) - if "bqstoragereadclient" in self._client_endpoints_override: - bqstorage_options = google.api_core.client_options.ClientOptions( - api_endpoint=self._client_endpoints_override["bqstoragereadclient"] + bqstorage_info = google.api_core.gapic_v1.client_info.ClientInfo( + user_agent=self._application_name ) - bqstorage_info = google.api_core.gapic_v1.client_info.ClientInfo( - user_agent=self._application_name - ) - self._bqstoragereadclient = ( - google.cloud.bigquery_storage_v1.BigQueryReadClient( - client_info=bqstorage_info, - client_options=bqstorage_options, - credentials=self._credentials, + self._bqstoragereadclient = ( + google.cloud.bigquery_storage_v1.BigQueryReadClient( + client_info=bqstorage_info, + client_options=bqstorage_options, + credentials=self._credentials, + ) ) - ) return self._bqstoragereadclient + @property + def bqstoragewriteclient(self): + with self._bqstoragewriteclient_lock: + if not self._bqstoragewriteclient: + bqstorage_options = None + if "bqstoragewriteclient" in self._client_endpoints_override: + bqstorage_options = google.api_core.client_options.ClientOptions( + api_endpoint=self._client_endpoints_override[ + "bqstoragewriteclient" + ] + ) + elif self._use_regional_endpoints: + bqstorage_options = google.api_core.client_options.ClientOptions( + api_endpoint=_BIGQUERYSTORAGE_REGIONAL_ENDPOINT.format( + location=self._location + ) + ) + + bqstorage_info = google.api_core.gapic_v1.client_info.ClientInfo( + user_agent=self._application_name + ) + self._bqstoragewriteclient = ( + google.cloud.bigquery_storage_v1.BigQueryWriteClient( + client_info=bqstorage_info, + client_options=bqstorage_options, + credentials=self._credentials, + ) + ) + + return self._bqstoragewriteclient + @property def cloudfunctionsclient(self): - if not self._cloudfunctionsclient: - functions_info = google.api_core.gapic_v1.client_info.ClientInfo( - user_agent=self._application_name - ) - self._cloudfunctionsclient = ( - google.cloud.functions_v2.FunctionServiceClient( - client_info=functions_info, - credentials=self._credentials, + with self._cloudfunctionsclient_lock: + if not self._cloudfunctionsclient: + functions_info = google.api_core.gapic_v1.client_info.ClientInfo( + user_agent=self._application_name + ) + self._cloudfunctionsclient = ( + google.cloud.functions_v2.FunctionServiceClient( + client_info=functions_info, + credentials=self._credentials, + ) ) - ) return self._cloudfunctionsclient @property def resourcemanagerclient(self): - if not self._resourcemanagerclient: - resourcemanager_info = google.api_core.gapic_v1.client_info.ClientInfo( - user_agent=self._application_name - ) - self._resourcemanagerclient = ( - google.cloud.resourcemanager_v3.ProjectsClient( - credentials=self._credentials, client_info=resourcemanager_info + with self._resourcemanagerclient_lock: + if not self._resourcemanagerclient: + resourcemanager_info = google.api_core.gapic_v1.client_info.ClientInfo( + user_agent=self._application_name + ) + self._resourcemanagerclient = ( + google.cloud.resourcemanager_v3.ProjectsClient( + credentials=self._credentials, client_info=resourcemanager_info + ) ) - ) return self._resourcemanagerclient + + @property + def storageclient(self): + with self._storageclient_lock: + if not self._storageclient: + storage_info = google.api_core.client_info.ClientInfo( + user_agent=self._application_name + ) + self._storageclient = google.cloud.storage.Client( + client_info=storage_info, + credentials=self._credentials, + ) + + return self._storageclient diff --git a/bigframes/session/direct_gbq_execution.py b/bigframes/session/direct_gbq_execution.py new file mode 100644 index 0000000000..3ec10bf20f --- /dev/null +++ b/bigframes/session/direct_gbq_execution.py @@ -0,0 +1,95 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from typing import Literal, Optional, Tuple + +from google.cloud import bigquery +import google.cloud.bigquery.job as bq_job +import google.cloud.bigquery.table as bq_table + +from bigframes.core import compile, nodes +from bigframes.core.compile import sqlglot +import bigframes.core.events +from bigframes.session import executor, semi_executor +import bigframes.session._io.bigquery as bq_io + + +# used only in testing right now, BigQueryCachingExecutor is the fully featured engine +# simplified, doesnt not do large >10 gb result queries, error handling, respect global config +# or record metrics. Also avoids caching, and most pre-compile rewrites, to better serve as a +# reference for validating more complex executors. +class DirectGbqExecutor(semi_executor.SemiExecutor): + def __init__( + self, + bqclient: bigquery.Client, + compiler: Literal["ibis", "sqlglot"] = "ibis", + *, + publisher: bigframes.core.events.Publisher, + ): + self.bqclient = bqclient + self._compile_fn = ( + compile.compile_sql if compiler == "ibis" else sqlglot.compile_sql + ) + self._publisher = publisher + + def execute( + self, + plan: nodes.BigFrameNode, + ordered: bool, + peek: Optional[int] = None, + ) -> executor.ExecuteResult: + """Just execute whatever plan as is, without further caching or decomposition.""" + # TODO(swast): plumb through the api_name of the user-facing api that + # caused this query. + + compiled = self._compile_fn( + compile.CompileRequest(plan, sort_rows=ordered, peek_count=peek) + ) + + iterator, query_job = self._run_execute_query( + sql=compiled.sql, + session=plan.session, + ) + + # just immediately downlaod everything for simplicity + return executor.LocalExecuteResult( + data=iterator.to_arrow(), + bf_schema=plan.schema, + execution_metadata=executor.ExecutionMetadata.from_iterator_and_job( + iterator, query_job + ), + ) + + def _run_execute_query( + self, + sql: str, + job_config: Optional[bq_job.QueryJobConfig] = None, + session=None, + ) -> Tuple[bq_table.RowIterator, Optional[bigquery.QueryJob]]: + """ + Starts BigQuery query job and waits for results. + """ + return bq_io.start_query_with_client( + self.bqclient, + sql, + job_config=job_config or bq_job.QueryJobConfig(), + project=None, + location=None, + timeout=None, + metrics=None, + query_with_job=False, + publisher=self._publisher, + session=session, + ) diff --git a/bigframes/session/dry_runs.py b/bigframes/session/dry_runs.py new file mode 100644 index 0000000000..bd54bb65d7 --- /dev/null +++ b/bigframes/session/dry_runs.py @@ -0,0 +1,182 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import copy +from typing import Any, Dict, List, Sequence + +from google.cloud import bigquery +import pandas + +from bigframes import dtypes +from bigframes.core import bigframe_node, nodes + + +def get_table_stats(table: bigquery.Table) -> pandas.Series: + values: List[Any] = [] + index: List[Any] = [] + + # Indicate that no query is executed. + index.append("isQuery") + values.append(False) + + # Populate column and index types + col_dtypes = dtypes.bf_type_from_type_kind(table.schema) + index.append("columnCount") + values.append(len(col_dtypes)) + index.append("columnDtypes") + values.append(col_dtypes) + + # Add raw BQ schema + index.append("bigquerySchema") + values.append(table.schema) + + for key in ("numBytes", "numRows", "location", "type"): + index.append(key) + values.append(table._properties[key]) + + index.append("creationTime") + values.append(table.created) + + index.append("lastModifiedTime") + values.append(table.modified) + + return pandas.Series(values, index=index) + + +def get_query_stats_with_inferred_dtypes( + query_job: bigquery.QueryJob, + value_cols: Sequence[str], + index_cols: Sequence[str], +) -> pandas.Series: + if query_job.schema is None: + # If the schema is not available, don't bother inferring dtypes. + return get_query_stats(query_job) + + col_dtypes = dtypes.bf_type_from_type_kind(query_job.schema) + + if value_cols: + value_col_dtypes = { + col: col_dtypes[col] for col in value_cols if col in col_dtypes + } + else: + # Use every column that is not mentioned as an index column + value_col_dtypes = { + col: dtype + for col, dtype in col_dtypes.items() + if col not in set(index_cols) + } + + index_dtypes = [col_dtypes[col] for col in index_cols] + + return get_query_stats_with_dtypes(query_job, value_col_dtypes, index_dtypes) + + +def get_query_stats_with_dtypes( + query_job: bigquery.QueryJob, + column_dtypes: Dict[str, dtypes.Dtype], + index_dtypes: Sequence[dtypes.Dtype], + expr_root: bigframe_node.BigFrameNode | None = None, +) -> pandas.Series: + """ + Returns important stats from the query job as a Pandas Series. The dtypes information is added too. + + Args: + expr_root (Optional): + The root of the expression tree that may contain local data, whose size is added to the + total bytes count if available. + + """ + index = ["columnCount", "columnDtypes", "indexLevel", "indexDtypes"] + values = [len(column_dtypes), column_dtypes, len(index_dtypes), index_dtypes] + + s = pandas.Series(values, index=index) + + result = pandas.concat([s, get_query_stats(query_job)]) + if expr_root is not None: + result["totalBytesProcessed"] += get_local_bytes(expr_root) + return result + + +def get_query_stats( + query_job: bigquery.QueryJob, +) -> pandas.Series: + """Returns important stats from the query job as a Pandas Series.""" + + index: List[Any] = [] + values: List[Any] = [] + + # Add raw BQ schema + index.append("bigquerySchema") + values.append(query_job.schema) + + job_api_repr = copy.deepcopy(query_job._properties) + + # jobReference might not be populated for "job optional" queries. + job_ref = job_api_repr.get("jobReference", {}) + for key, val in job_ref.items(): + index.append(key) + values.append(val) + + configuration = job_api_repr.get("configuration", {}) + index.append("jobType") + values.append(configuration.get("jobType", None)) + index.append("dispatchedSql") + values.append(configuration.get("query", {}).get("query", None)) + + query_config = configuration.get("query", {}) + for key in ("destinationTable", "useLegacySql"): + index.append(key) + values.append(query_config.get(key, None)) + + statistics = job_api_repr.get("statistics", {}) + query_stats = statistics.get("query", {}) + for key in ( + "referencedTables", + "totalBytesProcessed", + "cacheHit", + "statementType", + ): + index.append(key) + values.append(query_stats.get(key, None)) + + creation_time = statistics.get("creationTime", None) + index.append("creationTime") + values.append( + pandas.Timestamp(creation_time, unit="ms", tz="UTC") + if creation_time is not None + else None + ) + + result = pandas.Series(values, index=index) + if result["totalBytesProcessed"] is None: + result["totalBytesProcessed"] = 0 + else: + result["totalBytesProcessed"] = int(result["totalBytesProcessed"]) + + return result + + +def get_local_bytes(root: bigframe_node.BigFrameNode) -> int: + def get_total_bytes( + root: bigframe_node.BigFrameNode, child_results: tuple[int, ...] + ) -> int: + child_bytes = sum(child_results) + + if isinstance(root, nodes.ReadLocalNode): + return child_bytes + root.local_data_source.data.get_total_buffer_size() + + return child_bytes + + return root.reduce_up(get_total_bytes) diff --git a/bigframes/session/environment.py b/bigframes/session/environment.py new file mode 100644 index 0000000000..940f8deed4 --- /dev/null +++ b/bigframes/session/environment.py @@ -0,0 +1,101 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import importlib +import json +import os +import pathlib + +Path = pathlib.Path + + +# The identifier for GCP VS Code extension +# https://cloud.google.com/code/docs/vscode/install +GOOGLE_CLOUD_CODE_EXTENSION_NAME = "googlecloudtools.cloudcode" + + +# The identifier for BigQuery Jupyter notebook plugin +# https://cloud.google.com/bigquery/docs/jupyterlab-plugin +BIGQUERY_JUPYTER_PLUGIN_NAME = "bigquery_jupyter_plugin" + + +def _is_vscode_extension_installed(extension_id: str) -> bool: + """ + Checks if a given Visual Studio Code extension is installed. + Args: + extension_id: The ID of the extension (e.g., "ms-python.python"). + Returns: + True if the extension is installed, False otherwise. + """ + try: + # Determine the user's VS Code extensions directory. + user_home = Path.home() + vscode_extensions_dir = user_home / ".vscode" / "extensions" + + # Check if the extensions directory exists. + if not vscode_extensions_dir.exists(): + return False + + # Iterate through the subdirectories in the extensions directory. + extension_dirs = filter( + lambda p: p.is_dir() and p.name.startswith(extension_id + "-"), + vscode_extensions_dir.iterdir(), + ) + for extension_dir in extension_dirs: + # As a more robust check, the manifest file must exist. + manifest_path = extension_dir / "package.json" + if not manifest_path.exists() or not manifest_path.is_file(): + continue + + # Finally, the manifest file must be a valid json + with open(manifest_path, "r", encoding="utf-8") as f: + json.load(f) + + return True + except Exception: + pass + + return False + + +def _is_package_installed(package_name: str) -> bool: + """ + Checks if a Python package is installed. + Args: + package_name: The name of the package to check (e.g., "requests", "numpy"). + Returns: + True if the package is installed, False otherwise. + """ + try: + importlib.import_module(package_name) + return True + except Exception: + return False + + +def is_vscode() -> bool: + return os.getenv("VSCODE_PID") is not None + + +def is_jupyter() -> bool: + return os.getenv("JPY_PARENT_PID") is not None + + +def is_vscode_google_cloud_code_extension_installed() -> bool: + return _is_vscode_extension_installed(GOOGLE_CLOUD_CODE_EXTENSION_NAME) + + +def is_jupyter_bigquery_plugin_installed() -> bool: + return _is_package_installed(BIGQUERY_JUPYTER_PLUGIN_NAME) diff --git a/bigframes/session/execution_spec.py b/bigframes/session/execution_spec.py new file mode 100644 index 0000000000..c9431dbd11 --- /dev/null +++ b/bigframes/session/execution_spec.py @@ -0,0 +1,53 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +from typing import Literal, Optional, Union + +from google.cloud import bigquery + + +@dataclasses.dataclass(frozen=True) +class ExecutionSpec: + destination_spec: Union[TableOutputSpec, GcsOutputSpec, CacheSpec, None] = None + peek: Optional[int] = None + ordered: bool = ( + False # ordered and promise_under_10gb must both be together for bq execution + ) + # This is an optimization flag for gbq execution, it doesn't change semantics, but if promise is falsely made, errors may occur + promise_under_10gb: bool = False + + +# This one is temporary, in future, caching will not be done through immediate execution, but will label nodes +# that will be cached only when a super-tree is executed +@dataclasses.dataclass(frozen=True) +class CacheSpec: + cluster_cols: tuple[str, ...] + + +@dataclasses.dataclass(frozen=True) +class TableOutputSpec: + table: bigquery.TableReference + cluster_cols: tuple[str, ...] + if_exists: Literal["fail", "replace", "append"] = "fail" + + +@dataclasses.dataclass(frozen=True) +class GcsOutputSpec: + uri: str + format: Literal["json", "csv", "parquet"] + # sequence of (option, value) pairs + export_options: tuple[tuple[str, Union[bool, str]], ...] diff --git a/bigframes/session/executor.py b/bigframes/session/executor.py index 502692929d..bca98bfb2f 100644 --- a/bigframes/session/executor.py +++ b/bigframes/session/executor.py @@ -16,653 +16,333 @@ import abc import dataclasses -import math -import os -from typing import ( - Callable, - cast, - Iterator, - Literal, - Mapping, - Optional, - Sequence, - Tuple, - Union, -) -import warnings -import weakref +import functools +import itertools +from typing import Iterator, Literal, Optional, Sequence, Union -import google.api_core.exceptions -import google.cloud.bigquery as bigquery -import google.cloud.bigquery.job as bq_job +from google.cloud import bigquery, bigquery_storage_v1 import google.cloud.bigquery.table as bq_table -import google.cloud.bigquery_storage_v1 +import pandas as pd import pyarrow +import pyarrow as pa +import bigframes import bigframes.core -import bigframes.core.compile -import bigframes.core.guid -import bigframes.core.identifiers -import bigframes.core.nodes as nodes -import bigframes.core.ordering as order +from bigframes.core import bq_data, local_data, pyarrow_utils import bigframes.core.schema -import bigframes.core.tree_properties as tree_properties -import bigframes.features -import bigframes.session._io.bigquery as bq_io -import bigframes.session.metrics -import bigframes.session.planner -import bigframes.session.temp_storage - -# Max complexity that should be executed as a single query -QUERY_COMPLEXITY_LIMIT = 1e7 -# Number of times to factor out subqueries before giving up. -MAX_SUBTREE_FACTORINGS = 5 -_MAX_CLUSTER_COLUMNS = 4 -# TODO: b/338258028 Enable pruning to reduce text size. -ENABLE_PRUNING = False +import bigframes.dtypes +import bigframes.session._io.pandas as io_pandas +import bigframes.session.execution_spec as ex_spec + +_ROW_LIMIT_EXCEEDED_TEMPLATE = ( + "Execution has downloaded {result_rows} rows so far, which exceeds the " + "limit of {maximum_result_rows}. You can adjust this limit by setting " + "`bpd.options.compute.maximum_result_rows`." +) -@dataclasses.dataclass(frozen=True) -class ExecuteResult: - arrow_batches: Callable[[], Iterator[pyarrow.RecordBatch]] - schema: bigframes.core.schema.ArraySchema - query_job: Optional[bigquery.QueryJob] = None - total_bytes: Optional[int] = None - total_rows: Optional[int] = None +class ResultsIterator(Iterator[pa.RecordBatch]): + """ + Iterator for query results, with some extra metadata attached. + """ + + def __init__( + self, + batches: Iterator[pa.RecordBatch], + schema: bigframes.core.schema.ArraySchema, + total_rows: Optional[int] = 0, + total_bytes: Optional[int] = 0, + ): + self._batches = batches + self._schema = schema + self._total_rows = total_rows + self._total_bytes = total_bytes + + @property + def approx_total_rows(self) -> Optional[int]: + return self._total_rows + + @property + def approx_total_bytes(self) -> Optional[int]: + return self._total_bytes + + def __next__(self) -> pa.RecordBatch: + return next(self._batches) + + @property + def arrow_batches(self) -> Iterator[pyarrow.RecordBatch]: + result_rows = 0 + + for batch in self._batches: + result_rows += batch.num_rows + + maximum_result_rows = bigframes.options.compute.maximum_result_rows + if maximum_result_rows is not None and result_rows > maximum_result_rows: + message = bigframes.exceptions.format_message( + _ROW_LIMIT_EXCEEDED_TEMPLATE.format( + result_rows=result_rows, + maximum_result_rows=maximum_result_rows, + ) + ) + raise bigframes.exceptions.MaximumResultRowsExceeded(message) + + yield batch def to_arrow_table(self) -> pyarrow.Table: # Need to provide schema if no result rows, as arrow can't infer # If ther are rows, it is safest to infer schema from batches. # Any discrepencies between predicted schema and actual schema will produce errors. - return pyarrow.Table.from_batches( - self.arrow_batches(), - self.schema.to_pyarrow() if not self.total_rows else None, - ) - - -class Executor(abc.ABC): - """ - Interface for an executor, which compiles and executes ArrayValue objects. - """ - - def to_sql( - self, - array_value: bigframes.core.ArrayValue, - offset_column: Optional[str] = None, - col_id_overrides: Mapping[str, str] = {}, - ordered: bool = False, - enable_cache: bool = True, - ) -> str: - """ - Convert an ArrayValue to a sql query that will yield its value. - """ - raise NotImplementedError("to_sql not implemented for this executor") + batches = iter(self.arrow_batches) + peek_it = itertools.islice(batches, 0, 1) + peek_value = list(peek_it) + # TODO: Enforce our internal schema on the table for consistency + if len(peek_value) > 0: + return pyarrow.Table.from_batches( + itertools.chain(peek_value, batches), # reconstruct + ) + else: + try: + return self._schema.to_pyarrow().empty_table() + except pa.ArrowNotImplementedError: + # Bug with some pyarrow versions, empty_table only supports base storage types, not extension types. + return self._schema.to_pyarrow(use_storage_types=True).empty_table() + + def to_pandas(self) -> pd.DataFrame: + return io_pandas.arrow_to_pandas(self.to_arrow_table(), self._schema) + + def to_pandas_batches( + self, page_size: Optional[int] = None, max_results: Optional[int] = None + ) -> Iterator[pd.DataFrame]: + assert (page_size is None) or (page_size > 0) + assert (max_results is None) or (max_results > 0) + batch_iter: Iterator[ + Union[pyarrow.Table, pyarrow.RecordBatch] + ] = self.arrow_batches + if max_results is not None: + batch_iter = pyarrow_utils.truncate_pyarrow_iterable( + batch_iter, max_results + ) - def execute( - self, - array_value: bigframes.core.ArrayValue, - *, - ordered: bool = True, - col_id_overrides: Mapping[str, str] = {}, - use_explicit_destination: bool = False, - get_size_bytes: bool = False, - page_size: Optional[int] = None, - max_results: Optional[int] = None, - ): - """ - Execute the ArrayValue, storing the result to a temporary session-owned table. - """ - raise NotImplementedError("execute not implemented for this executor") + if page_size is not None: + batches_iter = pyarrow_utils.chunk_by_row_count(batch_iter, page_size) + batch_iter = map( + lambda batches: pyarrow.Table.from_batches(batches), batches_iter + ) - def export_gbq( - self, - array_value: bigframes.core.ArrayValue, - col_id_overrides: Mapping[str, str], - destination: bigquery.TableReference, - if_exists: Literal["fail", "replace", "append"] = "fail", - cluster_cols: Sequence[str] = [], - ) -> bigquery.QueryJob: - """ - Export the ArrayValue to an existing BigQuery table. - """ - raise NotImplementedError("export_gbq not implemented for this executor") + yield from map( + functools.partial(io_pandas.arrow_to_pandas, schema=self._schema), + batch_iter, + ) - def export_gcs( - self, - array_value: bigframes.core.ArrayValue, - col_id_overrides: Mapping[str, str], - uri: str, - format: Literal["json", "csv", "parquet"], - export_options: Mapping[str, Union[bool, str]], - ) -> bigquery.QueryJob: - """ - Export the ArrayValue to gcs. - """ - raise NotImplementedError("export_gcs not implemented for this executor") + def to_py_scalar(self): + columns = list(self.to_arrow_table().to_pydict().values()) + if len(columns) != 1: + raise ValueError( + f"Expected single column result, got {len(columns)} columns." + ) + column = columns[0] + if len(column) != 1: + raise ValueError(f"Expected single row result, got {len(column)} rows.") + return column[0] - def dry_run( - self, array_value: bigframes.core.ArrayValue, ordered: bool = True - ) -> bigquery.QueryJob: - """ - Dry run executing the ArrayValue. - Does not actually execute the data but will get stats and indicate any invalid query errors. - """ - raise NotImplementedError("dry_run not implemented for this executor") +class ExecuteResult(abc.ABC): + @property + @abc.abstractmethod + def execution_metadata(self) -> ExecutionMetadata: + ... - def peek( - self, - array_value: bigframes.core.ArrayValue, - n_rows: int, - ) -> ExecuteResult: - """ - A 'peek' efficiently accesses a small number of rows in the dataframe. - """ - raise NotImplementedError("peek not implemented for this executor") + @property + @abc.abstractmethod + def schema(self) -> bigframes.core.schema.ArraySchema: + ... - # TODO: Remove this and replace with efficient slice operator that can use execute() - def head( - self, array_value: bigframes.core.ArrayValue, n_rows: int - ) -> ExecuteResult: - """ - Preview the first n rows of the dataframe. This is less efficient than the unordered peek preview op. - """ - raise NotImplementedError("head not implemented for this executor") + @abc.abstractmethod + def batches(self) -> ResultsIterator: + ... - # TODO: This should be done through execute() - def get_row_count(self, array_value: bigframes.core.ArrayValue) -> int: - raise NotImplementedError("get_row_count not implemented for this executor") + @property + def query_job(self) -> Optional[bigquery.QueryJob]: + return self.execution_metadata.query_job - def cached( - self, - array_value: bigframes.core.ArrayValue, - *, - force: bool = False, - use_session: bool = False, - cluster_cols: Sequence[str] = (), - ) -> None: - raise NotImplementedError("cached not implemented for this executor") + @property + def total_bytes_processed(self) -> Optional[int]: + return self.execution_metadata.bytes_processed -class BigQueryCachingExecutor(Executor): - """Computes BigFrames values using BigQuery Engine. +@dataclasses.dataclass(frozen=True) +class ExecutionMetadata: + query_job: Optional[bigquery.QueryJob] = None + bytes_processed: Optional[int] = None - This executor can cache expressions. If those expressions are executed later, this session - will re-use the pre-existing results from previous executions. + @classmethod + def from_iterator_and_job( + cls, iterator: bq_table.RowIterator, job: Optional[bigquery.QueryJob] + ) -> ExecutionMetadata: + return cls(query_job=job, bytes_processed=iterator.total_bytes_processed) - This class is not thread-safe. - """ +class LocalExecuteResult(ExecuteResult): def __init__( self, - bqclient: bigquery.Client, - storage_manager: bigframes.session.temp_storage.TemporaryGbqStorageManager, - bqstoragereadclient: google.cloud.bigquery_storage_v1.BigQueryReadClient, - *, - strictly_ordered: bool = True, - metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, + data: pa.Table, + bf_schema: bigframes.core.schema.ArraySchema, + execution_metadata: ExecutionMetadata = ExecutionMetadata(), ): - self.bqclient = bqclient - self.storage_manager = storage_manager - self.compiler: bigframes.core.compile.SQLCompiler = ( - bigframes.core.compile.SQLCompiler(strict=strictly_ordered) + self._data = local_data.ManagedArrowTable.from_pyarrow(data, bf_schema) + self._execution_metadata = execution_metadata + + @property + def execution_metadata(self) -> ExecutionMetadata: + return self._execution_metadata + + @property + def schema(self) -> bigframes.core.schema.ArraySchema: + return self._data.schema + + def batches(self) -> ResultsIterator: + return ResultsIterator( + iter(self._data.to_arrow()[1]), + self.schema, + self._data.metadata.row_count, + self._data.metadata.total_bytes, ) - self.strictly_ordered: bool = strictly_ordered - self._cached_executions: weakref.WeakKeyDictionary[ - nodes.BigFrameNode, nodes.BigFrameNode - ] = weakref.WeakKeyDictionary() - self.metrics = metrics - self.bqstoragereadclient = bqstoragereadclient - def to_sql( - self, - array_value: bigframes.core.ArrayValue, - offset_column: Optional[str] = None, - col_id_overrides: Mapping[str, str] = {}, - ordered: bool = False, - enable_cache: bool = True, - ) -> str: - if offset_column: - array_value, internal_offset_col = array_value.promote_offsets() - col_id_overrides = dict(col_id_overrides) - col_id_overrides[internal_offset_col] = offset_column - node = ( - self.replace_cached_subtrees(array_value.node) - if enable_cache - else array_value.node - ) - if ordered: - return self.compiler.compile_ordered( - node, col_id_overrides=col_id_overrides - ) - return self.compiler.compile_unordered(node, col_id_overrides=col_id_overrides) - def execute( +class EmptyExecuteResult(ExecuteResult): + def __init__( self, - array_value: bigframes.core.ArrayValue, - *, - ordered: bool = True, - col_id_overrides: Mapping[str, str] = {}, - use_explicit_destination: bool = False, - get_size_bytes: bool = False, - page_size: Optional[int] = None, - max_results: Optional[int] = None, + bf_schema: bigframes.core.schema.ArraySchema, + execution_metadata: ExecutionMetadata = ExecutionMetadata(), ): - if bigframes.options.compute.enable_multi_query_execution: - self._simplify_with_caching(array_value) + self._schema = bf_schema + self._execution_metadata = execution_metadata - sql = self.to_sql( - array_value, ordered=ordered, col_id_overrides=col_id_overrides - ) - adjusted_schema = array_value.schema.rename(col_id_overrides) - job_config = bigquery.QueryJobConfig() - # Use explicit destination to avoid 10GB limit of temporary table - if use_explicit_destination: - destination_table = self.storage_manager.create_temp_table( - adjusted_schema.to_bigquery(), cluster_cols=[] - ) - job_config.destination = destination_table - # TODO(swast): plumb through the api_name of the user-facing api that - # caused this query. - iterator, query_job = self._run_execute_query( - sql=sql, - job_config=job_config, - page_size=page_size, - max_results=max_results, - ) + @property + def execution_metadata(self) -> ExecutionMetadata: + return self._execution_metadata - # Though we provide the read client, iterator may or may not use it based on what is efficient for the result - def iterator_supplier(): - return iterator.to_arrow_iterable(bqstorage_client=self.bqstoragereadclient) + @property + def schema(self) -> bigframes.core.schema.ArraySchema: + return self._schema + + def batches(self) -> ResultsIterator: + return ResultsIterator(iter([]), self.schema, 0, 0) - if get_size_bytes is True: - size_bytes = self.bqclient.get_table(query_job.destination).num_bytes - else: - size_bytes = None - - # Runs strict validations to ensure internal type predictions and ibis are completely in sync - # Do not execute these validations outside of testing suite. - if "PYTEST_CURRENT_TEST" in os.environ and len(col_id_overrides) == 0: - self._validate_result_schema(array_value, iterator.schema) - - return ExecuteResult( - arrow_batches=iterator_supplier, - schema=adjusted_schema, - query_job=query_job, - total_bytes=size_bytes, - total_rows=iterator.total_rows, - ) - def export_gbq( +class BQTableExecuteResult(ExecuteResult): + def __init__( self, - array_value: bigframes.core.ArrayValue, - col_id_overrides: Mapping[str, str], - destination: bigquery.TableReference, - if_exists: Literal["fail", "replace", "append"] = "fail", - cluster_cols: Sequence[str] = [], + data: bq_data.BigqueryDataSource, + storage_client: bigquery_storage_v1.BigQueryReadClient, + project_id: str, + *, + execution_metadata: ExecutionMetadata = ExecutionMetadata(), + limit: Optional[int] = None, + selected_fields: Optional[Sequence[tuple[str, str]]] = None, ): - """ - Export the ArrayValue to an existing BigQuery table. - """ - if bigframes.options.compute.enable_multi_query_execution: - self._simplify_with_caching(array_value) - - dispositions = { - "fail": bigquery.WriteDisposition.WRITE_EMPTY, - "replace": bigquery.WriteDisposition.WRITE_TRUNCATE, - "append": bigquery.WriteDisposition.WRITE_APPEND, - } - sql = self.to_sql(array_value, ordered=False, col_id_overrides=col_id_overrides) - job_config = bigquery.QueryJobConfig( - write_disposition=dispositions[if_exists], - destination=destination, - clustering_fields=cluster_cols if cluster_cols else None, + self._data = data + self._project_id = project_id + self._execution_metadata = execution_metadata + self._storage_client = storage_client + self._limit = limit + self._selected_fields = selected_fields or [ + (name, name) for name in data.schema.names + ] + + @property + def execution_metadata(self) -> ExecutionMetadata: + return self._execution_metadata + + @property + @functools.cache + def schema(self) -> bigframes.core.schema.ArraySchema: + source_ids = [selection[0] for selection in self._selected_fields] + return self._data.schema.select(source_ids).rename(dict(self._selected_fields)) + + def batches(self) -> ResultsIterator: + read_batches = bq_data.get_arrow_batches( + self._data, + [x[0] for x in self._selected_fields], + self._storage_client, + self._project_id, ) - # TODO(swast): plumb through the api_name of the user-facing api that - # caused this query. - _, query_job = self._run_execute_query( - sql=sql, - job_config=job_config, + arrow_batches: Iterator[pa.RecordBatch] = map( + functools.partial( + pyarrow_utils.rename_batch, names=list(self.schema.names) + ), + read_batches.iter, ) - return query_job + approx_bytes: Optional[int] = read_batches.approx_bytes + approx_rows: Optional[int] = self._data.n_rows or read_batches.approx_rows + + if self._limit is not None: + if approx_rows is not None: + approx_rows = min(approx_rows, self._limit) + arrow_batches = pyarrow_utils.truncate_pyarrow_iterable( + arrow_batches, self._limit + ) - def export_gcs( - self, - array_value: bigframes.core.ArrayValue, - col_id_overrides: Mapping[str, str], - uri: str, - format: Literal["json", "csv", "parquet"], - export_options: Mapping[str, Union[bool, str]], - ): - query_job = self.execute( - array_value, - ordered=False, - col_id_overrides=col_id_overrides, - ).query_job - result_table = query_job.destination - export_data_statement = bq_io.create_export_data_statement( - f"{result_table.project}.{result_table.dataset_id}.{result_table.table_id}", - uri=uri, - format=format, - export_options=dict(export_options), - ) + if self._data.sql_predicate: + approx_bytes = None + approx_rows = None - bq_io.start_query_with_client( - self.bqclient, - export_data_statement, - job_config=bigquery.QueryJobConfig(), - api_name=f"dataframe-to_{format.lower()}", - metrics=self.metrics, - ) - return query_job + return ResultsIterator(arrow_batches, self.schema, approx_rows, approx_bytes) - def dry_run( - self, array_value: bigframes.core.ArrayValue, ordered: bool = True - ) -> bigquery.QueryJob: - sql = self.to_sql(array_value, ordered=ordered) - job_config = bigquery.QueryJobConfig(dry_run=True) - query_job = self.bqclient.query(sql, job_config=job_config) - return query_job - def peek( - self, - array_value: bigframes.core.ArrayValue, - n_rows: int, - ) -> ExecuteResult: - """ - A 'peek' efficiently accesses a small number of rows in the dataframe. - """ - plan = self.replace_cached_subtrees(array_value.node) - if not tree_properties.can_fast_peek(plan): - msg = "Peeking this value cannot be done efficiently." - warnings.warn(msg) - - sql = self.compiler.compile_peek(plan, n_rows) - - # TODO(swast): plumb through the api_name of the user-facing api that - # caused this query. - iterator, query_job = self._run_execute_query(sql=sql) - return ExecuteResult( - # Probably don't need read client for small peek results, but let client decide - arrow_batches=lambda: iterator.to_arrow_iterable( - bqstorage_client=self.bqstoragereadclient - ), - schema=array_value.schema, - query_job=query_job, - total_rows=iterator.total_rows, - ) +@dataclasses.dataclass(frozen=True) +class HierarchicalKey: + columns: tuple[str, ...] - def head( - self, array_value: bigframes.core.ArrayValue, n_rows: int - ) -> ExecuteResult: - maybe_row_count = self._local_get_row_count(array_value) - if (maybe_row_count is not None) and (maybe_row_count <= n_rows): - return self.execute(array_value, ordered=True) - - if not self.strictly_ordered and not array_value.node.explicitly_ordered: - # No user-provided ordering, so just get any N rows, its faster! - return self.peek(array_value, n_rows) - - plan = self.replace_cached_subtrees(array_value.node) - if not tree_properties.can_fast_head(plan): - # If can't get head fast, we are going to need to execute the whole query - # Will want to do this in a way such that the result is reusable, but the first - # N values can be easily extracted. - # This currently requires clustering on offsets. - self._cache_with_offsets(array_value) - # Get a new optimized plan after caching - plan = self.replace_cached_subtrees(array_value.node) - assert tree_properties.can_fast_head(plan) - - head_plan = generate_head_plan(plan, n_rows) - sql = self.compiler.compile_ordered(head_plan) - - # TODO(swast): plumb through the api_name of the user-facing api that - # caused this query. - iterator, query_job = self._run_execute_query(sql=sql) - return ExecuteResult( - # Probably don't need read client for small head results, but let client decide - arrow_batches=lambda: iterator.to_arrow_iterable( - bqstorage_client=self.bqstoragereadclient - ), - schema=array_value.schema, - query_job=query_job, - total_rows=iterator.total_rows, - ) +@dataclasses.dataclass(frozen=True) +class CacheConfig(abc.ABC): + optimize_for: Union[Literal["auto", "head"], HierarchicalKey] = "auto" + if_cached: Literal["reuse-strict", "reuse-any", "replace"] = "reuse-any" - def get_row_count(self, array_value: bigframes.core.ArrayValue) -> int: - count = self._local_get_row_count(array_value) - if count is not None: - return count - else: - row_count_plan = self.replace_cached_subtrees( - generate_row_count_plan(array_value.node) - ) - sql = self.compiler.compile_unordered(row_count_plan) - iter, _ = self._run_execute_query(sql) - return next(iter)[0] - def cached( +class Executor(abc.ABC): + """ + Interface for an executor, which compiles and executes ArrayValue objects. + """ + + def to_sql( self, array_value: bigframes.core.ArrayValue, - *, - force: bool = False, - use_session: bool = False, - cluster_cols: Sequence[str] = (), - ) -> None: - """Write the block to a session table.""" - # use a heuristic for whether something needs to be cached - if (not force) and self._is_trivially_executable(array_value): - return - if use_session: - self._cache_with_session_awareness(array_value) - else: - self._cache_with_cluster_cols(array_value, cluster_cols=cluster_cols) - - def _local_get_row_count( - self, array_value: bigframes.core.ArrayValue - ) -> Optional[int]: - # optimized plan has cache materializations which will have row count metadata - # that is more likely to be usable than original leaf nodes. - plan = self.replace_cached_subtrees(array_value.node) - return tree_properties.row_count(plan) - - # Helpers - def _run_execute_query( - self, - sql: str, - job_config: Optional[bq_job.QueryJobConfig] = None, - api_name: Optional[str] = None, - page_size: Optional[int] = None, - max_results: Optional[int] = None, - ) -> Tuple[bq_table.RowIterator, bigquery.QueryJob]: + offset_column: Optional[str] = None, + ordered: bool = False, + enable_cache: bool = True, + ) -> str: """ - Starts BigQuery query job and waits for results. + Convert an ArrayValue to a sql query that will yield its value. """ - job_config = bq_job.QueryJobConfig() if job_config is None else job_config - if bigframes.options.compute.maximum_bytes_billed is not None: - job_config.maximum_bytes_billed = ( - bigframes.options.compute.maximum_bytes_billed - ) - - if not self.strictly_ordered: - job_config.labels["bigframes-mode"] = "unordered" - - # Note: add_and_trim_labels is global scope which may have unexpected effects - # Ensure no additional labels are added to job_config after this point, - # as `add_and_trim_labels` ensures the label count does not exceed 64. - bq_io.add_and_trim_labels(job_config, api_name=api_name) - try: - return bq_io.start_query_with_client( - self.bqclient, - sql, - job_config=job_config, - api_name=api_name, - max_results=max_results, - page_size=page_size, - metrics=self.metrics, - ) - - except google.api_core.exceptions.BadRequest as e: - # Unfortunately, this error type does not have a separate error code or exception type - if "Resources exceeded during query execution" in e.message: - new_message = "Computation is too complex to execute as a single query. Try using DataFrame.cache() on intermediate results, or setting bigframes.options.compute.enable_multi_query_execution." - raise bigframes.exceptions.QueryComplexityError(new_message) from e - else: - raise - - def replace_cached_subtrees(self, node: nodes.BigFrameNode) -> nodes.BigFrameNode: - return nodes.top_down(node, lambda x: self._cached_executions.get(x, x)) + raise NotImplementedError("to_sql not implemented for this executor") - def _is_trivially_executable(self, array_value: bigframes.core.ArrayValue): + @abc.abstractmethod + def execute( + self, + array_value: bigframes.core.ArrayValue, + execution_spec: ex_spec.ExecutionSpec, + ) -> ExecuteResult: """ - Can the block be evaluated very cheaply? - If True, the array_value probably is not worth caching. + Execute the ArrayValue. """ - # Once rewriting is available, will want to rewrite before - # evaluating execution cost. - return tree_properties.is_trivially_executable( - self.replace_cached_subtrees(array_value.node) - ) + ... - def _cache_with_cluster_cols( - self, array_value: bigframes.core.ArrayValue, cluster_cols: Sequence[str] - ): - """Executes the query and uses the resulting table to rewrite future executions.""" - - sql, schema, ordering_info = self.compiler.compile_raw( - self.replace_cached_subtrees(array_value.node) - ) - tmp_table = self._sql_as_cached_temp_table( - sql, - schema, - cluster_cols=bq_io.select_cluster_cols(schema, cluster_cols), - ) - cached_replacement = array_value.as_cached( - cache_table=self.bqclient.get_table(tmp_table), - ordering=ordering_info, - ).node - self._cached_executions[array_value.node] = cached_replacement - - def _cache_with_offsets(self, array_value: bigframes.core.ArrayValue): - """Executes the query and uses the resulting table to rewrite future executions.""" - offset_column = bigframes.core.guid.generate_guid("bigframes_offsets") - w_offsets, offset_column = array_value.promote_offsets() - sql = self.compiler.compile_unordered( - self.replace_cached_subtrees(w_offsets.node) - ) + def dry_run( + self, array_value: bigframes.core.ArrayValue, ordered: bool = True + ) -> bigquery.QueryJob: + """ + Dry run executing the ArrayValue. - tmp_table = self._sql_as_cached_temp_table( - sql, - w_offsets.schema.to_bigquery(), - cluster_cols=[offset_column], - ) - cached_replacement = array_value.as_cached( - cache_table=self.bqclient.get_table(tmp_table), - ordering=order.TotalOrdering.from_offset_col(offset_column), - ).node - self._cached_executions[array_value.node] = cached_replacement + Does not actually execute the data but will get stats and indicate any invalid query errors. + """ + raise NotImplementedError("dry_run not implemented for this executor") - def _cache_with_session_awareness( + def cached( self, array_value: bigframes.core.ArrayValue, + *, + config: CacheConfig, ) -> None: - session_forest = [obj._block._expr.node for obj in array_value.session.objects] - # These node types are cheap to re-compute - target, cluster_cols = bigframes.session.planner.session_aware_cache_plan( - array_value.node, list(session_forest) - ) - cluster_cols_sql_names = [id.sql for id in cluster_cols] - if len(cluster_cols) > 0: - self._cache_with_cluster_cols( - bigframes.core.ArrayValue(target), cluster_cols_sql_names - ) - elif self.strictly_ordered: - self._cache_with_offsets(bigframes.core.ArrayValue(target)) - else: - self._cache_with_cluster_cols(bigframes.core.ArrayValue(target), []) - - def _simplify_with_caching(self, array_value: bigframes.core.ArrayValue): - """Attempts to handle the complexity by caching duplicated subtrees and breaking the query into pieces.""" - # Apply existing caching first - for _ in range(MAX_SUBTREE_FACTORINGS): - node_with_cache = self.replace_cached_subtrees(array_value.node) - if node_with_cache.planning_complexity < QUERY_COMPLEXITY_LIMIT: - return - - did_cache = self._cache_most_complex_subtree(array_value.node) - if not did_cache: - return - - def _cache_most_complex_subtree(self, node: nodes.BigFrameNode) -> bool: - # TODO: If query fails, retry with lower complexity limit - selection = tree_properties.select_cache_target( - node, - min_complexity=(QUERY_COMPLEXITY_LIMIT / 500), - max_complexity=QUERY_COMPLEXITY_LIMIT, - cache=dict(self._cached_executions), - # Heuristic: subtree_compleixty * (copies of subtree)^2 - heuristic=lambda complexity, count: math.log(complexity) - + 2 * math.log(count), - ) - if selection is None: - # No good subtrees to cache, just return original tree - return False - - self._cache_with_cluster_cols(bigframes.core.ArrayValue(selection), []) - return True - - def _sql_as_cached_temp_table( - self, - sql: str, - schema: Sequence[bigquery.SchemaField], - cluster_cols: Sequence[str], - ) -> bigquery.TableReference: - assert len(cluster_cols) <= _MAX_CLUSTER_COLUMNS - temp_table = self.storage_manager.create_temp_table(schema, cluster_cols) - - # TODO: Get default job config settings - job_config = cast( - bigquery.QueryJobConfig, - bigquery.QueryJobConfig.from_api_repr({}), - ) - job_config.destination = temp_table - _, query_job = self._run_execute_query( - sql, - job_config=job_config, - api_name="cached", - ) - query_job.destination - query_job.result() - return query_job.destination - - def _validate_result_schema( - self, - array_value: bigframes.core.ArrayValue, - bq_schema: list[bigquery.SchemaField], - ): - actual_schema = tuple(bq_schema) - ibis_schema = bigframes.core.compile.test_only_ibis_inferred_schema( - self.replace_cached_subtrees(array_value.node) - ) - internal_schema = array_value.schema - if not bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable: - return - - if internal_schema.to_bigquery() != actual_schema: - raise ValueError( - f"This error should only occur while testing. BigFrames internal schema: {internal_schema.to_bigquery()} does not match actual schema: {actual_schema}" - ) - if ibis_schema.to_bigquery() != actual_schema: - raise ValueError( - f"This error should only occur while testing. Ibis schema: {ibis_schema.to_bigquery()} does not match actual schema: {actual_schema}" - ) - - -def generate_head_plan(node: nodes.BigFrameNode, n: int): - return nodes.SliceNode(node, start=None, stop=n) - - -def generate_row_count_plan(node: nodes.BigFrameNode): - return nodes.RowCountNode(node) + raise NotImplementedError("cached not implemented for this executor") diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 7204a14870..bf91637be4 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -14,63 +14,89 @@ from __future__ import annotations +import concurrent +import concurrent.futures import copy import dataclasses import datetime +import io import itertools +import math import os +import threading import typing -from typing import Dict, Hashable, IO, Iterable, List, Optional, Sequence, Tuple, Union +from typing import ( + cast, + Dict, + Hashable, + IO, + Iterable, + Iterator, + List, + Literal, + Optional, + overload, + Sequence, + Tuple, + TypeVar, +) import bigframes_vendored.constants as constants import bigframes_vendored.pandas.io.gbq as third_party_pandas_gbq import google.api_core.exceptions -import google.auth.credentials +from google.cloud import bigquery_storage_v1 +import google.cloud.bigquery import google.cloud.bigquery as bigquery import google.cloud.bigquery.table -import google.cloud.bigquery_connection_v1 -import google.cloud.bigquery_storage_v1 -import google.cloud.functions_v2 -import google.cloud.resourcemanager_v3 -import jellyfish +from google.cloud.bigquery_storage_v1 import types as bq_storage_types import pandas -import pandas_gbq.schema.pandas_to_bigquery # type: ignore - -import bigframes.clients -import bigframes.constants +import pyarrow as pa + +import bigframes._tools +import bigframes._tools.strings +from bigframes.core import ( + bq_data, + guid, + identifiers, + local_data, + nodes, + ordering, + utils, +) import bigframes.core as core import bigframes.core.blocks as blocks -import bigframes.core.compile -import bigframes.core.expression as expression -import bigframes.core.guid -import bigframes.core.ordering -import bigframes.core.pruning +import bigframes.core.events import bigframes.core.schema as schemata -import bigframes.dataframe import bigframes.dtypes -import bigframes.exceptions import bigframes.formatting_helpers as formatting_helpers -import bigframes.operations -import bigframes.operations.aggregations as agg_ops +from bigframes.session import dry_runs import bigframes.session._io.bigquery as bf_io_bigquery +import bigframes.session._io.bigquery.read_gbq_query as bf_read_gbq_query import bigframes.session._io.bigquery.read_gbq_table as bf_read_gbq_table -import bigframes.session._io.pandas as bf_io_pandas -import bigframes.session.clients -import bigframes.session.executor import bigframes.session.metrics -import bigframes.session.planner -import bigframes.session.temp_storage +import bigframes.session.temporary_storage import bigframes.session.time as session_time -import bigframes.version # Avoid circular imports. if typing.TYPE_CHECKING: - import bigframes.core.indexes import bigframes.dataframe as dataframe - import bigframes.series import bigframes.session -_MAX_CLUSTER_COLUMNS = 4 +_PLACEHOLDER_SCHEMA = ( + google.cloud.bigquery.SchemaField("bf_loader_placeholder", "INTEGER"), +) + +_LOAD_JOB_TYPE_OVERRIDES = { + # Json load jobs not supported yet: b/271321143 + bigframes.dtypes.JSON_DTYPE: "STRING", + # Timedelta is emulated using integer in bq type system + bigframes.dtypes.TIMEDELTA_DTYPE: "INTEGER", +} + +_STREAM_JOB_TYPE_OVERRIDES = { + # Timedelta is emulated using integer in bq type system + bigframes.dtypes.TIMEDELTA_DTYPE: "INTEGER", +} def _to_index_cols( @@ -87,6 +113,137 @@ def _to_index_cols( return index_cols +def _check_duplicates(name: str, columns: Optional[Iterable[str]] = None): + """Check for duplicate column names in the provided iterable.""" + if columns is None: + return + columns_list = list(columns) + set_columns = set(columns_list) + if len(columns_list) > len(set_columns): + raise ValueError( + f"The '{name}' argument contains duplicate names. " + f"All column names specified in '{name}' must be unique." + ) + + +def _check_index_col_param( + index_cols: Iterable[str], + columns: Iterable[str], + *, + table_columns: Optional[Iterable[str]] = None, + index_col_in_columns: Optional[bool] = False, +): + """Checks for duplicates in `index_cols` and resolves overlap with `columns`. + + Args: + index_cols (Iterable[str]): + Column names designated as the index columns. + columns (Iterable[str]): + Used column names from table_columns. + table_columns (Iterable[str]): + A full list of column names in the table schema. + index_col_in_columns (bool): + A flag indicating how to handle overlap between `index_cols` and + `columns`. + - If `False`, the two lists must be disjoint (contain no common + elements). An error is raised if any overlap is found. + - If `True`, `index_cols` is expected to be a subset of + `columns`. An error is raised if an index column is not found + in the `columns` list. + """ + _check_duplicates("index_col", index_cols) + + if columns is not None and len(list(columns)) > 0: + set_index = set(list(index_cols) if index_cols is not None else []) + set_columns = set(list(columns) if columns is not None else []) + + if index_col_in_columns: + if not set_index.issubset(set_columns): + raise ValueError( + f"The specified index column(s) were not found: {set_index - set_columns}. " + f"Available columns are: {set_columns}" + ) + else: + if not set_index.isdisjoint(set_columns): + raise ValueError( + "Found column names that exist in both 'index_col' and 'columns' arguments. " + "These arguments must specify distinct sets of columns." + ) + + if not index_col_in_columns and table_columns is not None: + for key in index_cols: + if key not in table_columns: + possibility = min( + table_columns, + key=lambda item: bigframes._tools.strings.levenshtein_distance( + key, item + ), + ) + raise ValueError( + f"Column '{key}' of `index_col` not found in this table. Did you mean '{possibility}'?" + ) + + +def _check_columns_param(columns: Iterable[str], table_columns: Iterable[str]): + """Validates that the specified columns are present in the table columns. + + Args: + columns (Iterable[str]): + Used column names from table_columns. + table_columns (Iterable[str]): + A full list of column names in the table schema. + Raises: + ValueError: If any column in `columns` is not found in the table columns. + """ + for column_name in columns: + if column_name not in table_columns: + possibility = min( + table_columns, + key=lambda item: bigframes._tools.strings.levenshtein_distance( + column_name, item + ), + ) + raise ValueError( + f"Column '{column_name}' is not found. Did you mean '{possibility}'?" + ) + + +def _check_names_param( + names: Iterable[str], + index_col: Iterable[str] + | str + | Iterable[int] + | int + | bigframes.enums.DefaultIndexKind, + columns: Iterable[str], + table_columns: Iterable[str], +): + len_names = len(list(names)) + len_table_columns = len(list(table_columns)) + len_columns = len(list(columns)) + if len_names > len_table_columns: + raise ValueError( + f"Too many columns specified: expected {len_table_columns}" + f" and found {len_names}" + ) + elif len_names < len_table_columns: + if isinstance(index_col, bigframes.enums.DefaultIndexKind) or index_col != (): + raise KeyError( + "When providing both `index_col` and `names`, ensure the " + "number of `names` matches the number of columns in your " + "data." + ) + if len_columns != 0: + # The 'columns' must be identical to the 'names'. If not, raise an error. + if len_columns != len_names: + raise ValueError( + "Number of passed names did not match number of header " + "fields in the file" + ) + if set(list(names)) != set(list(columns)): + raise ValueError("Usecols do not match columns") + + @dataclasses.dataclass class GbqDataLoader: """ @@ -115,156 +272,285 @@ def __init__( self, session: bigframes.session.Session, bqclient: bigquery.Client, - storage_manager: bigframes.session.temp_storage.TemporaryGbqStorageManager, + write_client: bigquery_storage_v1.BigQueryWriteClient, + storage_manager: bigframes.session.temporary_storage.TemporaryStorageManager, default_index_type: bigframes.enums.DefaultIndexKind, scan_index_uniqueness: bool, force_total_order: bool, metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, + *, + publisher: bigframes.core.events.Publisher, ): self._bqclient = bqclient + self._write_client = write_client self._storage_manager = storage_manager self._default_index_type = default_index_type self._scan_index_uniqueness = scan_index_uniqueness self._force_total_order = force_total_order - self._df_snapshot: Dict[ - bigquery.TableReference, Tuple[datetime.datetime, bigquery.Table] - ] = {} + self._df_snapshot: Dict[str, Tuple[datetime.datetime, bigquery.Table]] = {} self._metrics = metrics + self._publisher = publisher # Unfortunate circular reference, but need to pass reference when constructing objects self._session = session self._clock = session_time.BigQuerySyncedClock(bqclient) self._clock.sync() - def read_pandas_load_job( - self, pandas_dataframe: pandas.DataFrame, api_name: str + def read_pandas( + self, + pandas_dataframe: pandas.DataFrame, + method: Literal["load", "stream", "write"], ) -> dataframe.DataFrame: - import bigframes.dataframe as dataframe + # TODO: Push this into from_pandas, along with index flag + from bigframes import dataframe - df_and_labels = bf_io_pandas.pandas_to_bq_compatible(pandas_dataframe) - pandas_dataframe_copy = df_and_labels.df - new_idx_ids = pandas_dataframe_copy.index.names - ordering_col = df_and_labels.ordering_col + val_cols, idx_cols = utils.get_standardized_ids( + pandas_dataframe.columns, pandas_dataframe.index.names, strict=True + ) + prepared_df = pandas_dataframe.reset_index(drop=False).set_axis( + [*idx_cols, *val_cols], axis="columns" + ) + managed_data = local_data.ManagedArrowTable.from_pandas(prepared_df) + block = blocks.Block( + self.read_managed_data(managed_data, method=method), + index_columns=idx_cols, + column_labels=pandas_dataframe.columns, + index_labels=pandas_dataframe.index.names, + ) + return dataframe.DataFrame(block) - # TODO(https://github.com/googleapis/python-bigquery-pandas/issues/760): - # Once pandas-gbq can show a link to the running load job like - # bigframes does, switch to using pandas-gbq to load the - # bigquery-compatible pandas DataFrame. - schema: list[ - bigquery.SchemaField - ] = pandas_gbq.schema.pandas_to_bigquery.dataframe_to_bigquery_fields( - pandas_dataframe_copy, - index=True, + def read_managed_data( + self, + data: local_data.ManagedArrowTable, + method: Literal["load", "stream", "write"], + ) -> core.ArrayValue: + offsets_col = guid.generate_guid("upload_offsets_") + if method == "load": + gbq_source = self.load_data(data, offsets_col=offsets_col) + elif method == "stream": + gbq_source = self.stream_data(data, offsets_col=offsets_col) + elif method == "write": + gbq_source = self.write_data(data, offsets_col=offsets_col) + else: + raise ValueError(f"Unsupported read method {method}") + + return core.ArrayValue.from_bq_data_source( + source=gbq_source, + scan_list=nodes.ScanList( + tuple( + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + for item in data.schema.items + ) + ), + session=self._session, + ) + + def load_data( + self, + data: local_data.ManagedArrowTable, + offsets_col: str, + ) -> bq_data.BigqueryDataSource: + """Load managed data into bigquery""" + + # JSON support incomplete + for item in data.schema.items: + _validate_dtype_can_load(item.column, item.dtype) + + schema_w_offsets = data.schema.append( + schemata.SchemaItem(offsets_col, bigframes.dtypes.INT_DTYPE) ) + bq_schema = schema_w_offsets.to_bigquery(_LOAD_JOB_TYPE_OVERRIDES) job_config = bigquery.LoadJobConfig() - job_config.schema = schema + job_config.source_format = bigquery.SourceFormat.PARQUET - # TODO: Remove this. It's likely that the slower load job due to - # clustering doesn't improve speed of queries because pandas tables are - # small. - cluster_cols = [ordering_col] - job_config.clustering_fields = cluster_cols + # Ensure we can load pyarrow.list_ / BQ ARRAY type. + # See internal issue 414374215. + parquet_options = bigquery.ParquetOptions() + parquet_options.enable_list_inference = True + job_config.parquet_options = parquet_options - job_config.labels = {"bigframes-api": api_name} + job_config.schema = bq_schema - load_table_destination = self._storage_manager._random_table() - load_job = self._bqclient.load_table_from_dataframe( - pandas_dataframe_copy, - load_table_destination, - job_config=job_config, + load_table_destination = self._storage_manager.create_temp_table( + bq_schema, [offsets_col] ) - self._start_generic_job(load_job) + buffer = io.BytesIO() + data.to_parquet( + buffer, + offsets_col=offsets_col, + geo_format="wkt", + duration_type="duration", + json_type="string", + ) + buffer.seek(0) + load_job = self._bqclient.load_table_from_file( + buffer, destination=load_table_destination, job_config=job_config + ) + self._start_generic_job(load_job) + # must get table metadata after load job for accurate metadata destination_table = self._bqclient.get_table(load_table_destination) - array_value = core.ArrayValue.from_table( - table=destination_table, - # TODO (b/394156190): Generate this directly from original pandas df. - schema=schemata.ArraySchema.from_bq_table( - destination_table, df_and_labels.col_type_overrides - ), - session=self._session, - offsets_col=ordering_col, - ).drop_columns([ordering_col]) + return bq_data.BigqueryDataSource( + bq_data.GbqTable.from_table(destination_table), + schema=schema_w_offsets, + ordering=ordering.TotalOrdering.from_offset_col(offsets_col), + n_rows=data.metadata.row_count, + ) - block = blocks.Block( - array_value, - index_columns=new_idx_ids, - column_labels=df_and_labels.column_labels, - index_labels=df_and_labels.index_labels, + def stream_data( + self, + data: local_data.ManagedArrowTable, + offsets_col: str, + ) -> bq_data.BigqueryDataSource: + """Load managed data into bigquery""" + MAX_BYTES = 10000000 # streaming api has 10MB limit + SAFETY_MARGIN = ( + 40 # Perf seems bad for large chunks, so do 40x smaller than max + ) + batch_count = math.ceil( + data.metadata.total_bytes / (MAX_BYTES // SAFETY_MARGIN) + ) + rows_per_batch = math.ceil(data.metadata.row_count / batch_count) + + schema_w_offsets = data.schema.append( + schemata.SchemaItem(offsets_col, bigframes.dtypes.INT_DTYPE) + ) + bq_schema = schema_w_offsets.to_bigquery(_STREAM_JOB_TYPE_OVERRIDES) + load_table_destination = self._storage_manager.create_temp_table( + bq_schema, [offsets_col] ) - return dataframe.DataFrame(block) - def read_pandas_streaming( + rows = data.itertuples( + geo_format="wkt", duration_type="int", json_type="object" + ) + rows_w_offsets = ((*row, offset) for offset, row in enumerate(rows)) + + # TODO: don't use batched + batches = _batched(rows_w_offsets, rows_per_batch) + ids_iter = map(str, itertools.count()) + + for batch in batches: + batch_rows = list(batch) + row_ids = itertools.islice(ids_iter, len(batch_rows)) + + for errors in self._bqclient.insert_rows( + load_table_destination, + batch_rows, + selected_fields=bq_schema, + row_ids=row_ids, # used to ensure only-once insertion + ): + if errors: + raise ValueError( + f"Problem loading at least one row from DataFrame: {errors}. {constants.FEEDBACK_LINK}" + ) + destination_table = self._bqclient.get_table(load_table_destination) + return bq_data.BigqueryDataSource( + bq_data.GbqTable.from_table(destination_table), + schema=schema_w_offsets, + ordering=ordering.TotalOrdering.from_offset_col(offsets_col), + n_rows=data.metadata.row_count, + ) + + def write_data( self, - pandas_dataframe: pandas.DataFrame, - ) -> dataframe.DataFrame: - """Same as pandas_to_bigquery_load, but uses the BQ legacy streaming API.""" - import bigframes.dataframe as dataframe + data: local_data.ManagedArrowTable, + offsets_col: str, + ) -> bq_data.BigqueryDataSource: + """Load managed data into BigQuery using multiple concurrent streams.""" + schema_w_offsets = data.schema.append( + schemata.SchemaItem(offsets_col, bigframes.dtypes.INT_DTYPE) + ) + bq_schema = schema_w_offsets.to_bigquery(_STREAM_JOB_TYPE_OVERRIDES) + bq_table_ref = self._storage_manager.create_temp_table(bq_schema, [offsets_col]) + parent = bq_table_ref.to_bqstorage() - df_and_labels = bf_io_pandas.pandas_to_bq_compatible(pandas_dataframe) - pandas_dataframe_copy = df_and_labels.df - new_idx_ids = pandas_dataframe_copy.index.names - ordering_col = df_and_labels.ordering_col - - # TODO(https://github.com/googleapis/python-bigquery-pandas/issues/300): - # Once pandas-gbq can do streaming inserts (again), switch to using - # pandas-gbq to write the bigquery-compatible pandas DataFrame. - schema: list[ - bigquery.SchemaField - ] = pandas_gbq.schema.pandas_to_bigquery.dataframe_to_bigquery_fields( - pandas_dataframe_copy, - index=True, - ) - - destination = self._storage_manager.create_temp_table( - schema, - [ordering_col], - ) - destination_table = bigquery.Table(destination, schema=schema) - # TODO(swast): Confirm that the index is written. - for errors in self._bqclient.insert_rows_from_dataframe( - destination_table, - pandas_dataframe_copy, - ): - if errors: - raise ValueError( - f"Problem loading at least one row from DataFrame: {errors}. {constants.FEEDBACK_LINK}" - ) - array_value = ( - core.ArrayValue.from_table( - table=destination_table, - schema=schemata.ArraySchema.from_bq_table( - destination_table, df_and_labels.col_type_overrides - ), - session=self._session, - # Don't set the offsets column because we want to group by it. + # Some light benchmarking went into the constants here, not definitive + TARGET_BATCH_BYTES = ( + 5_000_000 # Must stay under the hard 10MB limit per request + ) + rows_per_batch = math.ceil( + data.metadata.row_count * TARGET_BATCH_BYTES / data.metadata.total_bytes + ) + min_batches = math.ceil(data.metadata.row_count / rows_per_batch) + num_streams = min((os.cpu_count() or 4) * 4, min_batches) + + schema, all_batches = data.to_arrow( + offsets_col=offsets_col, + duration_type="int", + max_chunksize=rows_per_batch, + ) + serialized_schema = schema.serialize().to_pybytes() + + def stream_worker(work: Iterator[pa.RecordBatch]) -> str: + requested_stream = bq_storage_types.WriteStream( + type_=bq_storage_types.WriteStream.Type.PENDING + ) + stream = self._write_client.create_write_stream( + parent=parent, write_stream=requested_stream ) - # There may be duplicate rows because of hidden retries, so use a query to - # deduplicate based on the ordering ID, which is guaranteed to be unique. - # We know that rows with same ordering ID are duplicates, - # so ANY_VALUE() is deterministic. - .aggregate( - by_column_ids=[ordering_col], - aggregations=[ - ( - expression.UnaryAggregation( - agg_ops.AnyValueOp(), - expression.deref(field.name), - ), - field.name, + stream_name = stream.name + + def request_generator(): + current_offset = 0 + for batch in work: + request = bq_storage_types.AppendRowsRequest( + write_stream=stream.name, offset=current_offset + ) + + request.arrow_rows.writer_schema.serialized_schema = ( + serialized_schema + ) + request.arrow_rows.rows.serialized_record_batch = ( + batch.serialize().to_pybytes() ) - for field in destination_table.schema - if field.name != ordering_col - ], - ).drop_columns([ordering_col]) + + yield request + current_offset += batch.num_rows + + responses = self._write_client.append_rows(requests=request_generator()) + for resp in responses: + if resp.row_errors: + raise ValueError( + f"Errors in stream {stream_name}: {resp.row_errors}" + ) + self._write_client.finalize_write_stream(name=stream_name) + return stream_name + + shared_batches = ThreadSafeIterator(all_batches) + + stream_names = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=num_streams) as executor: + futures = [] + for _ in range(num_streams): + try: + work = next(shared_batches) + except StopIteration: + break # existing workers have consume all work, don't create more workers + # Guarantee at least a single piece of work for each worker + future = executor.submit( + stream_worker, itertools.chain((work,), shared_batches) + ) + futures.append(future) + + for future in concurrent.futures.as_completed(futures): + stream_name = future.result() + stream_names.append(stream_name) + + # This makes all data from all streams visible in the table at once + commit_request = bq_storage_types.BatchCommitWriteStreamsRequest( + parent=parent, write_streams=stream_names ) - block = blocks.Block( - array_value, - index_columns=new_idx_ids, - column_labels=df_and_labels.column_labels, - index_labels=df_and_labels.index_labels, + self._write_client.batch_commit_write_streams(commit_request) + + result_table = bq_data.GbqTable.from_ref_and_schema( + bq_table_ref, schema=bq_schema, cluster_cols=[offsets_col] + ) + return bq_data.BigqueryDataSource( + result_table, + schema=schema_w_offsets, + ordering=ordering.TotalOrdering.from_offset_col(offsets_col), + n_rows=data.metadata.row_count, ) - return dataframe.DataFrame(block) def _start_generic_job(self, job: formatting_helpers.GenericJob): if bigframes.options.display.progress_bar is not None: @@ -274,18 +560,139 @@ def _start_generic_job(self, job: formatting_helpers.GenericJob): else: job.result() + @overload + def read_gbq_table( # type: ignore[overload-overlap] + self, + table_id: str, + *, + index_col: Iterable[str] + | str + | Iterable[int] + | int + | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + names: Optional[Iterable[str]] = ..., + max_results: Optional[int] = ..., + use_cache: bool = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + enable_snapshot: bool = ..., + dry_run: Literal[False] = ..., + force_total_order: Optional[bool] = ..., + n_rows: Optional[int] = None, + index_col_in_columns: bool = False, + publish_execution: bool = True, + ) -> dataframe.DataFrame: + ... + + @overload def read_gbq_table( self, - query: str, + table_id: str, *, - index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = (), + index_col: Iterable[str] + | str + | Iterable[int] + | int + | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + names: Optional[Iterable[str]] = ..., + max_results: Optional[int] = ..., + use_cache: bool = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + enable_snapshot: bool = ..., + dry_run: Literal[True] = ..., + force_total_order: Optional[bool] = ..., + n_rows: Optional[int] = None, + index_col_in_columns: bool = False, + publish_execution: bool = True, + ) -> pandas.Series: + ... + + def read_gbq_table( + self, + table_id: str, + *, + index_col: Iterable[str] + | str + | Iterable[int] + | int + | bigframes.enums.DefaultIndexKind = (), columns: Iterable[str] = (), + names: Optional[Iterable[str]] = None, max_results: Optional[int] = None, - api_name: str, use_cache: bool = True, filters: third_party_pandas_gbq.FiltersType = (), enable_snapshot: bool = True, - ) -> dataframe.DataFrame: + dry_run: bool = False, + force_total_order: Optional[bool] = None, + n_rows: Optional[int] = None, + index_col_in_columns: bool = False, + publish_execution: bool = True, + ) -> dataframe.DataFrame | pandas.Series: + """Read a BigQuery table into a BigQuery DataFrames DataFrame. + + This method allows you to create a DataFrame from a BigQuery table. + You can specify the columns to load, an index column, and apply + filters. + + Args: + table_id (str): + The identifier of the BigQuery table to read. + index_col (Iterable[str] | str | Iterable[int] | int | bigframes.enums.DefaultIndexKind, optional): + The column(s) to use as the index for the DataFrame. This can be + a single column name or a list of column names. If not provided, + a default index will be used based on the session's + ``default_index_type``. + columns (Iterable[str], optional): + The columns to read from the table. If not specified, all + columns will be read. + names (Optional[Iterable[str]], optional): + A list of column names to use for the resulting DataFrame. This + is useful if you want to rename the columns as you read the + data. + max_results (Optional[int], optional): + The maximum number of rows to retrieve from the table. If not + specified, all rows will be loaded. + use_cache (bool, optional): + Whether to use cached results for the query. Defaults to True. + Setting this to False will force a re-execution of the query. + filters (third_party_pandas_gbq.FiltersType, optional): + A list of filters to apply to the data. Filters are specified + as a list of tuples, where each tuple contains a column name, + an operator (e.g., '==', '!='), and a value. + enable_snapshot (bool, optional): + If True, a snapshot of the table is used to ensure that the + DataFrame is deterministic, even if the underlying table + changes. Defaults to True. + dry_run (bool, optional): + If True, the function will not actually execute the query but + will instead return statistics about the table. Defaults to False. + force_total_order (Optional[bool], optional): + If True, a total ordering is enforced on the DataFrame, which + can be useful for operations that require a stable row order. + If None, the session's default behavior is used. + n_rows (Optional[int], optional): + The number of rows to consider for type inference and other + metadata operations. This does not limit the number of rows + in the final DataFrame. + index_col_in_columns (bool, optional): + Specifies if the ``index_col`` is also present in the ``columns`` + list. Defaults to ``False``. + + * If ``False``, ``index_col`` and ``columns`` must specify + distinct sets of columns. An error will be raised if any + column is found in both. + * If ``True``, the column(s) in ``index_col`` are expected to + also be present in the ``columns`` list. This is useful + when the index is selected from the data columns (e.g., in a + ``read_csv`` scenario). The column will be used as the + DataFrame's index and removed from the list of value columns. + publish_execution (bool, optional): + If True, sends an execution started and stopped event if this + causes a query. Set to False if using read_gbq_table from + another function that is reporting execution. + """ + import bigframes.core.events import bigframes.dataframe as dataframe # --------------------------------- @@ -297,11 +704,10 @@ def read_gbq_table( f"`max_results` should be a positive number, got {max_results}." ) - table_ref = google.cloud.bigquery.table.TableReference.from_string( - query, default_project=self._bqclient.project - ) + _check_duplicates("columns", columns) columns = list(columns) + include_all_columns = columns is None or len(columns) == 0 filters = typing.cast(list, list(filters)) # --------------------------------- @@ -310,27 +716,47 @@ def read_gbq_table( time_travel_timestamp, table = bf_read_gbq_table.get_table_metadata( self._bqclient, - table_ref=table_ref, + table_id=table_id, + default_project=self._bqclient.project, bq_time=self._clock.get_time(), cache=self._df_snapshot, use_cache=use_cache, + publisher=self._publisher, ) - table_column_names = {field.name for field in table.schema} if table.location.casefold() != self._storage_manager.location.casefold(): raise ValueError( f"Current session is in {self._storage_manager.location} but dataset '{table.project}.{table.dataset_id}' is located in {table.location}" ) - for key in columns: - if key not in table_column_names: - possibility = min( - table_column_names, - key=lambda item: jellyfish.levenshtein_distance(key, item), - ) - raise ValueError( - f"Column '{key}' of `columns` not found in this table. Did you mean '{possibility}'?" - ) + table_column_names = [field.name for field in table.schema] + rename_to_schema: Optional[Dict[str, str]] = None + if names is not None: + _check_names_param(names, index_col, columns, table_column_names) + + # Additional unnamed columns is going to set as index columns + len_names = len(list(names)) + len_schema = len(table.schema) + if len(columns) == 0 and len_names < len_schema: + index_col = range(len_schema - len_names) + names = [ + field.name for field in table.schema[: len_schema - len_names] + ] + list(names) + + assert len_schema >= len_names + assert len_names >= len(columns) + + table_column_names = table_column_names[: len(list(names))] + rename_to_schema = dict(zip(list(names), table_column_names)) + + if len(columns) != 0: + if names is None: + _check_columns_param(columns, table_column_names) + else: + _check_columns_param(columns, names) + names = columns + assert rename_to_schema is not None + columns = [rename_to_schema[renamed_name] for renamed_name in columns] # Converting index_col into a list of column names requires # the table metadata because we might use the primary keys @@ -338,30 +764,32 @@ def read_gbq_table( index_cols = bf_read_gbq_table.get_index_cols( table=table, index_col=index_col, + rename_to_schema=rename_to_schema, + default_index_type=self._default_index_type, ) - - for key in index_cols: - if key not in table_column_names: - possibility = min( - table_column_names, - key=lambda item: jellyfish.levenshtein_distance(key, item), - ) - raise ValueError( - f"Column '{key}' of `index_col` not found in this table. Did you mean '{possibility}'?" - ) + _check_index_col_param( + index_cols, + columns, + table_columns=table_column_names, + index_col_in_columns=index_col_in_columns, + ) + if index_col_in_columns and not include_all_columns: + set_index = set(list(index_cols) if index_cols is not None else []) + columns = [col for col in columns if col not in set_index] # ----------------------------- # Optionally, execute the query # ----------------------------- - # max_results introduces non-determinism and limits the cost on - # clustered tables, so fallback to a query. We do this here so that - # the index is consistent with tables that have primary keys, even - # when max_results is set. - # TODO(b/338419730): We don't need to fallback to a query for wildcard - # tables if we allow some non-determinism when time travel isn't supported. - if max_results is not None or bf_io_bigquery.is_table_with_wildcard_suffix( - query + if ( + # max_results introduces non-determinism and limits the cost on + # clustered tables, so fallback to a query. We do this here so that + # the index is consistent with tables that have primary keys, even + # when max_results is set. + max_results is not None + # Views such as INFORMATION_SCHEMA can introduce non-determinism. + # They can update frequently and don't support time travel. + or bf_read_gbq_table.is_information_schema(table_id) ): # TODO(b/338111344): If we are running a query anyway, we might as # well generate ROW_NUMBER() at the same time. @@ -369,7 +797,7 @@ def read_gbq_table( itertools.chain(index_cols, columns) if columns else () ) query = bf_io_bigquery.to_query( - query, + f"{table.project}.{table.dataset_id}.{table.table_id}", columns=all_columns, sql_predicate=bf_io_bigquery.compile_filters(filters) if filters @@ -380,13 +808,20 @@ def read_gbq_table( time_travel_timestamp=None, ) - return self.read_gbq_query( + df = self.read_gbq_query( # type: ignore # for dry_run overload query, index_col=index_cols, columns=columns, - api_name="read_gbq_table", use_cache=use_cache, + dry_run=dry_run, + # If max_results has been set, we almost certainly have < 10 GB + # of results. + allow_large_results=False, ) + return df + + if dry_run: + return dry_runs.get_table_stats(table) # ----------------------------------------- # Validate table access and features @@ -405,18 +840,16 @@ def read_gbq_table( else (*columns, *[col for col in index_cols if col not in columns]) ) - try: - enable_snapshot = enable_snapshot and bf_read_gbq_table.validate_table( - self._bqclient, - table, - all_columns, - time_travel_timestamp, - filter_str, - ) - except google.api_core.exceptions.Forbidden as ex: - if "Drive credentials" in ex.message: - ex.message += "\nCheck https://cloud.google.com/bigquery/docs/query-drive-data#Google_Drive_permissions." - raise + enable_snapshot = enable_snapshot and bf_read_gbq_table.is_time_travel_eligible( + self._bqclient, + table, + all_columns, + time_travel_timestamp, + filter_str, + should_warn=True, + should_dry_run=True, + publisher=self._publisher, + ) # ---------------------------- # Create ordering and validate @@ -428,26 +861,46 @@ def read_gbq_table( # TODO(b/338065601): Provide a way to assume uniqueness and avoid this # check. primary_key = bf_read_gbq_table.infer_unique_columns( - bqclient=self._bqclient, table=table, index_cols=index_cols, - api_name=api_name, - # If non in strict ordering mode, don't go through overhead of scanning index column(s) to determine if unique - metadata_only=not self._scan_index_uniqueness, ) - schema = schemata.ArraySchema.from_bq_table(table) - if columns: - schema = schema.select(index_cols + columns) + + # If non in strict ordering mode, don't go through overhead of scanning index column(s) to determine if unique + if not primary_key and self._scan_index_uniqueness and index_cols: + if publish_execution: + self._publisher.publish( + bigframes.core.events.ExecutionStarted(), + ) + primary_key = bf_read_gbq_table.check_if_index_columns_are_unique( + self._bqclient, + table=table, + index_cols=index_cols, + publisher=self._publisher, + ) + if publish_execution: + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + + selected_cols = None if include_all_columns else index_cols + columns array_value = core.ArrayValue.from_table( table, - schema=schema, + columns=selected_cols, predicate=filter_str, at_time=time_travel_timestamp if enable_snapshot else None, primary_key=primary_key, session=self._session, + n_rows=n_rows, ) # if we don't have a unique index, we order by row hash if we are in strict mode - if self._force_total_order: + if ( + # If the user has explicitly selected or disabled total ordering for + # this API call, respect that choice. + (force_total_order is not None and force_total_order) + # If the user has not explicitly selected or disabled total ordering + # for this API call, respect the default choice for the session. + or (force_total_order is None and self._force_total_order) + ): if not primary_key: array_value = array_value.order_by( [ @@ -479,6 +932,16 @@ def read_gbq_table( index_names = [None] value_columns = [col for col in array_value.column_ids if col not in index_cols] + if names is not None: + assert rename_to_schema is not None + schema_to_rename = {value: key for key, value in rename_to_schema.items()} + if index_col != bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64: + index_names = [ + schema_to_rename.get(index_col, index_col) + for index_col in index_cols + ] + value_columns = [schema_to_rename.get(col, col) for col in value_columns] + block = blocks.Block( array_value, index_columns=index_cols, @@ -494,29 +957,26 @@ def read_gbq_table( df.sort_index() return df - def _read_bigquery_load_job( + def load_file( self, filepath_or_buffer: str | IO["bytes"], - table: Union[bigquery.Table, bigquery.TableReference], *, job_config: bigquery.LoadJobConfig, - index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = (), - columns: Iterable[str] = (), - ) -> dataframe.DataFrame: - index_cols = _to_index_cols(index_col) - - if not job_config.clustering_fields and index_cols: - job_config.clustering_fields = index_cols[:_MAX_CLUSTER_COLUMNS] - + ) -> str: + # Need to create session table beforehand + table = self._storage_manager.create_temp_table(_PLACEHOLDER_SCHEMA) + # but, we just overwrite the placeholder schema immediately with the load job + job_config.write_disposition = bigquery.WriteDisposition.WRITE_TRUNCATE if isinstance(filepath_or_buffer, str): + filepath_or_buffer = os.path.expanduser(filepath_or_buffer) if filepath_or_buffer.startswith("gs://"): load_job = self._bqclient.load_table_from_uri( - filepath_or_buffer, table, job_config=job_config + filepath_or_buffer, destination=table, job_config=job_config ) elif os.path.exists(filepath_or_buffer): # local file path with open(filepath_or_buffer, "rb") as source_file: load_job = self._bqclient.load_table_from_file( - source_file, table, job_config=job_config + source_file, destination=table, job_config=job_config ) else: raise NotImplementedError( @@ -525,30 +985,46 @@ def _read_bigquery_load_job( ) else: load_job = self._bqclient.load_table_from_file( - filepath_or_buffer, table, job_config=job_config + filepath_or_buffer, destination=table, job_config=job_config ) self._start_generic_job(load_job) table_id = f"{table.project}.{table.dataset_id}.{table.table_id}" + return table_id - # Update the table expiration so we aren't limited to the default 24 - # hours of the anonymous dataset. - table_expiration = bigquery.Table(table_id) - table_expiration.expires = ( - datetime.datetime.now(datetime.timezone.utc) - + bigframes.constants.DEFAULT_EXPIRATION - ) - self._bqclient.update_table(table_expiration, ["expires"]) - - # The BigQuery REST API for tables.get doesn't take a session ID, so we - # can't get the schema for a temp table that way. + @overload + def read_gbq_query( # type: ignore[overload-overlap] + self, + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + use_cache: Optional[bool] = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + dry_run: Literal[False] = ..., + force_total_order: Optional[bool] = ..., + allow_large_results: bool, + ) -> dataframe.DataFrame: + ... - return self.read_gbq_table( - query=table_id, - index_col=index_col, - columns=columns, - api_name="read_gbq_table", - ) + @overload + def read_gbq_query( + self, + query: str, + *, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = ..., + columns: Iterable[str] = ..., + configuration: Optional[Dict] = ..., + max_results: Optional[int] = ..., + use_cache: Optional[bool] = ..., + filters: third_party_pandas_gbq.FiltersType = ..., + dry_run: Literal[True] = ..., + force_total_order: Optional[bool] = ..., + allow_large_results: bool, + ) -> pandas.Series: + ... def read_gbq_query( self, @@ -558,12 +1034,12 @@ def read_gbq_query( columns: Iterable[str] = (), configuration: Optional[Dict] = None, max_results: Optional[int] = None, - api_name: str = "read_gbq_query", use_cache: Optional[bool] = None, filters: third_party_pandas_gbq.FiltersType = (), - ) -> dataframe.DataFrame: - import bigframes.dataframe as dataframe - + dry_run: bool = False, + force_total_order: Optional[bool] = None, + allow_large_results: bool, + ) -> dataframe.DataFrame | pandas.Series: configuration = _transform_read_gbq_configuration(configuration) if "query" not in configuration: @@ -587,7 +1063,9 @@ def read_gbq_query( True if use_cache is None else use_cache ) + _check_duplicates("columns", columns) index_cols = _to_index_cols(index_col) + _check_index_col_param(index_cols, columns) filters_copy1, filters_copy2 = itertools.tee(filters) has_filters = len(list(filters_copy1)) != 0 @@ -606,47 +1084,149 @@ def read_gbq_query( time_travel_timestamp=None, ) - destination, query_job = self._query_to_destination( - query, - index_cols, - api_name=api_name, - configuration=configuration, + if dry_run: + job_config = typing.cast( + bigquery.QueryJobConfig, + bigquery.QueryJobConfig.from_api_repr(configuration), + ) + job_config.dry_run = True + query_job = self._bqclient.query(query, job_config=job_config) + if self._metrics is not None: + self._metrics.count_job_stats(query_job=query_job) + return dry_runs.get_query_stats_with_inferred_dtypes( + query_job, list(columns), index_cols + ) + + # We want to make sure we show progress when we actually do execute a + # query. Since we have got this far, we know it's not a dry run. + self._publisher.publish( + bigframes.core.events.ExecutionStarted(), ) + query_job_for_metrics: Optional[bigquery.QueryJob] = None + destination: Optional[bigquery.TableReference] = None + + # TODO(b/421161077): If an explicit destination table is set in + # configuration, should we respect that setting? + if allow_large_results: + destination, query_job = self._query_to_destination( + query, + # No cluster candidates as user query might not be clusterable + # (eg because of ORDER BY clause) + cluster_candidates=[], + configuration=configuration, + ) + query_job_for_metrics = query_job + rows: Optional[google.cloud.bigquery.table.RowIterator] = None + else: + job_config = typing.cast( + bigquery.QueryJobConfig, + bigquery.QueryJobConfig.from_api_repr(configuration), + ) + + # TODO(b/420984164): We may want to set a page_size here to limit + # the number of results in the first jobs.query response. + rows = self._start_query_with_job_optional( + query, + job_config=job_config, + ) + + # If there is a query job, fetch it so that we can get the + # statistics and destination table, if needed. + if rows.job_id and rows.location and rows.project: + query_job = cast( + bigquery.QueryJob, + self._bqclient.get_job( + rows.job_id, project=rows.project, location=rows.location + ), + ) + destination = query_job.destination + query_job_for_metrics = query_job + + # We split query execution from results fetching so that we can log + # metrics from either the query job, row iterator, or both. if self._metrics is not None: - self._metrics.count_job_stats(query_job) - - # If there was no destination table, that means the query must have - # been DDL or DML. Return some job metadata, instead. - if not destination: - return dataframe.DataFrame( - data=pandas.DataFrame( - { - "statement_type": [ - query_job.statement_type if query_job else "unknown" - ], - "job_id": [query_job.job_id if query_job else "unknown"], - "location": [query_job.location if query_job else "unknown"], - } - ), + self._metrics.count_job_stats( + query_job=query_job_for_metrics, row_iterator=rows + ) + + # It's possible that there's no job and therefore no corresponding + # destination table. In this case, we must create a local node. + # + # TODO(b/420984164): Tune the threshold for which we download to + # local node. Likely there are a wide range of sizes in which it + # makes sense to download the results beyond the first page, even if + # there is a job and destination table available. + if query_job_for_metrics is None and rows is not None: + df = bf_read_gbq_query.create_dataframe_from_row_iterator( + rows, + session=self._session, + index_col=index_col, + columns=columns, + ) + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + return df + + # We already checked rows, so if there's no destination table, then + # there are no results to return. + if destination is None: + df = bf_read_gbq_query.create_dataframe_from_query_job_stats( + query_job_for_metrics, session=self._session, ) + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + return df + + # If the query was DDL or DML, return some job metadata. See + # https://cloud.google.com/bigquery/docs/reference/rest/v2/Job#JobStatistics2.FIELDS.statement_type + # for possible statement types. Note that destination table does exist + # for some DDL operations such as CREATE VIEW, but we don't want to + # read from that. See internal issue b/444282709. + if ( + query_job_for_metrics is not None + and not bf_read_gbq_query.should_return_query_results(query_job_for_metrics) + ): + df = bf_read_gbq_query.create_dataframe_from_query_job_stats( + query_job_for_metrics, + session=self._session, + ) + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + return df - return self.read_gbq_table( + # Speed up counts by getting counts from result metadata. + if rows is not None: + n_rows = rows.total_rows + elif query_job_for_metrics is not None: + n_rows = query_job_for_metrics.result().total_rows + else: + n_rows = None + + df = self.read_gbq_table( f"{destination.project}.{destination.dataset_id}.{destination.table_id}", index_col=index_col, columns=columns, use_cache=configuration["query"]["useQueryCache"], - api_name=api_name, + force_total_order=force_total_order, + n_rows=n_rows, + publish_execution=False, # max_results and filters are omitted because they are already # handled by to_query(), above. ) + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + return df def _query_to_destination( self, query: str, - index_cols: List[str], - api_name: str, + cluster_candidates: List[str], configuration: dict = {"query": {"useQueryCache": True}}, do_clustering=True, ) -> Tuple[Optional[bigquery.TableReference], bigquery.QueryJob]: @@ -654,11 +1234,12 @@ def _query_to_destination( # bother trying to do a CREATE TEMP TABLE ... AS SELECT ... statement. dry_run_config = bigquery.QueryJobConfig() dry_run_config.dry_run = True - _, dry_run_job = self._start_query( - query, job_config=dry_run_config, api_name=api_name + dry_run_job = self._start_query_with_job( + query, + job_config=dry_run_config, ) if dry_run_job.statement_type != "SELECT": - _, query_job = self._start_query(query, api_name=api_name) + query_job = self._start_query_with_job(query) return query_job.destination, query_job # Create a table to workaround BigQuery 10 GB query results limit. See: @@ -668,7 +1249,7 @@ def _query_to_destination( assert schema is not None if do_clustering: cluster_cols = bf_io_bigquery.select_cluster_cols( - schema, cluster_candidates=index_cols + schema, cluster_candidates=cluster_candidates ) else: cluster_cols = [] @@ -692,11 +1273,10 @@ def _query_to_destination( # Write to temp table to workaround BigQuery 10 GB query results # limit. See: internal issue 303057336. job_config.labels["error_caught"] = "true" - _, query_job = self._start_query( + query_job = self._start_query_with_job( query, job_config=job_config, timeout=timeout, - api_name=api_name, ) return query_job.destination, query_job except google.api_core.exceptions.BadRequest: @@ -704,36 +1284,76 @@ def _query_to_destination( # tables as the destination. For example, if the query has a # top-level ORDER BY, this conflicts with our ability to cluster # the table by the index column(s). - _, query_job = self._start_query(query, timeout=timeout, api_name=api_name) + query_job = self._start_query_with_job(query, timeout=timeout) return query_job.destination, query_job - def _start_query( + def _prepare_job_config( + self, + job_config: Optional[google.cloud.bigquery.QueryJobConfig] = None, + ) -> google.cloud.bigquery.QueryJobConfig: + job_config = bigquery.QueryJobConfig() if job_config is None else job_config + + if bigframes.options.compute.maximum_bytes_billed is not None: + # Maybe this should be pushed down into start_query_with_client + job_config.maximum_bytes_billed = ( + bigframes.options.compute.maximum_bytes_billed + ) + + return job_config + + def _start_query_with_job_optional( self, sql: str, + *, + job_config: Optional[google.cloud.bigquery.QueryJobConfig] = None, + timeout: Optional[float] = None, + ) -> google.cloud.bigquery.table.RowIterator: + """ + Starts BigQuery query with job optional and waits for results. + + Do not execute dataframe through this API, instead use the executor. + """ + job_config = self._prepare_job_config(job_config) + rows, _ = bf_io_bigquery.start_query_with_client( + self._bqclient, + sql, + job_config=job_config, + timeout=timeout, + location=None, + project=None, + metrics=None, + query_with_job=False, + publisher=self._publisher, + session=self._session, + ) + return rows + + def _start_query_with_job( + self, + sql: str, + *, job_config: Optional[google.cloud.bigquery.QueryJobConfig] = None, - max_results: Optional[int] = None, timeout: Optional[float] = None, - api_name: Optional[str] = None, - ) -> Tuple[google.cloud.bigquery.table.RowIterator, bigquery.QueryJob]: + ) -> bigquery.QueryJob: """ Starts BigQuery query job and waits for results. Do not execute dataframe through this API, instead use the executor. """ - job_config = bigquery.QueryJobConfig() if job_config is None else job_config - if bigframes.options.compute.maximum_bytes_billed is not None: - # Maybe this should be pushed down into start_query_with_client - job_config.maximum_bytes_billed = ( - bigframes.options.compute.maximum_bytes_billed - ) - return bf_io_bigquery.start_query_with_client( + job_config = self._prepare_job_config(job_config) + _, query_job = bf_io_bigquery.start_query_with_client( self._bqclient, sql, job_config=job_config, - max_results=max_results, timeout=timeout, - api_name=api_name, + location=None, + project=None, + metrics=None, + query_with_job=True, + publisher=self._publisher, + session=self._session, ) + return query_job def _transform_read_gbq_configuration(configuration: Optional[dict]) -> dict: @@ -756,3 +1376,55 @@ def _transform_read_gbq_configuration(configuration: Optional[dict]) -> dict: configuration["jobTimeoutMs"] = timeout_ms return configuration + + +def _validate_dtype_can_load(name: str, column_type: bigframes.dtypes.Dtype): + """ + Determines whether a datatype is supported by bq load jobs. + + Due to a BigQuery IO limitation with loading JSON from Parquet files (b/374784249), + we're using a workaround: storing JSON as strings and then parsing them into JSON + objects. + TODO(b/395912450): Remove workaround solution once b/374784249 got resolved. + + Raises: + NotImplementedError: Type is not yet supported by load jobs. + """ + # we can handle top-level json, but not nested yet through string conversion + if column_type == bigframes.dtypes.JSON_DTYPE: + return + + if isinstance( + column_type, pandas.ArrowDtype + ) and bigframes.dtypes.contains_db_dtypes_json_arrow_type( + column_type.pyarrow_dtype + ): + raise NotImplementedError( + f"Nested JSON types, found in column `{name}`: `{column_type}`', " + f"are currently unsupported for upload. {constants.FEEDBACK_LINK}" + ) + + +# itertools.batched not available in python <3.12, so we use this instead +def _batched(iterator: Iterable, n: int) -> Iterable: + assert n > 0 + while batch := tuple(itertools.islice(iterator, n)): + yield batch + + +T = TypeVar("T") + + +class ThreadSafeIterator(Iterator[T]): + """A wrapper to make an iterator thread-safe.""" + + def __init__(self, it: Iterable[T]): + self.it = iter(it) + self.lock = threading.Lock() + + def __next__(self): + with self.lock: + return next(self.it) + + def __iter__(self): + return self diff --git a/bigframes/session/local_scan_executor.py b/bigframes/session/local_scan_executor.py new file mode 100644 index 0000000000..fee0f557ea --- /dev/null +++ b/bigframes/session/local_scan_executor.py @@ -0,0 +1,63 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from typing import Optional + +from bigframes.core import bigframe_node, rewrite +from bigframes.session import executor, semi_executor + + +class LocalScanExecutor(semi_executor.SemiExecutor): + """ + Executes plans reducible to a arrow table scan. + """ + + def execute( + self, + plan: bigframe_node.BigFrameNode, + ordered: bool, + peek: Optional[int] = None, + ) -> Optional[executor.ExecuteResult]: + reduced_result = rewrite.try_reduce_to_local_scan(plan) + if not reduced_result: + return None + + node, limit = reduced_result + + if limit is not None: + if peek is None or limit < peek: + peek = limit + + # TODO: Can support some sorting + offsets_col = node.offsets_col.sql if (node.offsets_col is not None) else None + arrow_table = node.local_data_source.to_pyarrow_table(offsets_col=offsets_col) + if peek: + arrow_table = arrow_table.slice(0, peek) + + needed_cols = [item.source_id for item in node.scan_list.items] + if offsets_col is not None: + needed_cols.append(offsets_col) + + arrow_table = arrow_table.select(needed_cols) + arrow_table = arrow_table.rename_columns([id.sql for id in node.ids]) + total_rows = node.row_count + + if (peek is not None) and (total_rows is not None): + total_rows = min(peek, total_rows) + + return executor.LocalExecuteResult( + data=arrow_table, + bf_schema=plan.schema, + ) diff --git a/bigframes/session/metrics.py b/bigframes/session/metrics.py index 33bcd7fbf5..8d43a83d73 100644 --- a/bigframes/session/metrics.py +++ b/bigframes/session/metrics.py @@ -20,6 +20,7 @@ import google.cloud.bigquery as bigquery import google.cloud.bigquery.job as bq_job +import google.cloud.bigquery.table as bq_table LOGGING_NAME_ENV_VAR = "BIGFRAMES_PERFORMANCE_LOG_NAME" @@ -32,25 +33,70 @@ class ExecutionMetrics: execution_secs: float = 0 query_char_count: int = 0 - def count_job_stats(self, query_job: bq_job.QueryJob): - stats = get_performance_stats(query_job) - if stats is not None: - bytes_processed, slot_millis, execution_secs, query_char_count = stats + def count_job_stats( + self, + query_job: Optional[bq_job.QueryJob] = None, + row_iterator: Optional[bq_table.RowIterator] = None, + ): + if query_job is None: + assert row_iterator is not None + + # TODO(tswast): Pass None after making benchmark publishing robust to missing data. + bytes_processed = getattr(row_iterator, "total_bytes_processed", 0) or 0 + query_char_count = len(getattr(row_iterator, "query", "") or "") + slot_millis = getattr(row_iterator, "slot_millis", 0) or 0 + created = getattr(row_iterator, "created", None) + ended = getattr(row_iterator, "ended", None) + exec_seconds = ( + (ended - created).total_seconds() if created and ended else 0.0 + ) + self.execution_count += 1 + self.query_char_count += query_char_count self.bytes_processed += bytes_processed self.slot_millis += slot_millis - self.execution_secs += execution_secs - self.query_char_count += query_char_count - if LOGGING_NAME_ENV_VAR in os.environ: - # when running notebooks via pytest nbmake - write_stats_to_disk( - bytes_processed, slot_millis, execution_secs, query_char_count - ) + self.execution_secs += exec_seconds + + elif query_job.configuration.dry_run: + query_char_count = len(query_job.query) + + # TODO(tswast): Pass None after making benchmark publishing robust to missing data. + bytes_processed = 0 + slot_millis = 0 + exec_seconds = 0.0 + + elif (stats := get_performance_stats(query_job)) is not None: + query_char_count, bytes_processed, slot_millis, exec_seconds = stats + self.execution_count += 1 + self.query_char_count += query_char_count or 0 + self.bytes_processed += bytes_processed or 0 + self.slot_millis += slot_millis or 0 + self.execution_secs += exec_seconds or 0 + write_stats_to_disk( + query_char_count=query_char_count, + bytes_processed=bytes_processed, + slot_millis=slot_millis, + exec_seconds=exec_seconds, + ) + + else: + # TODO(tswast): Pass None after making benchmark publishing robust to missing data. + bytes_processed = 0 + query_char_count = 0 + slot_millis = 0 + exec_seconds = 0 + + write_stats_to_disk( + query_char_count=query_char_count, + bytes_processed=bytes_processed, + slot_millis=slot_millis, + exec_seconds=exec_seconds, + ) def get_performance_stats( query_job: bigquery.QueryJob, -) -> Optional[Tuple[int, int, float, int]]: +) -> Optional[Tuple[int, int, int, float]]: """Parse the query job for performance stats. Return None if the stats do not reflect real work done in bigquery. @@ -63,39 +109,43 @@ def get_performance_stats( return None bytes_processed = query_job.total_bytes_processed - if not isinstance(bytes_processed, int): + if bytes_processed and not isinstance(bytes_processed, int): return None # filter out mocks slot_millis = query_job.slot_millis - if not isinstance(slot_millis, int): + if slot_millis and not isinstance(slot_millis, int): return None # filter out mocks execution_secs = (query_job.ended - query_job.created).total_seconds() query_char_count = len(query_job.query) - return bytes_processed, slot_millis, execution_secs, query_char_count + return ( + query_char_count, + # Not every job populates these. For example, slot_millis is missing + # from queries that came from cached results. + bytes_processed if bytes_processed else 0, + slot_millis if slot_millis else 0, + execution_secs, + ) def write_stats_to_disk( - bytes_processed: int, slot_millis: int, exec_seconds: float, query_char_count: int + *, + query_char_count: int, + bytes_processed: int, + slot_millis: int, + exec_seconds: float, ): """For pytest runs only, log information about the query job to a file in order to create a performance report. """ if LOGGING_NAME_ENV_VAR not in os.environ: - raise EnvironmentError( - "Environment variable {env_var} is not set".format( - env_var=LOGGING_NAME_ENV_VAR - ) - ) + return + + # when running notebooks via pytest nbmake and running benchmarks test_name = os.environ[LOGGING_NAME_ENV_VAR] current_directory = os.getcwd() - # store bytes processed - bytes_file = os.path.join(current_directory, test_name + ".bytesprocessed") - with open(bytes_file, "a") as f: - f.write(str(bytes_processed) + "\n") - # store slot milliseconds slot_file = os.path.join(current_directory, test_name + ".slotmillis") with open(slot_file, "a") as f: @@ -114,3 +164,8 @@ def write_stats_to_disk( ) with open(query_char_count_file, "a") as f: f.write(str(query_char_count) + "\n") + + # store bytes processed + bytes_file = os.path.join(current_directory, test_name + ".bytesprocessed") + with open(bytes_file, "a") as f: + f.write(str(bytes_processed) + "\n") diff --git a/bigframes/session/polars_executor.py b/bigframes/session/polars_executor.py new file mode 100644 index 0000000000..575beff8fc --- /dev/null +++ b/bigframes/session/polars_executor.py @@ -0,0 +1,162 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import itertools +from typing import Optional, TYPE_CHECKING + +from bigframes.core import ( + agg_expressions, + array_value, + bigframe_node, + expression, + nodes, +) +import bigframes.operations +from bigframes.operations import aggregations as agg_ops +from bigframes.operations import ( + bool_ops, + comparison_ops, + date_ops, + frequency_ops, + generic_ops, + numeric_ops, + string_ops, +) +from bigframes.session import executor, semi_executor + +if TYPE_CHECKING: + import polars as pl + +# Polars executor can execute more node types, but these are the validated ones +_COMPATIBLE_NODES = ( + nodes.ReadLocalNode, + nodes.OrderByNode, + nodes.ReversedNode, + nodes.SelectionNode, + nodes.ProjectionNode, + nodes.SliceNode, + nodes.AggregateNode, + nodes.FilterNode, + nodes.ConcatNode, + nodes.JoinNode, + nodes.InNode, + nodes.PromoteOffsetsNode, +) + +_COMPATIBLE_SCALAR_OPS = ( + bool_ops.AndOp, + bool_ops.OrOp, + bool_ops.XorOp, + comparison_ops.EqOp, + comparison_ops.EqNullsMatchOp, + comparison_ops.NeOp, + comparison_ops.LtOp, + comparison_ops.GtOp, + comparison_ops.LeOp, + comparison_ops.GeOp, + date_ops.YearOp, + date_ops.QuarterOp, + date_ops.MonthOp, + date_ops.DayOfWeekOp, + date_ops.DayOp, + date_ops.IsoYearOp, + date_ops.IsoWeekOp, + date_ops.IsoDayOp, + frequency_ops.FloorDtOp, + numeric_ops.AddOp, + numeric_ops.SubOp, + numeric_ops.MulOp, + numeric_ops.DivOp, + numeric_ops.FloorDivOp, + numeric_ops.ModOp, + generic_ops.AsTypeOp, + generic_ops.WhereOp, + generic_ops.CoalesceOp, + generic_ops.FillNaOp, + generic_ops.CaseWhenOp, + generic_ops.InvertOp, + generic_ops.IsInOp, + generic_ops.IsNullOp, + generic_ops.NotNullOp, + string_ops.StartsWithOp, + string_ops.EndsWithOp, + string_ops.StrContainsOp, + string_ops.StrContainsRegexOp, +) +_COMPATIBLE_AGG_OPS = ( + agg_ops.SizeOp, + agg_ops.SizeUnaryOp, + agg_ops.MinOp, + agg_ops.MaxOp, + agg_ops.SumOp, + agg_ops.MeanOp, + agg_ops.CountOp, + agg_ops.VarOp, + agg_ops.PopVarOp, + agg_ops.StdOp, +) + + +def _get_expr_ops(expr: expression.Expression) -> set[bigframes.operations.ScalarOp]: + if isinstance(expr, expression.OpExpression): + return set(itertools.chain.from_iterable(map(_get_expr_ops, expr.children))) + return set() + + +def _is_node_polars_executable(node: nodes.BigFrameNode): + if not isinstance(node, _COMPATIBLE_NODES): + return False + for expr in node._node_expressions: + if isinstance(expr, agg_expressions.Aggregation): + if not type(expr.op) in _COMPATIBLE_AGG_OPS: + return False + if isinstance(expr, expression.Expression): + if not set(map(type, _get_expr_ops(expr))).issubset(_COMPATIBLE_SCALAR_OPS): + return False + return True + + +class PolarsExecutor(semi_executor.SemiExecutor): + def __init__(self): + # This will error out if polars is not installed + from bigframes.core.compile.polars import PolarsCompiler + + self._compiler = PolarsCompiler() + + def execute( + self, + plan: bigframe_node.BigFrameNode, + ordered: bool, + peek: Optional[int] = None, + ) -> Optional[executor.ExecuteResult]: + if not self._can_execute(plan): + return None + # Note: Ignoring ordered flag, as just executing totally ordered is fine. + try: + lazy_frame: pl.LazyFrame = self._compiler.compile( + array_value.ArrayValue(plan).node + ) + except Exception: + return None + if peek is not None: + lazy_frame = lazy_frame.limit(peek) + pa_table = lazy_frame.collect().to_arrow() + return executor.LocalExecuteResult( + data=pa_table, + bf_schema=plan.schema, + ) + + def _can_execute(self, plan: bigframe_node.BigFrameNode): + return all(_is_node_polars_executable(node) for node in plan.unique_nodes()) diff --git a/bigframes/session/read_api_execution.py b/bigframes/session/read_api_execution.py new file mode 100644 index 0000000000..c7138f7b30 --- /dev/null +++ b/bigframes/session/read_api_execution.py @@ -0,0 +1,87 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from typing import Optional + +from google.cloud import bigquery_storage_v1 + +from bigframes.core import bigframe_node, nodes, rewrite +from bigframes.session import executor, semi_executor + + +class ReadApiSemiExecutor(semi_executor.SemiExecutor): + """ + Executes plans reducible to a bq table scan by directly reading the table with the read api. + """ + + def __init__( + self, + bqstoragereadclient: bigquery_storage_v1.BigQueryReadClient, + project: str, + ): + self.bqstoragereadclient = bqstoragereadclient + self.project = project + + def execute( + self, + plan: bigframe_node.BigFrameNode, + ordered: bool, + peek: Optional[int] = None, + ) -> Optional[executor.ExecuteResult]: + adapt_result = self._try_adapt_plan(plan, ordered) + if not adapt_result: + return None + node, limit = adapt_result + if node.explicitly_ordered and ordered: + return None + + if not node.source.table.is_physically_stored: + return None + + if limit is not None: + if peek is None or limit < peek: + peek = limit + + return executor.BQTableExecuteResult( + data=node.source, + project_id=self.project, + storage_client=self.bqstoragereadclient, + limit=peek, + selected_fields=[ + (item.source_id, item.id.sql) for item in node.scan_list.items + ], + ) + + def _try_adapt_plan( + self, + plan: bigframe_node.BigFrameNode, + ordered: bool, + ) -> Optional[tuple[nodes.ReadTableNode, Optional[int]]]: + """ + Tries to simplify the plan to an equivalent single ReadTableNode and a limit. Otherwise, returns None. + """ + plan, limit = rewrite.pull_out_limit(plan) + # bake_order does not allow slice ops + plan = plan.bottom_up(rewrite.rewrite_slice) + if not ordered: + # gets rid of order_by ops + plan = rewrite.bake_order(plan) + read_table_node = rewrite.try_reduce_to_table_scan(plan) + if read_table_node is None: + return None + if (limit is not None) and (read_table_node.source.ordering is not None): + # read api can only use physical ordering to limit, not a logical ordering + return None + return (read_table_node, limit) diff --git a/bigframes/session/semi_executor.py b/bigframes/session/semi_executor.py new file mode 100644 index 0000000000..c41d7c96d3 --- /dev/null +++ b/bigframes/session/semi_executor.py @@ -0,0 +1,33 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import abc +from typing import Optional + +from bigframes.core import bigframe_node +from bigframes.session import executor + + +# Unstable interface, in development +class SemiExecutor(abc.ABC): + """ + A semi executor executes a subset of possible plans, returns None for unsupported plans. + """ + + def execute( + self, + plan: bigframe_node.BigFrameNode, + ordered: bool, + peek: Optional[int] = None, + ) -> Optional[executor.ExecuteResult]: + raise NotImplementedError("execute not implemented for this executor") diff --git a/bigframes/session/temp_storage.py b/bigframes/session/temp_storage.py deleted file mode 100644 index de764e4535..0000000000 --- a/bigframes/session/temp_storage.py +++ /dev/null @@ -1,102 +0,0 @@ -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import datetime -from typing import List, Optional, Sequence -import uuid - -import google.cloud.bigquery as bigquery - -import bigframes.constants as constants -import bigframes.session._io.bigquery as bf_io_bigquery - -_TEMP_TABLE_ID_FORMAT = "bqdf{date}_{session_id}_{random_id}" - - -class TemporaryGbqStorageManager: - """ - Responsible for allocating and cleaning up temporary gbq tables used by a BigFrames session. - """ - - def __init__( - self, - bqclient: bigquery.Client, - location: str, - session_id: str, - *, - kms_key: Optional[str] = None - ): - self.bqclient = bqclient - self.location = location - self.dataset = bf_io_bigquery.create_bq_dataset_reference( - self.bqclient, - location=self.location, - api_name="session-__init__", - ) - - self.session_id = session_id - self._table_ids: List[str] = [] - self._kms_key = kms_key - - def create_temp_table( - self, schema: Sequence[bigquery.SchemaField], cluster_cols: Sequence[str] - ) -> bigquery.TableReference: - # Can't set a table in _SESSION as destination via query job API, so we - # run DDL, instead. - expiration = ( - datetime.datetime.now(datetime.timezone.utc) + constants.DEFAULT_EXPIRATION - ) - table = bf_io_bigquery.create_temp_table( - self.bqclient, - self._random_table(), - expiration, - schema=schema, - cluster_columns=list(cluster_cols), - kms_key=self._kms_key, - ) - return bigquery.TableReference.from_string(table) - - def _random_table(self, skip_cleanup: bool = False) -> bigquery.TableReference: - """Generate a random table ID with BigQuery DataFrames prefix. - - The generated ID will be stored and checked for deletion when the - session is closed, unless skip_cleanup is True. - - Args: - skip_cleanup (bool, default False): - If True, do not add the generated ID to the list of tables - to clean up when the session is closed. - - Returns: - google.cloud.bigquery.TableReference: - Fully qualified table ID of a table that doesn't exist. - """ - now = datetime.datetime.now(datetime.timezone.utc) - random_id = uuid.uuid4().hex - table_id = _TEMP_TABLE_ID_FORMAT.format( - date=now.strftime("%Y%m%d"), session_id=self.session_id, random_id=random_id - ) - if not skip_cleanup: - self._table_ids.append(table_id) - return self.dataset.table(table_id) - - def clean_up_tables(self): - """Delete tables that were created with this session's session_id.""" - client = self.bqclient - project_id = self.dataset.project - dataset_id = self.dataset.dataset_id - - for table_id in self._table_ids: - full_id = ".".join([project_id, dataset_id, table_id]) - client.delete_table(full_id, not_found_ok=True) diff --git a/bigframes/session/temporary_storage.py b/bigframes/session/temporary_storage.py new file mode 100644 index 0000000000..0c2a36f3fe --- /dev/null +++ b/bigframes/session/temporary_storage.py @@ -0,0 +1,32 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Protocol, Sequence + +from google.cloud import bigquery + + +class TemporaryStorageManager(Protocol): + @property + def location(self) -> str: + ... + + def create_temp_table( + self, schema: Sequence[bigquery.SchemaField], cluster_cols: Sequence[str] = [] + ) -> bigquery.TableReference: + ... + + # implementations should be robust to repeatedly closing + def close(self) -> None: + ... diff --git a/bigframes/streaming/__init__.py b/bigframes/streaming/__init__.py index d439d622a2..477c7a99e0 100644 --- a/bigframes/streaming/__init__.py +++ b/bigframes/streaming/__init__.py @@ -12,8 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import inspect +import sys +from bigframes.core import log_adapter import bigframes.core.global_session as global_session from bigframes.pandas.io.api import _set_default_session_location_if_possible import bigframes.session @@ -32,3 +36,12 @@ def read_gbq_table(table: str) -> streaming_dataframe.StreamingDataFrame: ) StreamingDataFrame = streaming_dataframe.StreamingDataFrame + +_module = sys.modules[__name__] +_functions = [read_gbq_table] + +for _function in _functions: + _decorated_object = log_adapter.method_logger(_function, custom_base_name="pandas") + setattr(_module, _function.__name__, _decorated_object) + +__all__ = ["read_gbq_table", "StreamingDataFrame"] diff --git a/bigframes/streaming/dataframe.py b/bigframes/streaming/dataframe.py index 2180a66207..3e030a4aa2 100644 --- a/bigframes/streaming/dataframe.py +++ b/bigframes/streaming/dataframe.py @@ -15,13 +15,16 @@ """Module for bigquery continuous queries""" from __future__ import annotations +from abc import abstractmethod +from datetime import date, datetime import functools import inspect import json -from typing import Optional +from typing import Optional, Union import warnings from google.cloud import bigquery +import pandas as pd from bigframes import dataframe from bigframes.core import log_adapter, nodes @@ -54,9 +57,14 @@ def _curate_df_doc(doc: Optional[str]): class StreamingBase: - _appends_sql: str _session: bigframes.session.Session + @abstractmethod + def _appends_sql( + self, start_timestamp: Optional[Union[int, float, str, datetime, date]] + ) -> str: + pass + def to_bigtable( self, *, @@ -70,6 +78,8 @@ def to_bigtable( bigtable_options: Optional[dict] = None, job_id: Optional[str] = None, job_id_prefix: Optional[str] = None, + start_timestamp: Optional[Union[int, float, str, datetime, date]] = None, + end_timestamp: Optional[Union[int, float, str, datetime, date]] = None, ) -> bigquery.QueryJob: """ Export the StreamingDataFrame as a continue job and returns a @@ -115,7 +125,8 @@ def to_bigtable( If specified, a job id prefix for the query, see job_id_prefix parameter of https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.client.Client#google_cloud_bigquery_client_Client_query - + start_timestamp (int, float, str, datetime, date, default None): + The starting timestamp for the query. Possible values are to 7 days in the past. If don't specify a timestamp (None), the query will default to the earliest possible time, 7 days ago. If provide a time-zone-naive timestamp, it will be treated as UTC. Returns: google.cloud.bigquery.QueryJob: See https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJob @@ -123,8 +134,15 @@ def to_bigtable( For example, the job can be cancelled or its error status can be examined. """ + if not isinstance( + start_timestamp, (int, float, str, datetime, date, type(None)) + ): + raise ValueError( + f"Unsupported start_timestamp type {type(start_timestamp)}" + ) + return _to_bigtable( - self._appends_sql, + self._appends_sql(start_timestamp), instance=instance, table=table, service_account_email=service_account_email, @@ -145,6 +163,7 @@ def to_pubsub( service_account_email: str, job_id: Optional[str] = None, job_id_prefix: Optional[str] = None, + start_timestamp: Optional[Union[int, float, str, datetime, date]] = None, ) -> bigquery.QueryJob: """ Export the StreamingDataFrame as a continue job and returns a @@ -172,6 +191,8 @@ def to_pubsub( If specified, a job id prefix for the query, see job_id_prefix parameter of https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.client.Client#google_cloud_bigquery_client_Client_query + start_timestamp (int, float, str, datetime, date, default None): + The starting timestamp for the query. Possible values are to 7 days in the past. If don't specify a timestamp (None), the query will default to the earliest possible time, 7 days ago. If provide a time-zone-naive timestamp, it will be treated as UTC. Returns: google.cloud.bigquery.QueryJob: @@ -180,8 +201,15 @@ def to_pubsub( For example, the job can be cancelled or its error status can be examined. """ + if not isinstance( + start_timestamp, (int, float, str, datetime, date, type(None)) + ): + raise ValueError( + f"Unsupported start_timestamp type {type(start_timestamp)}" + ) + return _to_pubsub( - self._appends_sql, + self._appends_sql(start_timestamp), topic=topic, service_account_email=service_account_email, session=self._session, @@ -263,13 +291,13 @@ def __repr__(self, *args, **kwargs): __repr__.__doc__ = _curate_df_doc(inspect.getdoc(dataframe.DataFrame.__repr__)) - def _repr_html_(self, *args, **kwargs): - return _return_type_wrapper(self._df._repr_html_, StreamingDataFrame)( + def _repr_mimebundle_(self, *args, **kwargs): + return _return_type_wrapper(self._df._repr_mimebundle_, StreamingDataFrame)( *args, **kwargs ) - _repr_html_.__doc__ = _curate_df_doc( - inspect.getdoc(dataframe.DataFrame._repr_html_) + _repr_mimebundle_.__doc__ = _curate_df_doc( + inspect.getdoc(dataframe.DataFrame._repr_mimebundle_) ) @property @@ -280,13 +308,21 @@ def sql(self): sql.__doc__ = _curate_df_doc(inspect.getdoc(dataframe.DataFrame.sql)) # Patch for the required APPENDS clause - @property - def _appends_sql(self): + def _appends_sql( + self, start_timestamp: Optional[Union[int, float, str, datetime, date]] + ) -> str: sql_str = self.sql original_table = self._original_table assert original_table is not None - appends_clause = f"APPENDS(TABLE `{original_table}`, NULL, NULL)" + # TODO(b/405691193): set start time back to NULL. Now set it slightly after 7 days max interval to avoid the bug. + start_ts_str = ( + str(f"TIMESTAMP('{pd.to_datetime(start_timestamp)}')") + if start_timestamp + else "CURRENT_TIMESTAMP() - (INTERVAL 7 DAY - INTERVAL 5 MINUTE)" + ) + + appends_clause = f"APPENDS(TABLE `{original_table}`, {start_ts_str})" sql_str = sql_str.replace(f"`{original_table}`", appends_clause) return sql_str @@ -372,7 +408,9 @@ def _to_bigtable( For example, the job can be cancelled or its error status can be examined. """ - msg = "The bigframes.streaming module is a preview feature, and subject to change." + msg = bfe.format_message( + "The bigframes.streaming module is a preview feature, and subject to change." + ) warnings.warn(msg, stacklevel=1, category=bfe.PreviewWarning) # get default client if not passed @@ -484,7 +522,9 @@ def _to_pubsub( For example, the job can be cancelled or its error status can be examined. """ - msg = "The bigframes.streaming module is a preview feature, and subject to change." + msg = bfe.format_message( + "The bigframes.streaming module is a preview feature, and subject to change." + ) warnings.warn(msg, stacklevel=1, category=bfe.PreviewWarning) # get default client if not passed diff --git a/bigframes/testing/__init__.py b/bigframes/testing/__init__.py new file mode 100644 index 0000000000..529c08241d --- /dev/null +++ b/bigframes/testing/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""[Experimental] Utilities for testing BigQuery DataFrames. + +These modules are provided for testing the BigQuery DataFrames package. The +interface is not considered stable. +""" diff --git a/bigframes/testing/compiler_session.py b/bigframes/testing/compiler_session.py new file mode 100644 index 0000000000..b248f37cfc --- /dev/null +++ b/bigframes/testing/compiler_session.py @@ -0,0 +1,50 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import dataclasses +import typing + +import bigframes.core +import bigframes.core.compile as compile +import bigframes.session.executor + + +@dataclasses.dataclass +class SQLCompilerExecutor(bigframes.session.executor.Executor): + """Executor for SQL compilation using sqlglot.""" + + compiler = compile.sqlglot + + def to_sql( + self, + array_value: bigframes.core.ArrayValue, + offset_column: typing.Optional[str] = None, + ordered: bool = True, + enable_cache: bool = False, + ) -> str: + if offset_column: + array_value, _ = array_value.promote_offsets() + + # Compared with BigQueryCachingExecutor, SQLCompilerExecutor skips + # caching the subtree. + return self.compiler.compile_sql( + compile.CompileRequest(array_value.node, sort_rows=ordered) + ).sql + + def execute( + self, + array_value, + execution_spec, + ): + raise NotImplementedError("SQLCompilerExecutor.execute not implemented") diff --git a/bigframes/testing/engine_utils.py b/bigframes/testing/engine_utils.py new file mode 100644 index 0000000000..edb68c3a9b --- /dev/null +++ b/bigframes/testing/engine_utils.py @@ -0,0 +1,34 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas.testing + +from bigframes.core import nodes +from bigframes.session import semi_executor + + +def assert_equivalence_execution( + node: nodes.BigFrameNode, + engine1: semi_executor.SemiExecutor, + engine2: semi_executor.SemiExecutor, +): + e1_result = engine1.execute(node, ordered=True) + e2_result = engine2.execute(node, ordered=True) + assert e1_result is not None + assert e2_result is not None + # Convert to pandas, as pandas has better comparison utils than arrow + assert e1_result.schema == e2_result.schema + e1_table = e1_result.batches().to_pandas() + e2_table = e2_result.batches().to_pandas() + pandas.testing.assert_frame_equal(e1_table, e2_table, rtol=1e-5) diff --git a/bigframes/testing/mocks.py b/bigframes/testing/mocks.py new file mode 100644 index 0000000000..ff210419fd --- /dev/null +++ b/bigframes/testing/mocks.py @@ -0,0 +1,186 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import datetime +from typing import Any, Dict, Literal, Optional, Sequence +import unittest.mock as mock + +from bigframes_vendored.google_cloud_bigquery import _pandas_helpers +import google.auth.credentials +import google.cloud.bigquery +import google.cloud.bigquery.table +import pyarrow +import pytest + +import bigframes +import bigframes.clients +import bigframes.core.global_session +import bigframes.dataframe +import bigframes.session.clients + +"""Utilities for creating test resources.""" + + +TEST_SCHEMA = (google.cloud.bigquery.SchemaField("col", "INTEGER"),) + + +def create_bigquery_session( + *, + bqclient: Optional[mock.Mock] = None, + session_id: str = "abcxyz", + table_schema: Sequence[google.cloud.bigquery.SchemaField] = TEST_SCHEMA, + table_name: str = "test_table", + anonymous_dataset: Optional[google.cloud.bigquery.DatasetReference] = None, + location: str = "test-region", + ordering_mode: Literal["strict", "partial"] = "partial", +) -> bigframes.Session: + """[Experimental] Create a mock BigQuery DataFrames session that avoids making Google Cloud API calls. + + Intended for unit test environments that don't have access to the network. + """ + credentials = mock.create_autospec( + google.auth.credentials.Credentials, instance=True + ) + + bq_time = datetime.datetime.now() + table_time = bq_time + datetime.timedelta(minutes=1) + + if anonymous_dataset is None: + anonymous_dataset = google.cloud.bigquery.DatasetReference( + "test-project", + "test_dataset", + ) + + if bqclient is None: + bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) + bqclient.project = anonymous_dataset.project + bqclient.location = location + + # Mock the location. + table = mock.create_autospec(google.cloud.bigquery.Table, instance=True) + table._properties = {} + # TODO(tswast): support tables created before and after the session started. + type(table).created = mock.PropertyMock(return_value=table_time) + type(table).location = mock.PropertyMock(return_value=location) + type(table).schema = mock.PropertyMock(return_value=table_schema) + type(table).project = anonymous_dataset.project + type(table).dataset_id = anonymous_dataset.dataset_id + type(table).table_id = table_name + type(table).num_rows = mock.PropertyMock(return_value=1000000000) + bqclient.get_table.return_value = table + + queries = [] + job_configs = [] + + def query_mock( + query, + *args, + job_config: Optional[google.cloud.bigquery.QueryJobConfig] = None, + **kwargs, + ): + queries.append(query) + job_configs.append(copy.deepcopy(job_config)) + query_job = mock.create_autospec(google.cloud.bigquery.QueryJob, instance=True) + query_job._properties = {} + type(query_job).destination = mock.PropertyMock( + return_value=anonymous_dataset.table(table_name), + ) + type(query_job).statement_type = mock.PropertyMock(return_value="SELECT") + + if job_config is not None and job_config.create_session: + type(query_job).session_info = google.cloud.bigquery.SessionInfo( + {"sessionId": session_id}, + ) + + if query.startswith("SELECT CURRENT_TIMESTAMP()"): + query_job.result = mock.MagicMock(return_value=[[bq_time]]) + elif "CREATE TEMP TABLE".casefold() in query.casefold(): + type(query_job).destination = mock.PropertyMock( + return_value=anonymous_dataset.table("temp_table_from_session"), + ) + else: + type(query_job).schema = mock.PropertyMock(return_value=table_schema) + + return query_job + + def query_and_wait_mock(query, *args, job_config=None, **kwargs): + queries.append(query) + job_configs.append(copy.deepcopy(job_config)) + + if query.startswith("SELECT CURRENT_TIMESTAMP()"): + return iter([[datetime.datetime.now()]]) + + rows = mock.create_autospec( + google.cloud.bigquery.table.RowIterator, instance=True + ) + row = mock.create_autospec(google.cloud.bigquery.table.Row, instance=True) + rows.__iter__.return_value = [row] + type(rows).schema = mock.PropertyMock(return_value=table_schema) + rows.to_arrow.return_value = pyarrow.Table.from_pydict( + {field.name: [None] for field in table_schema}, + schema=pyarrow.schema( + _pandas_helpers.bq_to_arrow_field(field) for field in table_schema + ), + ) + + if job_config is not None and job_config.destination is None: + # Assume that the query finishes fast enough for jobless mode. + type(rows).job_id = mock.PropertyMock(return_value=None) + + return rows + + bqclient.query.side_effect = query_mock + bqclient.query_and_wait.side_effect = query_and_wait_mock + bqclient._query_and_wait_bigframes.side_effect = query_and_wait_mock + + clients_provider = mock.create_autospec(bigframes.session.clients.ClientsProvider) + type(clients_provider).bqclient = mock.PropertyMock(return_value=bqclient) + clients_provider._credentials = credentials + + bqoptions = bigframes.BigQueryOptions( + credentials=credentials, + location=location, + ordering_mode=ordering_mode, + ) + session = bigframes.Session(context=bqoptions, clients_provider=clients_provider) + session._bq_connection_manager = mock.create_autospec( + bigframes.clients.BqConnectionManager, instance=True + ) + session._queries = queries # type: ignore + session._job_configs = job_configs # type: ignore + return session + + +def create_dataframe( + monkeypatch: pytest.MonkeyPatch, + *, + session: Optional[bigframes.Session] = None, + data: Optional[Dict[str, Sequence[Any]]] = None, +) -> bigframes.dataframe.DataFrame: + """[Experimental] Create a mock DataFrame that avoids making Google Cloud API calls. + + Intended for unit test environments that don't have access to the network. + """ + if session is None: + session = create_bigquery_session() + + if data is None: + data = {"col": []} + + # Since this may create a ReadLocalNode, the session we explicitly pass in + # might not actually be used. Mock out the global session, too. + monkeypatch.setattr(bigframes.core.global_session, "_global_session", session) + bigframes.options.bigquery._session_started = True + return bigframes.dataframe.DataFrame(data, session=session) diff --git a/tests/unit/polars_session.py b/bigframes/testing/polars_session.py similarity index 66% rename from tests/unit/polars_session.py rename to bigframes/testing/polars_session.py index cffd8ff7ca..ca1fa329a2 100644 --- a/tests/unit/polars_session.py +++ b/bigframes/testing/polars_session.py @@ -13,23 +13,22 @@ # limitations under the License. import dataclasses -from typing import Mapping, Optional, Union +from typing import Union import weakref +import pandas import polars import bigframes -import bigframes.clients import bigframes.core.blocks import bigframes.core.compile.polars -import bigframes.core.ordering import bigframes.dataframe -import bigframes.session.clients +import bigframes.session.execution_spec import bigframes.session.executor import bigframes.session.metrics -# Does not support to_sql, export_gbq, export_gcs, dry_run, peek, head, get_row_count, cached +# Does not support to_sql, dry_run, peek, cached @dataclasses.dataclass class TestExecutor(bigframes.session.executor.Executor): compiler = bigframes.core.compile.polars.PolarsCompiler() @@ -37,35 +36,40 @@ class TestExecutor(bigframes.session.executor.Executor): def execute( self, array_value: bigframes.core.ArrayValue, - *, - ordered: bool = True, - col_id_overrides: Mapping[str, str] = {}, - use_explicit_destination: bool = False, - get_size_bytes: bool = False, - page_size: Optional[int] = None, - max_results: Optional[int] = None, + execution_spec: bigframes.session.execution_spec.ExecutionSpec, ): """ Execute the ArrayValue, storing the result to a temporary session-owned table. """ - lazy_frame: polars.LazyFrame = self.compiler.compile(array_value) + if execution_spec.destination_spec is not None: + raise ValueError( + f"TestExecutor does not support destination spec: {execution_spec.destination_spec}" + ) + lazy_frame: polars.LazyFrame = self.compiler.compile(array_value.node) + if execution_spec.peek is not None: + lazy_frame = lazy_frame.limit(execution_spec.peek) pa_table = lazy_frame.collect().to_arrow() # Currently, pyarrow types might not quite be exactly the ones in the bigframes schema. # Nullability may be different, and might use large versions of list, string datatypes. - return bigframes.session.executor.ExecuteResult( - arrow_batches=lambda: pa_table.to_batches(), - schema=array_value.schema, - total_bytes=pa_table.nbytes, - total_rows=pa_table.num_rows, + return bigframes.session.executor.LocalExecuteResult( + data=pa_table, + bf_schema=array_value.schema, ) + def cached( + self, + array_value: bigframes.core.ArrayValue, + *, + config, + ) -> None: + return + class TestSession(bigframes.session.Session): def __init__(self): self._location = None # type: ignore self._bq_kms_key_name = None # type: ignore self._clients_provider = None # type: ignore - self.ibis_client = None # type: ignore self._bq_connection = None # type: ignore self._skip_bq_connection_check = True self._session_id: str = "test_session" @@ -88,6 +92,26 @@ def __init__(self): self._loader = None # type: ignore def read_pandas(self, pandas_dataframe, write_engine="default"): + original_input = pandas_dataframe + # override read_pandas to always keep data local-only + if isinstance(pandas_dataframe, (pandas.Series, pandas.Index)): + pandas_dataframe = pandas_dataframe.to_frame() + local_block = bigframes.core.blocks.Block.from_local(pandas_dataframe, self) - return bigframes.dataframe.DataFrame(local_block) + bf_df = bigframes.dataframe.DataFrame(local_block) + + if isinstance(original_input, pandas.Series): + series = bf_df[bf_df.columns[0]] + series.name = original_input.name + return series + + if isinstance(original_input, pandas.Index): + return bf_df.index + + return bf_df + + @property + def bqclient(self): + # prevents logger from trying to call bq upon any errors + return None diff --git a/tests/system/utils.py b/bigframes/testing/utils.py similarity index 67% rename from tests/system/utils.py rename to bigframes/testing/utils.py index 0772468085..6679f53b2c 100644 --- a/tests/system/utils.py +++ b/bigframes/testing/utils.py @@ -14,8 +14,8 @@ import base64 import decimal -import functools -from typing import Iterable, Optional, Set, Union +import re +from typing import Iterable, Optional, Sequence, Set, Union import geopandas as gpd # type: ignore import google.api_core.operation @@ -23,11 +23,15 @@ from google.cloud.functions_v2.types import functions import numpy as np import pandas as pd +import pandas.api.types as pd_types import pyarrow as pa # type: ignore import pytest +from bigframes import operations as ops +from bigframes.core import expression as ex +import bigframes.dtypes import bigframes.functions._utils as bff_utils -import bigframes.pandas +import bigframes.pandas as bpd ML_REGRESSION_METRICS = [ "mean_absolute_error", @@ -56,54 +60,78 @@ "ml_generate_embedding_status", "content", ] +ML_MULTIMODAL_GENERATE_EMBEDDING_OUTPUT = [ + "ml_generate_embedding_result", + "ml_generate_embedding_status", + # start and end sec depend on input format. Images and videos input will contain these 2. + "ml_generate_embedding_start_sec", + "ml_generate_embedding_end_sec", + "content", +] -def skip_legacy_pandas(test): - @functools.wraps(test) - def wrapper(*args, **kwds): - if pd.__version__.startswith("1."): - pytest.skip("Skips pandas 1.x as not compatible with 2.x behavior.") - return test(*args, **kwds) - - return wrapper +def pandas_major_version() -> int: + match = re.search(r"^v?(\d+)", pd.__version__.strip()) + assert match is not None + return int(match.group(1)) # Prefer this function for tests that run in both ordered and unordered mode -def assert_dfs_equivalent( - pd_df: pd.DataFrame, bf_df: bigframes.pandas.DataFrame, **kwargs -): +def assert_dfs_equivalent(pd_df: pd.DataFrame, bf_df: bpd.DataFrame, **kwargs): bf_df_local = bf_df.to_pandas() ignore_order = not bf_df._session._strictly_ordered - assert_pandas_df_equal(bf_df_local, pd_df, ignore_order=ignore_order, **kwargs) + assert_frame_equal(bf_df_local, pd_df, ignore_order=ignore_order, **kwargs) -def assert_series_equivalent( - pd_series: pd.Series, bf_series: bigframes.pandas.Series, **kwargs -): +def assert_series_equivalent(pd_series: pd.Series, bf_series: bpd.Series, **kwargs): bf_df_local = bf_series.to_pandas() ignore_order = not bf_series._session._strictly_ordered assert_series_equal(bf_df_local, pd_series, ignore_order=ignore_order, **kwargs) -def assert_pandas_df_equal(df0, df1, ignore_order: bool = False, **kwargs): +def _normalize_all_nulls(col: pd.Series) -> pd.Series: + if col.dtype in (bigframes.dtypes.FLOAT_DTYPE, bigframes.dtypes.INT_DTYPE): + col = col.astype("float64") + if pd_types.is_object_dtype(col): + col = col.fillna(float("nan")) + return col + + +def assert_frame_equal( + left: pd.DataFrame, + right: pd.DataFrame, + *, + ignore_order: bool = False, + nulls_are_nan: bool = True, + **kwargs, +): if ignore_order: # Sort by a column to get consistent results. - if df0.index.name != "rowindex": - df0 = df0.sort_values( - list(df0.columns.drop("geography_col", errors="ignore")) + if left.index.name != "rowindex": + left = left.sort_values( + list(left.columns.drop("geography_col", errors="ignore")) ).reset_index(drop=True) - df1 = df1.sort_values( - list(df1.columns.drop("geography_col", errors="ignore")) + right = right.sort_values( + list(right.columns.drop("geography_col", errors="ignore")) ).reset_index(drop=True) else: - df0 = df0.sort_index() - df1 = df1.sort_index() + left = left.sort_index() + right = right.sort_index() - pd.testing.assert_frame_equal(df0, df1, **kwargs) + if nulls_are_nan: + left = left.apply(_normalize_all_nulls) + right = right.apply(_normalize_all_nulls) + + pd.testing.assert_frame_equal(left, right, **kwargs) def assert_series_equal( - left: pd.Series, right: pd.Series, ignore_order: bool = False, **kwargs + left: pd.Series, + right: pd.Series, + *, + ignore_order: bool = False, + nulls_are_nan: bool = True, + **kwargs, ): if ignore_order: if left.index.name is None: @@ -113,6 +141,19 @@ def assert_series_equal( left = left.sort_index() right = right.sort_index() + if isinstance(left.index, pd.RangeIndex) or pd_types.is_integer_dtype( + left.index.dtype, + ): + left.index = left.index.astype("Int64") + if isinstance(right.index, pd.RangeIndex) or pd_types.is_integer_dtype( + right.index.dtype, + ): + right.index = right.index.astype("Int64") + + if nulls_are_nan: + left = _normalize_all_nulls(left) + right = _normalize_all_nulls(right) + pd.testing.assert_series_equal(left, right, **kwargs) @@ -188,6 +229,16 @@ def convert_pandas_dtypes(df: pd.DataFrame, bytes_col: bool): "timestamp_col" ] + if not isinstance(df["duration_col"].dtype, pd.ArrowDtype): + df["duration_col"] = df["duration_col"].astype(pd.Int64Dtype()) + arrow_table = pa.Table.from_pandas( + pd.DataFrame(df, columns=["duration_col"]), + schema=pa.schema([("duration_col", pa.duration("us"))]), + ) + df["duration_col"] = arrow_table.to_pandas(types_mapper=pd.ArrowDtype)[ + "duration_col" + ] + # Convert geography types columns. if "geography_col" in df.columns: df["geography_col"] = df["geography_col"].astype( @@ -383,3 +434,103 @@ def delete_cloud_function( def get_first_file_from_wildcard(path): return path.replace("*", "000000000000") + + +def cleanup_function_assets( + bigframes_func, + bigquery_client, + cloudfunctions_client=None, + ignore_failures=True, +) -> None: + """Clean up the GCP assets behind a bigframess function.""" + + # Clean up bigframes bigquery function. + try: + bigquery_client.delete_routine(bigframes_func.bigframes_bigquery_function) + except Exception: + # By default don't raise exception in cleanup. + if not ignore_failures: + raise + + if not ignore_failures: + # Make sure that the BQ routins is actually deleted + with pytest.raises(google.api_core.exceptions.NotFound): + bigquery_client.get_routine(bigframes_func.bigframes_bigquery_function) + + # Clean up bigframes cloud run function + if cloudfunctions_client: + # Clean up cloud function + try: + delete_cloud_function( + cloudfunctions_client, bigframes_func.bigframes_cloud_function + ) + except Exception: + # By default don't raise exception in cleanup. + if not ignore_failures: + raise + + if not ignore_failures: + # Make sure the cloud run function is actually deleted + try: + gcf = cloudfunctions_client.get_function( + name=bigframes_func.bigframes_cloud_function + ) + assert gcf.state is functions_v2.Function.State.DELETING + except google.cloud.exceptions.NotFound: + pass + + +def get_function_name(func, package_requirements=None, is_row_processor=False): + """Get a bigframes function name for testing given a udf.""" + # Augment user package requirements with any internal package + # requirements. + package_requirements = bff_utils.get_updated_package_requirements( + package_requirements, is_row_processor + ) + + # Compute a unique hash representing the user code. + function_hash = bff_utils.get_hash(func, package_requirements) + + return f"bigframes_{function_hash}" + + +def _apply_ops_to_sql( + obj: bpd.DataFrame, + ops_list: Sequence[ex.Expression], + new_names: Sequence[str], +) -> str: + """Applies a list of ops to the given DataFrame and returns the SQL + representing the resulting DataFrame.""" + array_value = obj._block.expr + result, old_names = array_value.compute_values(ops_list) + + # Rename columns for deterministic golden SQL results. + assert len(old_names) == len(new_names) + col_ids = {old_name: new_name for old_name, new_name in zip(old_names, new_names)} + result = result.rename_columns(col_ids).select_columns(new_names) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def _apply_binary_op( + obj: bpd.DataFrame, + op: ops.BinaryOp, + l_arg: str, + r_arg: Union[str, ex.Expression], +) -> str: + """Applies a binary op to the given DataFrame and return the SQL representing + the resulting DataFrame.""" + return _apply_nary_op(obj, op, l_arg, r_arg) + + +def _apply_nary_op( + obj: bpd.DataFrame, + op: Union[ops.BinaryOp, ops.NaryOp], + *args: Union[str, ex.Expression], +) -> str: + """Applies a nary op to the given DataFrame and return the SQL representing + the resulting DataFrame.""" + op_expr = op.as_expr(*args) + sql = _apply_ops_to_sql(obj, [op_expr], [args[0]]) # type: ignore + return sql diff --git a/bigframes/version.py b/bigframes/version.py index 27dfb23603..230dc343ac 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,4 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "1.37.0" +__version__ = "2.31.0" + +# {x-release-please-start-date} +__release_date__ = "2025-12-10" +# {x-release-please-end} diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000000..e0f059fa43 --- /dev/null +++ b/conftest.py @@ -0,0 +1,62 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import warnings + +import numpy as np +import pandas as pd +import pyarrow as pa +import pytest + +import bigframes._config + +# Make sure SettingWithCopyWarning is ignored if it exists. +# It was removed in pandas 3.0. +if hasattr(pd.errors, "SettingWithCopyWarning"): + warnings.simplefilter("ignore", pd.errors.SettingWithCopyWarning) + + +@pytest.fixture(scope="session") +def polars_session_or_bpd(): + # Since the doctest imports fixture is autouse=True, don't skip if polars + # isn't available. + try: + from bigframes.testing import polars_session + + return polars_session.TestSession() + except ImportError: + import bigframes.pandas as bpd + + return bpd + + +@pytest.fixture(autouse=True) +def default_doctest_imports(doctest_namespace, polars_session_or_bpd): + """ + Avoid some boilerplate in pandas-inspired tests. + + See: https://docs.pytest.org/en/stable/how-to/doctest.html#doctest-namespace-fixture + """ + doctest_namespace["np"] = np + doctest_namespace["pd"] = pd + doctest_namespace["pa"] = pa + doctest_namespace["bpd"] = polars_session_or_bpd + bigframes._config.options.display.progress_bar = None + + # TODO(tswast): Consider setting the numpy printoptions here for better + # compatibility across numpy versions. + # https://numpy.org/doc/stable/release/2.0.0-notes.html#representation-of-numpy-scalars-changed + # https://numpy.org/doc/stable/reference/generated/numpy.set_printoptions.html#numpy-set-printoptions diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst new file mode 120000 index 0000000000..bd84850996 --- /dev/null +++ b/docs/_templates/autosummary/class.rst @@ -0,0 +1 @@ +../../../third_party/sphinx/ext/autosummary/templates/autosummary/class.rst \ No newline at end of file diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst new file mode 120000 index 0000000000..f330261ac5 --- /dev/null +++ b/docs/_templates/autosummary/module.rst @@ -0,0 +1 @@ +../../../third_party/sphinx/ext/autosummary/templates/autosummary/module.rst \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 23ec7a6b36..22868aab67 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,9 +24,11 @@ # All configuration values have a default; values that are commented out # serve to show the default. +from __future__ import annotations + import os -import shlex import sys +from typing import Any # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -56,14 +58,16 @@ "sphinx.ext.napoleon", "sphinx.ext.todo", "sphinx.ext.viewcode", - "recommonmark", + "sphinx_sitemap", + "myst_parser", ] # autodoc/autosummary flags autoclass_content = "both" autodoc_default_options = {"members": True} autosummary_generate = True - +autosummary_imported_members = True +autosummary_ignore_module_all = True # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -98,7 +102,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en-US" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -148,19 +152,30 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "alabaster" +html_theme = "pydata_sphinx_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. +# https://pydata-sphinx-theme.readthedocs.io/en/stable/user_guide/layout.html#references html_theme_options = { - "description": "BigQuery DataFrames provides DataFrame APIs on the BigQuery engine", - "github_user": "googleapis", - "github_repo": "python-bigquery-dataframes", - "github_banner": True, - "font_family": "'Roboto', Georgia, sans", - "head_font_family": "'Roboto', Georgia, serif", - "code_font_family": "'Roboto Mono', 'Consolas', monospace", + "github_url": "https://github.com/googleapis/python-bigquery-dataframes", + "logo": { + "text": "BigQuery DataFrames (BigFrames)", + }, + "external_links": [ + { + "name": "Getting started", + "url": "https://docs.cloud.google.com/bigquery/docs/dataframes-quickstart", + }, + { + "name": "User guide", + "url": "https://docs.cloud.google.com/bigquery/docs/bigquery-dataframes-introduction", + }, + ], + "analytics": { + "google_analytics_id": "G-XVSRMCJ37X", + }, } # Add any paths that contain custom themes here, relative to this directory. @@ -250,6 +265,9 @@ # Output file base name for HTML help builder. htmlhelp_basename = "bigframes-doc" +# https://sphinx-sitemap.readthedocs.io/en/latest/getting-started.html#usage +html_baseurl = "https://dataframes.bigquery.dev/" + # -- Options for warnings ------------------------------------------------------ @@ -264,7 +282,7 @@ # -- Options for LaTeX output --------------------------------------------- -latex_elements = { +latex_elements: dict[str, Any] = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). @@ -282,7 +300,7 @@ ( root_doc, "bigframes.tex", - "bigframes Documentation", + "BigQuery DataFrames (BigFrames)", author, "manual", ) @@ -317,7 +335,7 @@ ( root_doc, "bigframes", - "bigframes Documentation", + "BigQuery DataFrames (BigFrames)", [author], 1, ) @@ -336,7 +354,7 @@ ( root_doc, "bigframes", - "bigframes Documentation", + "BigQuery DataFrames (BigFrames)", author, "bigframes", "bigframes Library", @@ -359,7 +377,7 @@ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { - "python": ("https://python.readthedocs.org/en/latest/", None), + "python": ("https://docs.python.org/3/", None), "google-auth": ("https://googleapis.dev/python/google-auth/latest/", None), "google.api_core": ( "https://googleapis.dev/python/google-api-core/latest/", diff --git a/docs/reference/.gitignore b/docs/reference/.gitignore new file mode 100644 index 0000000000..3f12795483 --- /dev/null +++ b/docs/reference/.gitignore @@ -0,0 +1 @@ +api/* diff --git a/docs/reference/bigframes.bigquery/index.rst b/docs/reference/bigframes.bigquery/index.rst deleted file mode 100644 index 03e9bb48a4..0000000000 --- a/docs/reference/bigframes.bigquery/index.rst +++ /dev/null @@ -1,9 +0,0 @@ - -=========================== -BigQuery Built-in Functions -=========================== - -.. automodule:: bigframes.bigquery - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.geopandas/geoseries.rst b/docs/reference/bigframes.geopandas/geoseries.rst deleted file mode 100644 index 481eb73b9d..0000000000 --- a/docs/reference/bigframes.geopandas/geoseries.rst +++ /dev/null @@ -1,17 +0,0 @@ - -========= -GeoSeries -========= - -.. contents:: Table of Contents - :depth: 2 - :local: - :backlinks: none - -GeoSeries ---------- - -.. autoclass:: bigframes.geopandas.GeoSeries - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.geopandas/index.rst b/docs/reference/bigframes.geopandas/index.rst deleted file mode 100644 index e33946461c..0000000000 --- a/docs/reference/bigframes.geopandas/index.rst +++ /dev/null @@ -1,9 +0,0 @@ - -=============================== -BigQuery DataFrames (geopandas) -=============================== - -.. toctree:: - :maxdepth: 2 - - geoseries diff --git a/docs/reference/bigframes.ml/README.rst b/docs/reference/bigframes.ml/README.rst deleted file mode 100644 index 80a1fe97b7..0000000000 --- a/docs/reference/bigframes.ml/README.rst +++ /dev/null @@ -1,125 +0,0 @@ -BigQuery DataFrames ML -====================== - -As BigQuery DataFrames implements the Pandas API over top of BigQuery, BigQuery -DataFrame ML implements the SKLearn API over top of BigQuery Machine Learning. - -Tutorial --------- - -Start a session and initialize a dataframe for a BigQuery table - -.. code-block:: python - - import bigframes.pandas - - df = bigframes.pandas.read_gbq("bigquery-public-data.ml_datasets.penguins") - df - -Clean and prepare the data - -.. code-block:: python - - # filter down to the data we want to analyze - adelie_data = df[df.species == "Adelie Penguin (Pygoscelis adeliae)"] - - # drop the columns we don't care about - adelie_data = adelie_data.drop(columns=["species"]) - - # drop rows with nulls to get our training data - training_data = adelie_data.dropna() - - # take a peek at the training data - training_data - -.. code-block:: python - - # pick feature columns and label column - X = training_data[['island', 'culmen_length_mm', 'culmen_depth_mm', 'flipper_length_mm', 'sex']] - y = training_data[['body_mass_g']] - -Use train_test_split to create train and test datasets - -.. code-block:: python - - from bigframes.ml.model_selection import train_test_split - - X_train, X_test, y_train, y_test = train_test_split( - X, y, test_size=0.2) - -Define the model training pipeline - -.. code-block:: python - - from bigframes.ml.linear_model import LinearRegression - from bigframes.ml.pipeline import Pipeline - from bigframes.ml.compose import ColumnTransformer - from bigframes.ml.preprocessing import StandardScaler, OneHotEncoder - - preprocessing = ColumnTransformer([ - ("onehot", OneHotEncoder(), ["island", "species", "sex"]), - ("scaler", StandardScaler(), ["culmen_depth_mm", "culmen_length_mm", "flipper_length_mm"]), - ]) - - model = LinearRegression(fit_intercept=False) - - pipeline = Pipeline([ - ('preproc', preprocessing), - ('linreg', model) - ]) - - # view the pipeline - pipeline - -Train the pipeline - -.. code-block:: python - - pipeline.fit(X_train, y_train) - -Evaluate the model's performance on the test data - -.. code-block:: python - - from bigframes.ml.metrics import r2_score - - y_pred = pipeline.predict(X_test) - - r2_score(y_test, y_pred) - -Make predictions on new data - -.. code-block:: python - - import pandas - - new_penguins = bigframes.pandas.read_pandas( - pandas.DataFrame( - { - "tag_number": [1633, 1672, 1690], - "species": [ - "Adelie Penguin (Pygoscelis adeliae)", - "Adelie Penguin (Pygoscelis adeliae)", - "Adelie Penguin (Pygoscelis adeliae)", - ], - "island": ["Torgersen", "Torgersen", "Dream"], - "culmen_length_mm": [39.5, 38.5, 37.9], - "culmen_depth_mm": [18.8, 17.2, 18.1], - "flipper_length_mm": [196.0, 181.0, 188.0], - "sex": ["MALE", "FEMALE", "FEMALE"], - } - ).set_index("tag_number") - ) - - # view the new data - new_penguins - -.. code-block:: python - - pipeline.predict(new_penguins) - -Save the trained model to BigQuery, so we can load it later - -.. code-block:: python - - pipeline.to_gbq("bqml_tutorial.penguins_model", replace=True) diff --git a/docs/reference/bigframes.ml/cluster.rst b/docs/reference/bigframes.ml/cluster.rst deleted file mode 100644 index e91a28c051..0000000000 --- a/docs/reference/bigframes.ml/cluster.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.cluster -==================== - -.. automodule:: bigframes.ml.cluster - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/compose.rst b/docs/reference/bigframes.ml/compose.rst deleted file mode 100644 index 9992728362..0000000000 --- a/docs/reference/bigframes.ml/compose.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.compose -==================== - -.. automodule:: bigframes.ml.compose - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/decomposition.rst b/docs/reference/bigframes.ml/decomposition.rst deleted file mode 100644 index ec804ac8cd..0000000000 --- a/docs/reference/bigframes.ml/decomposition.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.decomposition -========================== - -.. automodule:: bigframes.ml.decomposition - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/ensemble.rst b/docs/reference/bigframes.ml/ensemble.rst deleted file mode 100644 index 2652ab5aa4..0000000000 --- a/docs/reference/bigframes.ml/ensemble.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.ensemble -===================== - -.. automodule:: bigframes.ml.ensemble - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/forecasting.rst b/docs/reference/bigframes.ml/forecasting.rst deleted file mode 100644 index 04015c9911..0000000000 --- a/docs/reference/bigframes.ml/forecasting.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.forecasting -======================== - -.. automodule:: bigframes.ml.forecasting - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/imported.rst b/docs/reference/bigframes.ml/imported.rst deleted file mode 100644 index c151cbda6f..0000000000 --- a/docs/reference/bigframes.ml/imported.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.imported -===================== - -.. automodule:: bigframes.ml.imported - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/impute.rst b/docs/reference/bigframes.ml/impute.rst deleted file mode 100644 index 3796e287ef..0000000000 --- a/docs/reference/bigframes.ml/impute.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.impute -========================== - -.. automodule:: bigframes.ml.impute - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/index.rst b/docs/reference/bigframes.ml/index.rst deleted file mode 100644 index c14efaede6..0000000000 --- a/docs/reference/bigframes.ml/index.rst +++ /dev/null @@ -1,38 +0,0 @@ -.. _bigframes_ml: -.. include:: README.rst - -API Reference -------------- - -.. toctree:: - :maxdepth: 3 - - cluster - - compose - - decomposition - - ensemble - - forecasting - - imported - - impute - - linear_model - - llm - - metrics - - metrics.pairwise - - model_selection - - pipeline - - preprocessing - - remote diff --git a/docs/reference/bigframes.ml/linear_model.rst b/docs/reference/bigframes.ml/linear_model.rst deleted file mode 100644 index 8c6c2765b1..0000000000 --- a/docs/reference/bigframes.ml/linear_model.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.linear_model -========================= - -.. automodule:: bigframes.ml.linear_model - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/llm.rst b/docs/reference/bigframes.ml/llm.rst deleted file mode 100644 index 20ae7793e7..0000000000 --- a/docs/reference/bigframes.ml/llm.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.llm -================ - -.. automodule:: bigframes.ml.llm - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/metrics.pairwise.rst b/docs/reference/bigframes.ml/metrics.pairwise.rst deleted file mode 100644 index c20772ef07..0000000000 --- a/docs/reference/bigframes.ml/metrics.pairwise.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.metrics.pairwise -============================= - -.. automodule:: bigframes.ml.metrics.pairwise - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/metrics.rst b/docs/reference/bigframes.ml/metrics.rst deleted file mode 100644 index aca11f7e9f..0000000000 --- a/docs/reference/bigframes.ml/metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.metrics -==================== - -.. automodule:: bigframes.ml.metrics - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/model_selection.rst b/docs/reference/bigframes.ml/model_selection.rst deleted file mode 100644 index d662285f99..0000000000 --- a/docs/reference/bigframes.ml/model_selection.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.model_selection -============================ - -.. automodule:: bigframes.ml.model_selection - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/pipeline.rst b/docs/reference/bigframes.ml/pipeline.rst deleted file mode 100644 index 22e877dc5b..0000000000 --- a/docs/reference/bigframes.ml/pipeline.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.pipeline -===================== - -.. automodule:: bigframes.ml.pipeline - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/preprocessing.rst b/docs/reference/bigframes.ml/preprocessing.rst deleted file mode 100644 index eac72da173..0000000000 --- a/docs/reference/bigframes.ml/preprocessing.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.preprocessing -========================== - -.. automodule:: bigframes.ml.preprocessing - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/remote.rst b/docs/reference/bigframes.ml/remote.rst deleted file mode 100644 index 7827acfe92..0000000000 --- a/docs/reference/bigframes.ml/remote.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.remote -=================== - -.. automodule:: bigframes.ml.remote - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.pandas/frame.rst b/docs/reference/bigframes.pandas/frame.rst deleted file mode 100644 index bc9f714416..0000000000 --- a/docs/reference/bigframes.pandas/frame.rst +++ /dev/null @@ -1,36 +0,0 @@ - -========= -DataFrame -========= - -.. contents:: Table of Contents - :depth: 2 - :local: - :backlinks: none - -DataFrame ---------- - -.. autoclass:: bigframes.dataframe.DataFrame - :members: - :inherited-members: - :undoc-members: - -Accessors ---------- - -Plotting handling -^^^^^^^^^^^^^^^^^ - -.. automodule:: bigframes.operations.plotting - :members: - :inherited-members: - :undoc-members: - -Struct handling -^^^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.structs.StructFrameAccessor - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.pandas/general_functions.rst b/docs/reference/bigframes.pandas/general_functions.rst deleted file mode 100644 index fff1a9ef59..0000000000 --- a/docs/reference/bigframes.pandas/general_functions.rst +++ /dev/null @@ -1,9 +0,0 @@ - -================= -General functions -================= - -.. automodule:: bigframes.pandas - :members: - :undoc-members: - :noindex: diff --git a/docs/reference/bigframes.pandas/groupby.rst b/docs/reference/bigframes.pandas/groupby.rst deleted file mode 100644 index 483340f348..0000000000 --- a/docs/reference/bigframes.pandas/groupby.rst +++ /dev/null @@ -1,20 +0,0 @@ - -======= -GroupBy -======= - -DataFrameGroupBy ----------------- - -.. autoclass:: bigframes.core.groupby.DataFrameGroupBy - :members: - :inherited-members: - :undoc-members: - -SeriesGroupBy -------------- - -.. autoclass:: bigframes.core.groupby.SeriesGroupBy - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.pandas/index.rst b/docs/reference/bigframes.pandas/index.rst deleted file mode 100644 index 3492f236ee..0000000000 --- a/docs/reference/bigframes.pandas/index.rst +++ /dev/null @@ -1,16 +0,0 @@ - -============================ -BigQuery DataFrames (pandas) -============================ - -.. toctree:: - :maxdepth: 2 - - general_functions - series - frame - indexers - indexing - window - groupby - options diff --git a/docs/reference/bigframes.pandas/indexers.rst b/docs/reference/bigframes.pandas/indexers.rst deleted file mode 100644 index 602b6de837..0000000000 --- a/docs/reference/bigframes.pandas/indexers.rst +++ /dev/null @@ -1,60 +0,0 @@ - -========= -Indexers -========= - -AtDataFrameIndexer --------------------- -.. autoclass:: bigframes.core.indexers.AtDataFrameIndexer - :members: - :inherited-members: - :undoc-members: - -AtSeriesIndexer --------------------- -.. autoclass:: bigframes.core.indexers.AtSeriesIndexer - :members: - :inherited-members: - :undoc-members: - -IatDataFrameIndexer --------------------- -.. autoclass:: bigframes.core.indexers.IatDataFrameIndexer - :members: - :inherited-members: - :undoc-members: - -IatSeriesIndexer --------------------- -.. autoclass:: bigframes.core.indexers.IatSeriesIndexer - :members: - :inherited-members: - :undoc-members: - -ILocDataFrameIndexer --------------------- -.. autoclass:: bigframes.core.indexers.ILocDataFrameIndexer - :members: - :inherited-members: - :undoc-members: - -IlocSeriesIndexer ------------------ -.. autoclass:: bigframes.core.indexers.IlocSeriesIndexer - :members: - :inherited-members: - :undoc-members: - -LocDataFrameIndexer -------------------- -.. autoclass:: bigframes.core.indexers.LocDataFrameIndexer - :members: - :inherited-members: - :undoc-members: - -LocSeriesIndexer ----------------- -.. autoclass:: bigframes.core.indexers.LocSeriesIndexer - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.pandas/indexing.rst b/docs/reference/bigframes.pandas/indexing.rst deleted file mode 100644 index 2cc1acfabf..0000000000 --- a/docs/reference/bigframes.pandas/indexing.rst +++ /dev/null @@ -1,9 +0,0 @@ - -============= -Index objects -============= - -.. autoclass:: bigframes.core.indexes.base.Index - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.pandas/options.rst b/docs/reference/bigframes.pandas/options.rst deleted file mode 100644 index 60af8c826a..0000000000 --- a/docs/reference/bigframes.pandas/options.rst +++ /dev/null @@ -1,6 +0,0 @@ - -==================== -Options and settings -==================== - -``bigframes.pandas.options`` is an alias for :data:`bigframes.options`. diff --git a/docs/reference/bigframes.pandas/series.rst b/docs/reference/bigframes.pandas/series.rst deleted file mode 100644 index 547b262591..0000000000 --- a/docs/reference/bigframes.pandas/series.rst +++ /dev/null @@ -1,61 +0,0 @@ - -====== -Series -====== - -.. contents:: Table of Contents - :depth: 2 - :local: - :backlinks: none - -Series ------- - -.. autoclass:: bigframes.series.Series - :members: - :inherited-members: - :undoc-members: - -Accessors ---------- - -Datetime properties -^^^^^^^^^^^^^^^^^^^ - -.. automodule:: bigframes.operations.datetimes - :members: - :inherited-members: - :undoc-members: - -String handling -^^^^^^^^^^^^^^^ - -.. automodule:: bigframes.operations.strings - :members: - :inherited-members: - :undoc-members: - -List handling -^^^^^^^^^^^^^ - -.. automodule:: bigframes.operations.lists - :members: - :inherited-members: - :undoc-members: - -Struct handling -^^^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.structs.StructAccessor - :members: - :inherited-members: - :undoc-members: - -Plotting handling -^^^^^^^^^^^^^^^^^ - -.. automodule:: bigframes.operations.plotting - :members: - :inherited-members: - :undoc-members: - :noindex: diff --git a/docs/reference/bigframes.pandas/window.rst b/docs/reference/bigframes.pandas/window.rst deleted file mode 100644 index 55d911ecf4..0000000000 --- a/docs/reference/bigframes.pandas/window.rst +++ /dev/null @@ -1,9 +0,0 @@ - -====== -Window -====== - -.. autoclass:: bigframes.core.window.Window - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.streaming/dataframe.rst b/docs/reference/bigframes.streaming/dataframe.rst deleted file mode 100644 index 79ec64961c..0000000000 --- a/docs/reference/bigframes.streaming/dataframe.rst +++ /dev/null @@ -1,6 +0,0 @@ -bigframes.streaming.dataframe -============================= - -.. autoclass:: bigframes.streaming.dataframe.StreamingDataFrame - :members: - :inherited-members: diff --git a/docs/reference/bigframes.streaming/index.rst b/docs/reference/bigframes.streaming/index.rst deleted file mode 100644 index 20a22072e5..0000000000 --- a/docs/reference/bigframes.streaming/index.rst +++ /dev/null @@ -1,13 +0,0 @@ - -============================ -BigQuery DataFrame Streaming -============================ - -.. automodule:: bigframes.streaming - :members: - :undoc-members: - -.. toctree:: - :maxdepth: 2 - - dataframe diff --git a/docs/reference/bigframes/enums.rst b/docs/reference/bigframes/enums.rst deleted file mode 100644 index b0a198e184..0000000000 --- a/docs/reference/bigframes/enums.rst +++ /dev/null @@ -1,8 +0,0 @@ - -===== -Enums -===== - -.. automodule:: bigframes.enums - :members: - :undoc-members: diff --git a/docs/reference/bigframes/exceptions.rst b/docs/reference/bigframes/exceptions.rst deleted file mode 100644 index c471aecdf7..0000000000 --- a/docs/reference/bigframes/exceptions.rst +++ /dev/null @@ -1,8 +0,0 @@ - -======================= -Exceptions and Warnings -======================= - -.. automodule:: bigframes.exceptions - :members: - :undoc-members: diff --git a/docs/reference/bigframes/index.rst b/docs/reference/bigframes/index.rst deleted file mode 100644 index f56883dc8e..0000000000 --- a/docs/reference/bigframes/index.rst +++ /dev/null @@ -1,22 +0,0 @@ - -============ -Core objects -============ - -.. toctree:: - :maxdepth: 2 - - enums - exceptions - options - - -Session -------- - -.. autofunction:: bigframes.connect - -.. autoclass:: bigframes.session.Session - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes/options.rst b/docs/reference/bigframes/options.rst deleted file mode 100644 index 991399eb88..0000000000 --- a/docs/reference/bigframes/options.rst +++ /dev/null @@ -1,16 +0,0 @@ -Options and settings -==================== - -.. currentmodule:: bigframes - -.. autodata:: options - -.. autoclass:: bigframes._config.Options - -.. autoclass:: bigframes._config.bigquery_options.BigQueryOptions - -.. autoclass:: bigframes._config.display_options.DisplayOptions - -.. autoclass:: bigframes._config.sampling_options.SamplingOptions - -.. autoclass:: bigframes._config.compute_options.ComputeOptions diff --git a/docs/reference/index.rst b/docs/reference/index.rst index a0f96f751a..e348bd608b 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -4,12 +4,42 @@ API Reference Refer to these pages for details about the public objects in the ``bigframes`` packages. -.. toctree:: - :maxdepth: 2 - - bigframes/index - bigframes.bigquery/index - bigframes.geopandas/index - bigframes.ml/index - bigframes.pandas/index - bigframes.streaming/index +.. autosummary:: + :toctree: api + + bigframes._config + bigframes.bigquery + bigframes.bigquery.ai + bigframes.bigquery.ml + bigframes.enums + bigframes.exceptions + bigframes.geopandas + bigframes.pandas + bigframes.pandas.api.typing + bigframes.streaming + +ML APIs +~~~~~~~ + +BigQuery DataFrames provides many machine learning modules, inspired by +scikit-learn. + + +.. autosummary:: + :toctree: api + + bigframes.ml + bigframes.ml.cluster + bigframes.ml.compose + bigframes.ml.decomposition + bigframes.ml.ensemble + bigframes.ml.forecasting + bigframes.ml.imported + bigframes.ml.impute + bigframes.ml.linear_model + bigframes.ml.llm + bigframes.ml.metrics + bigframes.ml.model_selection + bigframes.ml.pipeline + bigframes.ml.preprocessing + bigframes.ml.remote diff --git a/docs/templates/toc.yml b/docs/templates/toc.yml index b4f513b11d..5d043fd85f 100644 --- a/docs/templates/toc.yml +++ b/docs/templates/toc.yml @@ -45,6 +45,7 @@ uid: bigframes.operations.plotting.PlotAccessor - name: StructAccessor uid: bigframes.operations.structs.StructFrameAccessor + name: DataFrame - items: - name: DataFrameGroupBy uid: bigframes.core.groupby.DataFrameGroupBy @@ -82,8 +83,13 @@ uid: bigframes.operations.strings.StringMethods - name: StructAccessor uid: bigframes.operations.structs.StructAccessor + - name: ListAccessor + uid: bigframes.operations.lists.ListAccessor - name: PlotAccessor uid: bigframes.operations.plotting.PlotAccessor + - name: BlobAccessor + uid: bigframes.operations.blob.BlobAccessor + status: beta name: Series - name: Window uid: bigframes.core.window.Window @@ -106,6 +112,8 @@ uid: bigframes.ml.decomposition - name: PCA uid: bigframes.ml.decomposition.PCA + - name: MatrixFactorization + uid: bigframes.ml.decomposition.MatrixFactorization name: decomposition - items: - name: Overview @@ -207,6 +215,9 @@ - items: - name: BigQuery built-in functions uid: bigframes.bigquery + - name: BigQuery AI Functions + uid: bigframes.bigquery._operations.ai + status: beta name: bigframes.bigquery - items: - name: GeoSeries diff --git a/mypy.ini b/mypy.ini index f0a005d2e5..7709eb200a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -35,3 +35,12 @@ ignore_missing_imports = True [mypy-pyarrow.feather] ignore_missing_imports = True + +[mypy-google.cloud.pubsub] +ignore_missing_imports = True + +[mypy-google.cloud.bigtable] +ignore_missing_imports = True + +[mypy-anywidget] +ignore_missing_imports = True diff --git a/notebooks/.gitignore b/notebooks/.gitignore new file mode 100644 index 0000000000..d9acee9f51 --- /dev/null +++ b/notebooks/.gitignore @@ -0,0 +1,5 @@ +.ipynb_checkpoints/ +*.bq_exec_time_seconds +*.bytesprocessed +*.query_char_count +*.slotmillis diff --git a/notebooks/apps/synthetic_data_generation.ipynb b/notebooks/apps/synthetic_data_generation.ipynb index a6e8444aac..b59777a5da 100644 --- a/notebooks/apps/synthetic_data_generation.ipynb +++ b/notebooks/apps/synthetic_data_generation.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -38,7 +38,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -52,12 +52,12 @@ "output_type": "stream", "text": [ "Collecting faker\n", - " Downloading Faker-24.9.0-py3-none-any.whl (1.8 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.8/1.8 MB\u001b[0m \u001b[31m11.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: python-dateutil>=2.4 in /usr/local/lib/python3.10/dist-packages (from faker) (2.8.2)\n", - "Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.10/dist-packages (from python-dateutil>=2.4->faker) (1.16.0)\n", - "Installing collected packages: faker\n", - "Successfully installed faker-24.9.0\n" + " Downloading faker-37.1.0-py3-none-any.whl.metadata (15 kB)\n", + "Requirement already satisfied: tzdata in /usr/local/google/home/shuowei/src/python-bigquery-dataframes/venv/lib/python3.10/site-packages (from faker) (2024.2)\n", + "Downloading faker-37.1.0-py3-none-any.whl (1.9 MB)\n", + "\u001b[2K \u001b[38;2;114;156;31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.9/1.9 MB\u001b[0m \u001b[31m55.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hInstalling collected packages: faker\n", + "Successfully installed faker-37.1.0\n" ] } ], @@ -67,11 +67,23 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "metadata": { "id": "m3q1oeJALhsG" }, - "outputs": [], + "outputs": [ + { + "ename": "NameError", + "evalue": "name 'PROJECT_ID' is not defined", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[3], line 2\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[38;5;28;01mimport\u001b[39;00m \u001b[38;5;21;01mbigframes\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mpandas\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m \u001b[38;5;21;01mbpd\u001b[39;00m\n\u001b[0;32m----> 2\u001b[0m bpd\u001b[38;5;241m.\u001b[39moptions\u001b[38;5;241m.\u001b[39mbigquery\u001b[38;5;241m.\u001b[39mproject \u001b[38;5;241m=\u001b[39m \u001b[43mPROJECT_ID\u001b[49m\n", + "\u001b[0;31mNameError\u001b[0m: name 'PROJECT_ID' is not defined" + ] + } + ], "source": [ "import bigframes.pandas as bpd\n", "bpd.options.bigquery.project = PROJECT_ID" @@ -95,32 +107,11 @@ "id": "lIYdn1woOS1n", "outputId": "be474338-44c2-4ce0-955e-d525b8b9c84b" }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/lib/python3.10/dist-packages/bigframes/session/__init__.py:1907: UserWarning: No explicit location is set, so using location US for the session.\n", - " return Session(context)\n" - ] - }, - { - "data": { - "text/html": [ - "Query job 3e8423da-737c-42e2-a3d2-d2180ca18579 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "from bigframes.ml.llm import GeminiTextGenerator\n", "\n", - "model = GeminiTextGenerator()" + "model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")" ] }, { @@ -141,77 +132,7 @@ "id": "SSR-lLScLa95", "outputId": "cbaec34e-6fa6-45b4-e54a-f11ca06b61e1" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job d651d0bf-300c-4b1d-9e3c-03310b71287c is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job c67b9bb9-2f3e-4b9e-b680-0b7b6e9d2279 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
prompt
0Write python code to generate a pandas datafra...
\n", - "

1 rows × 1 columns

\n", - "
[1 rows x 1 columns in total]" - ], - "text/plain": [ - " prompt\n", - "0 Write python code to generate a pandas datafra...\n", - "\n", - "[1 rows x 1 columns]" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "prompt = \"\"\"\\\n", "Write python code to generate a pandas dataframe based on the requirements:\n", @@ -248,73 +169,7 @@ "id": "miDe3K4GNvOo", "outputId": "f2039e80-5ad7-4551-f8b2-7ef714a89d63" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job d5c0725d-9070-4712-adfd-8a9bd86eefc3 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 4eb581a3-7f97-411a-bee1-91e8c150cef4 is DONE. 8 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job f3d5503d-a3e7-49ce-b985-5ffbdbd856e3 is DONE. 2 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 8ef76041-f077-4a05-bc03-63e6983ef853 is DONE. 332 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "import pandas as pd\n", - "from faker import Faker\n", - "\n", - "fake = Faker('es_ES')\n", - "result_df = pd.DataFrame({\n", - " 'Name': [fake.name() for _ in range(100)],\n", - " 'Age': [fake.random_int(min=18, max=65) for _ in range(100)],\n", - " 'Gender': [fake.random_element(elements=['Male', 'Female', 'Non-binary']) for _ in range(100)]\n", - "})\n", - "\n" - ] - } - ], + "outputs": [], "source": [ "max_tries = 5\n", "for i in range(max_tries):\n", @@ -366,342 +221,7 @@ "id": "GODcPwX2PBEu", "outputId": "dec4c872-c464-49e4-cd7f-9442fc977d18" }, - "outputs": [ - { - "data": { - "application/vnd.google.colaboratory.intrinsic+json": { - "summary": "{\n \"name\": \"execution_context\",\n \"rows\": 100,\n \"fields\": [\n {\n \"column\": \"Name\",\n \"properties\": {\n \"dtype\": \"string\",\n \"num_unique_values\": 100,\n \"samples\": [\n \"Renata Pla Cases\",\n \"Guiomar Carnero-Paz\",\n \"Luciano Garmendia\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n },\n {\n \"column\": \"Age\",\n \"properties\": {\n \"dtype\": \"number\",\n \"std\": 13,\n \"min\": 18,\n \"max\": 64,\n \"num_unique_values\": 39,\n \"samples\": [\n 56,\n 31,\n 34\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n },\n {\n \"column\": \"Gender\",\n \"properties\": {\n \"dtype\": \"category\",\n \"num_unique_values\": 3,\n \"samples\": [\n \"Male\",\n \"Non-binary\",\n \"Female\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n }\n ]\n}", - "type": "dataframe" - }, - "text/html": [ - "\n", - "
\n", - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
NameAgeGender
0Pastora Acuña Company21Male
1León Reig-Salom39Non-binary
2Aura Tomás Llobet30Female
3Vicente Correa Palomar64Female
4Benito del Fuster34Female
............
95Eduardo Cabrera27Non-binary
96Nazaret de Izaguirre40Non-binary
97Manuela Agullo Bustamante27Female
98Eugenio Mateo Naranjo Blazquez36Non-binary
99Heriberto Vicens Baeza53Female
\n", - "

100 rows × 3 columns

\n", - "
\n", - "
\n", - "\n", - "
\n", - " \n", - "\n", - " \n", - "\n", - " \n", - "
\n", - "\n", - "\n", - "
\n", - " \n", - "\n", - "\n", - "\n", - " \n", - "
\n", - "\n", - "
\n", - "
\n" - ], - "text/plain": [ - " Name Age Gender\n", - "0 Pastora Acuña Company 21 Male\n", - "1 León Reig-Salom 39 Non-binary\n", - "2 Aura Tomás Llobet 30 Female\n", - "3 Vicente Correa Palomar 64 Female\n", - "4 Benito del Fuster 34 Female\n", - ".. ... ... ...\n", - "95 Eduardo Cabrera 27 Non-binary\n", - "96 Nazaret de Izaguirre 40 Non-binary\n", - "97 Manuela Agullo Bustamante 27 Female\n", - "98 Eugenio Mateo Naranjo Blazquez 36 Non-binary\n", - "99 Heriberto Vicens Baeza 53 Female\n", - "\n", - "[100 rows x 3 columns]" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "execution_context = {}\n", "exec(code, execution_context)\n", @@ -726,24 +246,10 @@ "id": "n-BsGciNqSwU", "outputId": "996e5639-a49c-4542-a0dc-ede450e0eb6d" }, - "outputs": [ - { - "data": { - "application/vnd.google.colaboratory.intrinsic+json": { - "type": "string" - }, - "text/plain": [ - "'projects/bigframes-dev/locations/us-central1/functions/bigframes-19f2f35637098969770261a2974bef32'" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "@bpd.remote_function([int], str, packages=['faker', 'pandas'])\n", - "def data_generator(id):\n", + "@bpd.remote_function(packages=['faker', 'pandas'], cloud_function_service_account=\"default\")\n", + "def data_generator(id: int) -> str:\n", " context = {}\n", " exec(code, context)\n", " result_df = context.get(\"result_df\")\n", @@ -770,20 +276,7 @@ "id": "Odkmev9nsYqA", "outputId": "4aa7a1fd-0c0d-4412-f326-a20e19f583b5" }, - "outputs": [ - { - "data": { - "text/html": [ - "Load job 40b9c3a8-27fc-40a8-9edf-4aa2e0fec332 is DONE. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "desired_num_rows = 1_000_000 # 1 million rows\n", "batch_size = 100 # used in the prompt\n", @@ -803,20 +296,7 @@ "id": "UyBhlJFVsmQC", "outputId": "29748df5-673b-4320-bb1f-53abaace3b81" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 9dd49b50-2dbf-4351-b9ad-b17aeb627caf is DONE. 240.0 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "df[\"json_data\"] = df[\"row_id\"].apply(data_generator)" ] @@ -839,262 +319,7 @@ "id": "6p3eM21qvRvy", "outputId": "333f4e49-a555-4d2f-b527-02142782b3a7" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 3f8d2133-b01d-402d-a731-79592810ca1c is DONE. 63.7 MB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 4a613aa3-6323-4914-8e34-93323885d458 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 0deb03be-725b-40b4-a7a1-1023b0477f35 is DONE. 40.1 MB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
NameAgeGender
0Eloy Santiago-Aragón31Male
1Amanda Mata Abril20Non-binary
2Danilo Velázquez Salcedo58Male
3Leyre Alba España61Female
4Paulina Amores Pastor41Male
5Jorge Cuadrado Mena50Female
6Chucho Catalán36Non-binary
7Vidal Benavente Lerma38Male
8Clementina Álamo32Female
9Petrona Roselló-Valls61Male
10Luís Camilo Sastre Marin45Male
11Gil Baudelio Carbajo Ordóñez58Non-binary
12David del Donoso44Female
13Dolores Arnau Ros21Non-binary
14Febe de León46Non-binary
15Ariadna Almazán34Female
16Blas Serna Aguiló24Non-binary
17Paulino Barreda Almeida59Female
18Eligio Valcárcel Tormo35Non-binary
19Toño Amador Torres Portillo48Female
20Florencia del Bejarano65Non-binary
21Clímaco Andreu Gómez18Male
22Xiomara Dominguez Solana35Female
23Leire Castilla Borrego19Non-binary
24Angelita Garmendia Carpio21Non-binary
\n", - "

25 rows × 3 columns

\n", - "
[1000000 rows x 3 columns in total]" - ], - "text/plain": [ - " Name Age Gender\n", - "0 Eloy Santiago-Aragón 31 Male\n", - "1 Amanda Mata Abril 20 Non-binary\n", - "2 Danilo Velázquez Salcedo 58 Male\n", - "3 Leyre Alba España 61 Female\n", - "4 Paulina Amores Pastor 41 Male\n", - "5 Jorge Cuadrado Mena 50 Female\n", - "6 Chucho Catalán 36 Non-binary\n", - "7 Vidal Benavente Lerma 38 Male\n", - "8 Clementina Álamo 32 Female\n", - "9 Petrona Roselló-Valls 61 Male\n", - "10 Luís Camilo Sastre Marin 45 Male\n", - "11 Gil Baudelio Carbajo Ordóñez 58 Non-binary\n", - "12 David del Donoso 44 Female\n", - "13 Dolores Arnau Ros 21 Non-binary\n", - "14 Febe de León 46 Non-binary\n", - "15 Ariadna Almazán 34 Female\n", - "16 Blas Serna Aguiló 24 Non-binary\n", - "17 Paulino Barreda Almeida 59 Female\n", - "18 Eligio Valcárcel Tormo 35 Non-binary\n", - "19 Toño Amador Torres Portillo 48 Female\n", - "20 Florencia del Bejarano 65 Non-binary\n", - "21 Clímaco Andreu Gómez 18 Male\n", - "22 Xiomara Dominguez Solana 35 Female\n", - "23 Leire Castilla Borrego 19 Non-binary\n", - "24 Angelita Garmendia Carpio 21 Non-binary\n", - "...\n", - "\n", - "[1000000 rows x 3 columns]" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "sql = f\"\"\"\n", "WITH T0 AS ({df.sql}),\n", @@ -1126,6 +351,18 @@ "kernelspec": { "display_name": "Python 3", "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.15" } }, "nbformat": 4, diff --git a/notebooks/dataframes/struct_and_array_dtypes.ipynb b/notebooks/data_types/array.ipynb similarity index 50% rename from notebooks/dataframes/struct_and_array_dtypes.ipynb rename to notebooks/data_types/array.ipynb index def65ee6ca..96c5da5ac6 100644 --- a/notebooks/dataframes/struct_and_array_dtypes.ipynb +++ b/notebooks/data_types/array.ipynb @@ -6,7 +6,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Copyright 2024 Google LLC\n", + "# Copyright 2025 Google LLC\n", "#\n", "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", "# you may not use this file except in compliance with the License.\n", @@ -25,16 +25,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# A Guide to Array and Struct Data Types in BigQuery DataFrames" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Set up your environment\n", + "# Array Data Types\n", + "\n", + "In BigQuery, an [ARRAY](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#array_type) (also called a `repeated` column) is an ordered list of zero or more elements of the same, non-`NULL` data type. It's important to note that BigQuery `ARRAY`s cannot contain nested `ARRAY`s. BigQuery DataFrames represents BigQuery `ARRAY` types to `pandas.ArrowDtype(pa.list_(T))`, where `T` is the underlying Arrow type of the array elements.\n", "\n", - "To get started, follow the instructions in the notebooks within the `getting_started` folder to set up your environment. Once your environment is ready, you can import the necessary packages by running the following code:" + "This notebook illustrates how to work with `ARRAY` columns in BigQuery DataFrames. First, let's import the required packages and perform the necessary setup below." ] }, { @@ -45,6 +40,7 @@ "source": [ "import bigframes.pandas as bpd\n", "import bigframes.bigquery as bbq\n", + "import pandas as pd\n", "import pyarrow as pa" ] }, @@ -64,20 +60,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Array Data Types\n", - "\n", - "In BigQuery, an [array](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#array_type) (also called a repeated column) is an ordered list of zero or more elements of the same data type. Arrays cannot contain other arrays or `NULL` elements.\n", + "## Create DataFrames with an array column\n", "\n", - "BigQuery DataFrames map BigQuery array types to `pandas.ArrowDtype(pa.list_())`. The following code examples illustrate how to work with array columns in BigQuery DataFrames." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Create DataFrames with array columns\n", - "\n", - "Create a DataFrame in BigQuery DataFrames from local sample data. Use a list of lists to create a column with the `list[pyarrow]` dtype, which corresponds to the `ARRAY` type in BigQuery." + "**Example 1: Creating from a list of lists/tuples**" ] }, { @@ -146,10 +131,13 @@ } ], "source": [ - "df = bpd.DataFrame({\n", - " 'Name': ['Alice', 'Bob', 'Charlie'],\n", - " 'Scores': [[95, 88, 92], [78, 81], [82, 89, 94, 100]],\n", - "})\n", + "names = [\"Alice\", \"Bob\", \"Charlie\"]\n", + "scores = [\n", + " [95, 88, 92],\n", + " [78, 81],\n", + " [82, 89, 94, 100]\n", + "]\n", + "df = bpd.DataFrame({\"Name\": names, \"Scores\": scores})\n", "df" ] }, @@ -179,9 +167,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Operate on array data\n", - "\n", - "While pandas offers vectorized operations and lambda expressions for array manipulation, BigQuery DataFrames leverages the computational power of BigQuery itself. You can access a variety of native BigQuery array operations, such as [`array_agg`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery#bigframes_bigquery_array_agg) and [`array_length`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery#bigframes_bigquery_array_length), through the [`bigframes.bigquery`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery) package (abbreviated as `bbq` in the following code samples)." + "**Example 2: Defining schema explicitly**" ] }, { @@ -192,10 +178,10 @@ { "data": { "text/plain": [ - "0 3\n", - "1 2\n", - "2 4\n", - "Name: Scores, dtype: Int64" + "0 [95. 88. 92.]\n", + "1 [78. 81.]\n", + "2 [ 82. 89. 94. 100.]\n", + "dtype: list[pyarrow]" ] }, "execution_count": 6, @@ -204,8 +190,14 @@ } ], "source": [ - "# Find the length in each array.\n", - "bbq.array_length(df['Scores'])" + "bpd.Series(data=scores, dtype=pd.ArrowDtype(pa.list_(pa.float64())))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Example 3: Reading from a source**" ] }, { @@ -216,10 +208,12 @@ { "data": { "text/plain": [ - "0 3\n", - "1 2\n", - "2 4\n", - "Name: Scores, dtype: Int64" + "0 [{'tables': {'score': 0.9349926710128784, 'val...\n", + "1 [{'tables': {'score': 0.9690881371498108, 'val...\n", + "2 [{'tables': {'score': 0.8667634129524231, 'val...\n", + "3 [{'tables': {'score': 0.9351968765258789, 'val...\n", + "4 [{'tables': {'score': 0.8572560548782349, 'val...\n", + "Name: predicted_default_payment_next_month, dtype: list>>[pyarrow]" ] }, "execution_count": 7, @@ -228,389 +222,188 @@ } ], "source": [ - "# Find the length of each array with list accessor\n", - "df['Scores'].list.len()" + "bpd.read_gbq(\"bigquery-public-data.ml_datasets.credit_card_default\", max_results=5)[\"predicted_default_payment_next_month\"]" ] }, { - "cell_type": "code", - "execution_count": 8, + "cell_type": "markdown", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0 88\n", - "1 81\n", - "2 89\n", - "Name: Scores, dtype: Int64" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ - "# Find the second element in each array with list accessor\n", - "df['Scores'].list[1]" + "## Operate on `ARRAY` data\n", + "\n", + "BigQuery DataFrames provides two main approaches for operating on list (`ARRAY`) data:\n", + "\n", + "1. **The `Series.list` accessor**: Provides Pandas-like methods for array column manipulation.\n", + "2. **[BigQuery built-in functions](https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions)**: Allows you to use functions mirroring BigQuery SQL operations, available through the `bigframes.bigquery` module (abbreviated as `bbq` below), such as [`array_agg`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery#bigframes_bigquery_array_agg) and [`array_length`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery#bigframes_bigquery_array_length)." ] }, { - "cell_type": "code", - "execution_count": 9, + "cell_type": "markdown", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0 95\n", - "0 88\n", - "0 92\n", - "1 78\n", - "1 81\n", - "2 82\n", - "2 89\n", - "2 94\n", - "2 100\n", - "Name: Scores, dtype: Int64" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ - "# Transforms array elements into individual rows, preserving original order when in ordering\n", - "# mode. If an array has multiple elements, exploded rows are ordered by the element's index\n", - "# within its original array.\n", - "scores = df['Scores'].explode()\n", - "scores" + "### Get the Length of Each Arrray\n", + "\n", + "**Example 1: Using list accessor to get array length**" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "0 100.0\n", - "0 93.0\n", - "0 97.0\n", - "1 83.0\n", - "1 86.0\n", - "2 87.0\n", - "2 94.0\n", - "2 99.0\n", - "2 105.0\n", - "Name: Scores, dtype: Float64" + "0 3\n", + "1 2\n", + "2 4\n", + "Name: Scores, dtype: Int64" ] }, - "execution_count": 10, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# Adjust the scores.\n", - "adj_scores = scores + 5.0\n", - "adj_scores" + "df['Scores'].list.len()" ] }, { - "cell_type": "code", - "execution_count": 11, + "cell_type": "markdown", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0 [100. 93. 97.]\n", - "1 [83. 86.]\n", - "2 [ 87. 94. 99. 105.]\n", - "Name: Scores, dtype: list[pyarrow]" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ - "# Aggregate adjusted scores back into arrays.\n", - "adj_scores_arr = bbq.array_agg(adj_scores.groupby(level=0))\n", - "adj_scores_arr" + "**Example 2: Using BigQuery build-in functions to get array length**" ] }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
NameScoresNewScores
0Alice[95 88 92][100. 93. 97.]
1Bob[78 81][83. 86.]
2Charlie[ 82 89 94 100][ 87. 94. 99. 105.]
\n", - "

3 rows × 3 columns

\n", - "
[3 rows x 3 columns in total]" - ], "text/plain": [ - " Name Scores NewScores\n", - "0 Alice [95 88 92] [100. 93. 97.]\n", - "1 Bob [78 81] [83. 86.]\n", - "2 Charlie [ 82 89 94 100] [ 87. 94. 99. 105.]\n", - "\n", - "[3 rows x 3 columns]" + "0 3\n", + "1 2\n", + "2 4\n", + "Name: Scores, dtype: Int64" ] }, - "execution_count": 12, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# Add adjusted scores into the DataFrame. This operation requires an implicit join \n", - "# between the two tables, necessitating a unique index in the DataFrame (guaranteed \n", - "# in the default ordering and index mode).\n", - "df['NewScores'] = adj_scores_arr\n", - "df" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Struct Data Types\n", - "\n", - "In BigQuery, a [struct](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#struct_type) (also known as a `record`) is a collection of ordered fields, each with a defined data type (required) and an optional field name. BigQuery DataFrames maps BigQuery struct types to the pandas equivalent, `pandas.ArrowDtype(pa.struct())`. This section provides practical code examples illustrating how to use struct columns with BigQuery DataFrames." + "bbq.array_length(df['Scores'])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Create DataFrames with struct columns \n", - "\n", - "Create a DataFrame with an `Address` struct column by using dictionaries for the data and setting the dtype to `struct[pyarrow]`." + "### Access Element at a Specific Index (e.g., First Element) " ] }, { "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/venv/lib/python3.11/site-packages/google/cloud/bigquery/_pandas_helpers.py:570: UserWarning: Pyarrow could not determine the type of columns: bigframes_unnamed_index.\n", - " warnings.warn(\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
NameAddress
0Alice{'City': 'New York', 'State': 'NY'}
1Bob{'City': 'San Francisco', 'State': 'CA'}
2Charlie{'City': 'Seattle', 'State': 'WA'}
\n", - "

3 rows × 2 columns

\n", - "
[3 rows x 2 columns in total]" - ], - "text/plain": [ - " Name Address\n", - "0 Alice {'City': 'New York', 'State': 'NY'}\n", - "1 Bob {'City': 'San Francisco', 'State': 'CA'}\n", - "2 Charlie {'City': 'Seattle', 'State': 'WA'}\n", - "\n", - "[3 rows x 2 columns]" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "names = bpd.Series(['Alice', 'Bob', 'Charlie'])\n", - "address = bpd.Series(\n", - " [\n", - " {'City': 'New York', 'State': 'NY'},\n", - " {'City': 'San Francisco', 'State': 'CA'},\n", - " {'City': 'Seattle', 'State': 'WA'}\n", - " ],\n", - " dtype=bpd.ArrowDtype(pa.struct(\n", - " [('City', pa.string()), ('State', pa.string())]\n", - " )))\n", - "\n", - "df = bpd.DataFrame({'Name': names, 'Address': address})\n", - "df" - ] - }, - { - "cell_type": "code", - "execution_count": 14, + "execution_count": 10, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "Name string[pyarrow]\n", - "Address struct[pyarrow]\n", - "dtype: object" + "0 95\n", + "1 78\n", + "2 82\n", + "Name: Scores, dtype: Int64" ] }, - "execution_count": 14, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "df.dtypes" + "df['Scores'].list[0]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Operate on struct data\n", + "### Explode/Unnest Array elements into Seperate Rows\n", "\n", - "Similar to pandas, BigQuery DataFrames provides a [`StructAccessor`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.operations.structs.StructAccessor). Use the methods provided in this accessor to manipulate struct data." + "The exploded rows preserving original order when in ordering mode. If an array has multiple elements, exploded rows are ordered by the element's index\n", + "within its original array. " ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 11, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "City string[pyarrow]\n", - "State string[pyarrow]\n", - "dtype: object" + "0 95\n", + "0 88\n", + "0 92\n", + "1 78\n", + "1 81\n", + "2 82\n", + "2 89\n", + "2 94\n", + "2 100\n", + "Name: Scores, dtype: Int64" ] }, - "execution_count": 15, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# Return the dtype object of each child field of the struct.\n", - "df['Address'].struct.dtypes()" + "scores = df['Scores'].explode()\n", + "scores" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Aggregate elements back into an array" ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 12, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "0 New York\n", - "1 San Francisco\n", - "2 Seattle\n", - "Name: City, dtype: string" + "0 [100. 93. 97.]\n", + "1 [83. 86.]\n", + "2 [ 87. 94. 99. 105.]\n", + "Name: Scores, dtype: list[pyarrow]" ] }, - "execution_count": 16, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# Extract a child field as a Series\n", - "city = df['Address'].struct.field(\"City\")\n", - "city" + "new_scores = scores + 5.0\n", + "new_scores_arr = bbq.array_agg(new_scores.groupby(level=0))\n", + "new_scores_arr" ] }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 13, "metadata": {}, "outputs": [ { @@ -634,49 +427,55 @@ " \n", " \n", " \n", - " City\n", - " State\n", + " Name\n", + " Scores\n", + " NewScores\n", " \n", " \n", " \n", " \n", " 0\n", - " New York\n", - " NY\n", + " Alice\n", + " [95 88 92]\n", + " [100. 93. 97.]\n", " \n", " \n", " 1\n", - " San Francisco\n", - " CA\n", + " Bob\n", + " [78 81]\n", + " [83. 86.]\n", " \n", " \n", " 2\n", - " Seattle\n", - " WA\n", + " Charlie\n", + " [ 82 89 94 100]\n", + " [ 87. 94. 99. 105.]\n", " \n", " \n", "\n", - "

3 rows × 2 columns

\n", - "[3 rows x 2 columns in total]" + "

3 rows × 3 columns

\n", + "[3 rows x 3 columns in total]" ], "text/plain": [ - " City State\n", - "0 New York NY\n", - "1 San Francisco CA\n", - "2 Seattle WA\n", + " Name Scores NewScores\n", + "0 Alice [95 88 92] [100. 93. 97.]\n", + "1 Bob [78 81] [83. 86.]\n", + "2 Charlie [ 82 89 94 100] [ 87. 94. 99. 105.]\n", "\n", - "[3 rows x 2 columns]" + "[3 rows x 3 columns]" ] }, - "execution_count": 17, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# Extract all child fields of a struct as a DataFrame.\n", - "address_df = df['Address'].struct.explode()\n", - "address_df" + "# Add adjusted scores into the DataFrame. This operation requires an implicit join \n", + "# between the two tables, necessitating a unique index in the DataFrame (guaranteed \n", + "# in the default ordering and index mode).\n", + "df['NewScores'] = new_scores_arr\n", + "df" ] } ], @@ -696,7 +495,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.9" + "version": "3.12.1" } }, "nbformat": 4, diff --git a/notebooks/data_types/json.ipynb b/notebooks/data_types/json.ipynb new file mode 100644 index 0000000000..f0a8ed4ffe --- /dev/null +++ b/notebooks/data_types/json.ipynb @@ -0,0 +1,451 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# JSON Data Types\n", + "\n", + "When using BigQuery DataFrames, columns containing data in BigQuery's [JSON](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#json_type) format (a lightweight standard) are represented as `pandas.ArrowDtype`. The exact underlying Arrow type depends on your library versions. Older environments typically use `db_dtypes.JSONArrowType()` for compatibility, which is an Arrow extension type acting as a light wrapper around `pa.string()`. In contrast, newer setups (pandas 3.0+ and pyarrow 19.0+) utilize the more recent `pa.json_(pa.string())` representation." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "import bigframes.bigquery as bbq\n", + "import db_dtypes\n", + "import pandas as pd\n", + "import pyarrow as pa" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "REGION = \"US\" # @param {type: \"string\"}\n", + "\n", + "bpd.options.display.progress_bar = None\n", + "bpd.options.bigquery.location = REGION" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create Series with JSON columns\n", + "\n", + "**Example 1: Create a Series with a JSON dtype from local data**\n", + "\n", + "This example demonstrates creating a JSON Series from a list of JSON strings. Note that BigQuery standardizes these strings, for instance, by removing extra spaces and ordering dictionary keys. Specifying the `dtype` is essential; if omitted, a string-type Series will be generated." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 1\n", + "1 \"str\"\n", + "2 false\n", + "3 [\"a\",{\"b\":1},null]\n", + "4 {\"a\":{\"b\":[1,2,3],\"c\":true}}\n", + "5 \n", + "dtype: extension>[pyarrow]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "json_data = [\n", + " \"1\",\n", + " '\"str\"',\n", + " \"false\",\n", + " '[\"a\", {\"b\": 1}, null]',\n", + " '{\"a\": {\"b\": [1, 2, 3], \"c\": true}}',\n", + " None,\n", + "]\n", + "bpd.Series(json_data, dtype=pd.ArrowDtype(db_dtypes.JSONArrowType()))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Example 2: Create a Series with a Nested JSON dtype from local data**\n", + "\n", + "To create a BigQuery DataFrame Series containing `JSON` data nested within a `STRUCT` or `LIST` type, you must represent the `JSON` data in a `pa.array` defined with the `pa.string` type. This workaround is necessary because Pyarrow lacks support for creating structs or lists that directly contain extension types (see [issue](https://github.com/apache/arrow/issues/45262))." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 [{'key': '1'}]\n", + "1 [{'key': None}]\n", + "2 [{'key': '[\"1\",\"3\",\"5\"]'}]\n", + "3 [{'key': '{\"a\":1,\"b\":[\"x\",\"y\"],\"c\":{\"x\":[],\"z\"...\n", + "dtype: list>>>[pyarrow]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "list_data = [\n", + " [{\"key\": \"1\"}],\n", + " [{\"key\": None}],\n", + " [{\"key\": '[\"1\",\"3\",\"5\"]'}],\n", + " [{\"key\": '{\"a\":1,\"b\":[\"x\",\"y\"],\"c\":{\"x\":[],\"z\":false}}'}],\n", + "]\n", + "pa_array = pa.array(list_data, type=pa.list_(pa.struct([(\"key\", pa.string())])))\n", + "bpd.Series(\n", + " pd.arrays.ArrowExtensionArray(pa_array),\n", + " dtype=pd.ArrowDtype(\n", + " pa.list_(pa.struct([(\"key\", db_dtypes.JSONArrowType())])),\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Example 3: Create a Series with a Nested JSON dtype using BigQuery SQLs**" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
idstruct_col
01{'data': '{\"b\":100}', 'number': 2}
10{'data': '{\"a\":true}', 'number': 1}
\n", + "

2 rows × 2 columns

\n", + "
[2 rows x 2 columns in total]" + ], + "text/plain": [ + " id struct_col\n", + "0 1 {'data': '{\"b\":100}', 'number': 2}\n", + "1 0 {'data': '{\"a\":true}', 'number': 1}\n", + "\n", + "[2 rows x 2 columns]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sql = \"\"\"\n", + "SELECT 0 AS id, STRUCT(JSON_OBJECT('a', True) AS data, 1 AS number) AS struct_col\n", + "UNION ALL\n", + "SELECT 1, STRUCT(JSON_OBJECT('b', 100), 2),\n", + "\"\"\"\n", + "df = bpd.read_gbq(sql)\n", + "df" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "id Int64\n", + "struct_col struct>,...\n", + "dtype: object" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.dtypes" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Operate on `JSON` data\n", + "\n", + "The `bigframes.bigquery` module (often abbreviated as `bbq`) provides access within BigQuery DataFrames to various **[BigQuery built-in functions](https://cloud.google.com/bigquery/docs/reference/standard-sql/json_functions)**. Examples relevant for JSON data include [`json_extract`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery#bigframes_bigquery_json_extract) and [`parse_json`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery#bigframes_bigquery_parse_json)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Extract JSON data via specific JSON path" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Example 1: When JSON data is represented as strings**" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "fruits = [\n", + " '{\"fruits\": [{\"name\": \"apple\"}, {\"name\": \"cherry\"}]}',\n", + " '{\"fruits\": [{\"name\": \"guava\"}, {\"name\": \"grapes\"}]}',\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 {\"fruits\": [{\"name\": \"apple\"}, {\"name\": \"cherr...\n", + "1 {\"fruits\": [{\"name\": \"guava\"}, {\"name\": \"grape...\n", + "dtype: string" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "str_s = bpd.Series(fruits, dtype=\"string\")\n", + "str_s" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 {\"name\":\"apple\"}\n", + "1 {\"name\":\"guava\"}\n", + "dtype: string" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.json_extract(str_s, \"$.fruits[0]\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Example 2: When JSON data is stored as JSON type**" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 {\"fruits\":[{\"name\":\"apple\"},{\"name\":\"cherry\"}]}\n", + "1 {\"fruits\":[{\"name\":\"guava\"},{\"name\":\"grapes\"}]}\n", + "dtype: extension>[pyarrow]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "json_s = bpd.Series(fruits, dtype=pd.ArrowDtype(db_dtypes.JSONArrowType()))\n", + "json_s" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 {\"name\":\"apple\"}\n", + "1 {\"name\":\"guava\"}\n", + "dtype: extension>[pyarrow]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.json_extract(json_s, \"$.fruits[0]\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Extract an array from JSON data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 ['{\"name\":\"apple\"}' '{\"name\":\"cherry\"}']\n", + "1 ['{\"name\":\"guava\"}' '{\"name\":\"grapes\"}']\n", + "dtype: list>>[pyarrow]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.json_extract_array(json_s, \"$.fruits\")" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 ['{\"name\":\"apple\"}' '{\"name\":\"cherry\"}']\n", + "1 ['{\"name\":\"guava\"}' '{\"name\":\"grapes\"}']\n", + "dtype: list[pyarrow]" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.json_extract_array(str_s, \"$.fruits\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.1" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/data_types/struct.ipynb b/notebooks/data_types/struct.ipynb new file mode 100644 index 0000000000..9df0780e30 --- /dev/null +++ b/notebooks/data_types/struct.ipynb @@ -0,0 +1,483 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Struct Data Types\n", + "\n", + "In BigQuery, a [STRUCT](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#struct_type) (also known as a `record`) is a collection of ordered fields, each with a defined data type (required) and an optional field name. BigQuery DataFrames maps BigQuery `STRUCT` types to the pandas equivalent, `pandas.ArrowDtype(pa.struct())`. \n", + "\n", + "This notebook illustrates how to work with `STRUCT` columns in BigQuery DataFrames. First, let's import the required packages and perform the necessary setup below." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "import bigframes.bigquery as bbq\n", + "import pandas as pd\n", + "import pyarrow as pa" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "REGION = \"US\" # @param {type: \"string\"}\n", + "\n", + "bpd.options.display.progress_bar = None\n", + "bpd.options.bigquery.location = REGION" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create DataFrames with struct columns\n", + "\n", + "**Example 1: Creating from a list of objects**" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
NameAddress
0Alice{'City': 'New York', 'State': 'NY'}
1Bob{'City': 'San Francisco', 'State': 'CA'}
2Charlie{'City': 'Seattle', 'State': 'WA'}
\n", + "

3 rows × 2 columns

\n", + "
[3 rows x 2 columns in total]" + ], + "text/plain": [ + " Name Address\n", + "0 Alice {'City': 'New York', 'State': 'NY'}\n", + "1 Bob {'City': 'San Francisco', 'State': 'CA'}\n", + "2 Charlie {'City': 'Seattle', 'State': 'WA'}\n", + "\n", + "[3 rows x 2 columns]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "names = [\"Alice\", \"Bob\", \"Charlie\"]\n", + "addresses = [\n", + " {'City': 'New York', 'State': 'NY'},\n", + " {'City': 'San Francisco', 'State': 'CA'},\n", + " {'City': 'Seattle', 'State': 'WA'}\n", + "]\n", + "df = bpd.DataFrame({'Name': names, 'Address': addresses})\n", + "df" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Name string[pyarrow]\n", + "Address struct[pyarrow]\n", + "dtype: object" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.dtypes" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Example 2: Defining schema explicitly**" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 {'City': 'New York', 'State': 'NY'}\n", + "1 {'City': 'San Francisco', 'State': 'CA'}\n", + "2 {'City': 'Seattle', 'State': 'WA'}\n", + "dtype: struct[pyarrow]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bpd.Series(\n", + " data=addresses, \n", + " dtype=bpd.ArrowDtype(pa.struct([('City', pa.string()), ('State', pa.string())]))\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Example 3: Reading from a source**" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 [{'tables': {'score': 0.8667634129524231, 'val...\n", + "1 [{'tables': {'score': 0.9351968765258789, 'val...\n", + "2 [{'tables': {'score': 0.8572560548782349, 'val...\n", + "3 [{'tables': {'score': 0.9690881371498108, 'val...\n", + "4 [{'tables': {'score': 0.9349926710128784, 'val...\n", + "Name: predicted_default_payment_next_month, dtype: list>>[pyarrow]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bpd.read_gbq(\"bigquery-public-data.ml_datasets.credit_card_default\", max_results=5)[\"predicted_default_payment_next_month\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Operate on `STRUCT` data\n", + "\n", + "BigQuery DataFrames provides two main approaches for operating on `STRUCT` data:\n", + "\n", + "1. **[The `Series.struct` accessor](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.operations.structs.StructAccessor)**: Provides Pandas-like methods for STRUCT column manipulation.\n", + "2. **The `DataFrame.struct` accessor**: Provides Pandas-like methods for all child STRUCT columns manipulation.\n", + "3. **[BigQuery built-in functions](https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions)**: Allows you to use functions mirroring BigQuery SQL operations, available through the `bigframes.bigquery` module (abbreviated as `bbq` below), such as [`struct`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery#bigframes_bigquery_struct)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### View Data Types of Struct Fields" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "City string[pyarrow]\n", + "State string[pyarrow]\n", + "dtype: object" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df['Address'].struct.dtypes" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Access a Struct Field by Name" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 New York\n", + "1 San Francisco\n", + "2 Seattle\n", + "Name: City, dtype: string" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df['Address'].struct.field(\"City\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Extract Struct Fields into a DataFrame\n", + "\n", + "**Example 1: Using Series `.struct` accessor**" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
CityState
0New YorkNY
1San FranciscoCA
2SeattleWA
\n", + "

3 rows × 2 columns

\n", + "
[3 rows x 2 columns in total]" + ], + "text/plain": [ + " City State\n", + "0 New York NY\n", + "1 San Francisco CA\n", + "2 Seattle WA\n", + "\n", + "[3 rows x 2 columns]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df['Address'].struct.explode()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Example 2: Using DataFrame `.struct` accessor while keeping other columns**" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
NameAddress.CityAddress.State
0AliceNew YorkNY
1BobSan FranciscoCA
2CharlieSeattleWA
\n", + "

3 rows × 3 columns

\n", + "
[3 rows x 3 columns in total]" + ], + "text/plain": [ + " Name Address.City Address.State\n", + "0 Alice New York NY\n", + "1 Bob San Francisco CA\n", + "2 Charlie Seattle WA\n", + "\n", + "[3 rows x 3 columns]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.struct.explode(\"Address\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/data_types/timedelta.ipynb b/notebooks/data_types/timedelta.ipynb new file mode 100644 index 0000000000..d65c812d83 --- /dev/null +++ b/notebooks/data_types/timedelta.ipynb @@ -0,0 +1,571 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "8ebb6e6a", + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "id": "c4f3bbfa", + "metadata": {}, + "source": [ + "# BigFrames Timedelta\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "f74e2573", + "metadata": {}, + "source": [ + "In this notebook, you will use timedeltas to analyze the taxi trips in NYC. " + ] + }, + { + "cell_type": "markdown", + "id": "8f74dec4", + "metadata": {}, + "source": [ + "# Setup" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "51173665", + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "\n", + "PROJECT = \"bigframes-dev\" # replace this with your project\n", + "LOCATION = \"us\" # replace this with your location\n", + "\n", + "bpd.options.bigquery.project = PROJECT\n", + "bpd.options.bigquery.location = LOCATION\n", + "bpd.options.display.progress_bar = None\n", + "\n", + "bpd.options.bigquery.ordering_mode = \"partial\"" + ] + }, + { + "cell_type": "markdown", + "id": "d64fd3e3", + "metadata": {}, + "source": [ + "# Timedelta arithmetics and comparisons" + ] + }, + { + "cell_type": "markdown", + "id": "e10bd798", + "metadata": {}, + "source": [ + "First, you load the taxi data from the BigQuery public dataset `bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2021`. The size of this table is about 6.3 GB." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "f1b11138", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
vendor_idpickup_datetimedropoff_datetimepassenger_counttrip_distancerate_codestore_and_fwd_flagpayment_typefare_amountextramta_taxtip_amounttolls_amountimp_surchargeairport_feetotal_amountpickup_location_iddropoff_location_iddata_file_yeardata_file_month
012021-06-09 07:44:46+00:002021-06-09 07:45:24+00:0012.2000000001.0N40E-90E-90E-90E-90E-90E-90E-90E-926326320216
122021-06-07 11:59:46+00:002021-06-07 12:00:00+00:0020.0100000003.0N20E-90E-90E-90E-90E-90E-90E-90E-926326320216
222021-06-23 15:03:58+00:002021-06-23 15:04:34+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-919319320216
312021-06-12 14:26:55+00:002021-06-12 14:27:08+00:0001.0000000001.0N30E-90E-90E-90E-90E-90E-90E-90E-914314320216
422021-06-15 08:39:01+00:002021-06-15 08:40:36+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-919319320216
\n", + "
" + ], + "text/plain": [ + " vendor_id pickup_datetime dropoff_datetime \\\n", + "0 1 2021-06-09 07:44:46+00:00 2021-06-09 07:45:24+00:00 \n", + "1 2 2021-06-07 11:59:46+00:00 2021-06-07 12:00:00+00:00 \n", + "2 2 2021-06-23 15:03:58+00:00 2021-06-23 15:04:34+00:00 \n", + "3 1 2021-06-12 14:26:55+00:00 2021-06-12 14:27:08+00:00 \n", + "4 2 2021-06-15 08:39:01+00:00 2021-06-15 08:40:36+00:00 \n", + "\n", + " passenger_count trip_distance rate_code store_and_fwd_flag payment_type \\\n", + "0 1 2.200000000 1.0 N 4 \n", + "1 2 0.010000000 3.0 N 2 \n", + "2 1 0E-9 1.0 N 1 \n", + "3 0 1.000000000 1.0 N 3 \n", + "4 1 0E-9 1.0 N 1 \n", + "\n", + " fare_amount extra mta_tax tip_amount tolls_amount imp_surcharge \\\n", + "0 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "1 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "2 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "3 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "4 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "\n", + " airport_fee total_amount pickup_location_id dropoff_location_id \\\n", + "0 0E-9 0E-9 263 263 \n", + "1 0E-9 0E-9 263 263 \n", + "2 0E-9 0E-9 193 193 \n", + "3 0E-9 0E-9 143 143 \n", + "4 0E-9 0E-9 193 193 \n", + "\n", + " data_file_year data_file_month \n", + "0 2021 6 \n", + "1 2021 6 \n", + "2 2021 6 \n", + "3 2021 6 \n", + "4 2021 6 " + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "taxi_trips = bpd.read_gbq(\"bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2021\").dropna()\n", + "taxi_trips = taxi_trips[taxi_trips['pickup_datetime'].dt.year == 2021]\n", + "taxi_trips.peek(5)" + ] + }, + { + "cell_type": "markdown", + "id": "f5b13623", + "metadata": {}, + "source": [ + "Based on the dataframe content, you calculate the trip durations and store them under the column “trip_duration”. You can see that the values under \"trip_duartion\" are timedeltas." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "12fc1a5a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "duration[us][pyarrow]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "taxi_trips['trip_duration'] = taxi_trips['dropoff_datetime'] - taxi_trips['pickup_datetime']\n", + "taxi_trips['trip_duration'].dtype" + ] + }, + { + "cell_type": "markdown", + "id": "4b18b8d9", + "metadata": {}, + "source": [ + "To remove data outliers, you filter the taxi_trips to keep only the trips that were less than 2 hours." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "62b2d42e", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "taxi_trips = taxi_trips[taxi_trips['trip_duration'] <= pd.Timedelta(\"2h\")]" + ] + }, + { + "cell_type": "markdown", + "id": "665fb8a7", + "metadata": {}, + "source": [ + "Finally, you calculate the average speed of each trip, and find the median speed of all trips." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e79e23c3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The median speed of an average taxi trip is: 10.58 mph.\n" + ] + } + ], + "source": [ + "average_speed = taxi_trips[\"trip_distance\"] / (taxi_trips['trip_duration'] / pd.Timedelta(\"1h\"))\n", + "print(f\"The median speed of an average taxi trip is: {average_speed.median():.2f} mph.\")" + ] + }, + { + "cell_type": "markdown", + "id": "261dbdf1", + "metadata": {}, + "source": [ + "Given how packed NYC is, a median taxi speed of 10.58 mph totally makes sense." + ] + }, + { + "cell_type": "markdown", + "id": "6122c32e", + "metadata": {}, + "source": [ + "# Use timedelta for rolling aggregation" + ] + }, + { + "cell_type": "markdown", + "id": "05ae6fbb", + "metadata": {}, + "source": [ + "Using your existing dataset, you can now calculate the taxi trip count over a period of two days, and find out when NYC is at its busiest and when it is fast asleep.\n", + "\n", + "First, you pick two workdays (a Thursday and a Friday) as your target dates:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "7dc50b1c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Number of records: 255434\n" + ] + } + ], + "source": [ + "import datetime\n", + "\n", + "target_dates = [\n", + " datetime.date(2021, 12, 2), \n", + " datetime.date(2021, 12, 3)\n", + "]\n", + "\n", + "two_day_taxi_trips = taxi_trips[taxi_trips['pickup_datetime'].dt.date.isin(target_dates)]\n", + "print(f\"Number of records: {len(two_day_taxi_trips)}\")\n", + "# Number of records: 255434\n" + ] + }, + { + "cell_type": "markdown", + "id": "a5f39bd8", + "metadata": {}, + "source": [ + "Your next step involves aggregating the number of records associated with each unique \"pickup_datetime\" value. Additionally, the data undergo upsampling to account for any absent timestamps, which are populated with a count of 0." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f25b34f5", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "two_day_trip_count = two_day_taxi_trips['pickup_datetime'].value_counts()\n", + "\n", + "full_index = pd.date_range(\n", + " start='2021-12-02 00:00:00',\n", + " end='2021-12-04 00:00:00',\n", + " freq='s',\n", + " tz='UTC'\n", + ")\n", + "two_day_trip_count = two_day_trip_count.reindex(full_index).fillna(0)" + ] + }, + { + "cell_type": "markdown", + "id": "3394b2ba", + "metadata": {}, + "source": [ + "You'll then calculate the sum of trip counts within a 5-minute rolling window. This involves using the `rolling()` method, which can accept a time window in the form of either a string or a timedelta object." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "4d5987d8", + "metadata": {}, + "outputs": [], + "source": [ + "two_day_trip_rolling_count = two_day_trip_count.sort_index().rolling(window=\"5m\").sum()" + ] + }, + { + "cell_type": "markdown", + "id": "3811b1fb", + "metadata": {}, + "source": [ + "Finally, you visualize the trip counts throughout the target dates." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "871c32c5", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABS8AAAJeCAYAAABVkfCjAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xd4HOW5NvB7tqv3brn3hm1MN70bCC2FhDRSSE8IpJ+QL5BwEpKQQ0gjJAdySCghIRCKKaaDbdx7t+Um2ep1e5vvj5l3dmZ2V9KuVtJKvn/XlSva3dnVyKxW7zzvUyRZlmUQERERERERERERZRnLaJ8AERERERERERERUSIMXhIREREREREREVFWYvCSiIiIiIiIiIiIshKDl0RERERERERERJSVGLwkIiIiIiIiIiKirMTgJREREREREREREWUlBi+JiIiIiIiIiIgoKzF4SURERERERERERFnJNtonkI2i0SiOHz+OgoICSJI02qdDREREREREREQ0psiyjL6+PtTW1sJiST9/ksHLBI4fP476+vrRPg0iIiIiIiIiIqIx7dixY5gwYULaz2fwMoGCggIAyj9uYWHhKJ9NZoVCIbz66qu47LLLYLfbR/t0aAzge4ZSxfcMpYrvGUoV3zOUKr5nKFV8z1A6+L6hVI3390xvby/q6+u1OFu6GLxMQJSKFxYWjsvgZW5uLgoLC8flLwZlHt8zlCq+ZyhVfM9QqvieoVTxPUOp4nuG0sH3DaXqZHnPDLUlIwf2EBERERERERERUVZi8JKIiIiIiIiIiIiyEoOXRERERERERERElJUYvCQiIiIiIiIiIqKsxOAlERERERERERERZSUGL4mIiIiIiIiIiCgrMXhJREREREREREREWYnBSyIiIiIiIiIiIspKDF4SERERERERERFRVmLwkoiIiIiIiIiIiLISg5dERERERERERESUlRi8JCIiIiIiIiIioqzE4CURERERERERERFlJQYviYiIiIiIiIiIKCsxeElERERERERERERZicFLIiIiIiIiIiIiykoMXhIREREREREREVFWYvCSiIiIiIiIiIiIshKDl0RERERERERERJSVGLwkIiIiIiIiIiKirMTgJRERERERERFRFvnzOw342hObEYnKo30qRKOOwUsiIiIiIiIioixyz4rdeH7rcbyzrw2yLCPKICad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jBu0FWOmdViwBwAAAAAAAEBYovISAAAAAAAAQFgieQkAAAAAAAAgLJG8BAAAAAAAABCWSF4CAAAAAAAACEs9Nnn5xRdf6JxzzlGvXr1kMpn03//+N2C/w+HQzTffrFGjRikuLk69evXST37yE+3Zs6fJuWpqahQXF6fNmzdLkhYsWKBjjjlGUVFRGjhwoF588cWA4++77z4dd9xxSkhIUGZmpn74wx9qw4YNzcbZr18/ffrpp1qwYIHOO+885eTkKC4uTmPGjNErr7wScOyaNWt0wQUXKD8/XyaTSY8++mirvhcrV67USSedpOjoaOXm5urBBx9scsxbb72loUOHKjo6WqNGjdKcOXOOeN4dO3Zo2rRpio2NVWZmpm666SY1NDQEHHOk71VzSkpKdPnllysxMVHJycm6+uqrVVlZ2eb31FaMmUat+f6WlpZq5syZysnJUVRUlAYPHnzEcdNZP9va2lrNnDlTaWlpio+P1wUXXKCioqKAY1ozXtuKMeNWW1urq666SqNGjZLVatUPf/jDJse8/fbbOv3005WRkaHExERNnDhRH3/88RHPzZjpuWNGkl555RUdddRRio2NVU5Ojn7605/qwIEDRzx3Z/xsDcPQn//8Z+Xk5CgmJkZTpkzRpk2bAo5pzXhtq1COmaefflqjR49WYmKi79/thx9+2Gyc4fLZxPUMY8Yf1zOtw5hx43qm9RgzblzPtE0ox42/+++/XyaTSddff32z+8Pl86nHXdMYPdScOXOMW2+91Xj77bcNScY777wTsL+0tNSYMmWK8cYbbxjr1683Fi5caIwbN8449thjm5zr3XffNYYNG2YYhmFs3brViI2NNW688UZj7dq1xt///nfDYrEYH330ke/4qVOnGrNnzzZWr15tLF++3Dj77LONvLw8o7KyMuC8K1asMJKSkoz6+nrjnnvuMW677Tbj66+/NjZv3mw8+uijhtlsNt577z3f8UuWLDF+97vfGa+99pqRnZ1tPPLII0f8PpSVlRlZWVnG5Zdfbqxevdp47bXXjJiYGOOZZ57xHfP1118bFovFePDBB421a9cat912m2Gz2YxVq1a1eN6GhgZj5MiRxpQpU4xly5YZc+bMMdLT041bbrnFd0xrvlfNOfPMM42jjjrKWLRokfHll18aAwcONC699NI2vadgMGbcWvP9raurM8aOHWucffbZxldffWVs27bNWLBggbF8+fLDnruzfrbXXHONkZuba8ybN8/47rvvjAkTJhjHH3+8b39rxmswGDNulZWVxjXXXGM8++yzxtSpU43zzjuvyTHXXXed8cADDxhLliwxNm7caNxyyy2GzWYzvv/++8OemzHTc8fMV199ZZjNZuOxxx4ztm7danz55ZfGiBEjjB/96EeHPXdn/Wzvv/9+Iykpyfjvf/9rrFixwjj33HONfv36GTU1Nb5jjjRegxHKMfO///3P+OCDD4yNGzcaGzZsMP74xz8aNpvNWL16dcB5w+WziesZN8aMG9czrceYceN6pvUYM25cz7RNKMeN15IlS4z8/Hxj9OjRxnXXXddkf7h8PvXEa5oem7z019w/jOYsWbLEkGRs3749YPtPf/pT4+abbzYMwzB+//vfGyNGjAjYf/HFFxtTp05t8bzFxcWGJOPzzz8P2H7XXXcZF198cYuvO/vss40ZM2Y0u69v376t+ofx1FNPGSkpKUZdXZ1v280332wMGTLE9/yiiy4ypk2bFvC68ePHG7/85S9bPO+cOXMMs9lsFBYW+rY9/fTTRmJiou9rBfO9Wrt2rSHJ+Pbbb33bPvzwQ8NkMhm7d+9u9XtqL8bM4b+/Tz/9tNG/f3+jvr7+iOfz6qyfbWlpqWGz2Yy33nrLt23dunWGJGPhwoWGYbRuvLZXTx4z/qZPn97shVtzhg8fbtx5550t7mfMuPXUMfPQQw8Z/fv3D9j2+OOPG717927xXJ31s3W5XEZ2drbx0EMPBXytqKgo47XXXjMMo3Xjtb1CPWYMwzBSUlKMf/7znwHbwuWzieuZphgzXM+0VU8eM/64nmk9xowb1zNtE4pxU1FRYQwaNMiYO3euccoppzSbvAyXz6eeeE3TY6eNB6OsrEwmk0nJycm+bS6XS++//77OO+88SdLChQs1ZcqUgNdNnTpVCxcuPOx5JSk1NTVg+//+9z/feVt63aGvaauFCxfq5JNPlt1uD4h3w4YNOnjwoO+YI72nO+64Q/n5+QHnHTVqlLKysgJeU15erjVr1rT6vC+++KJMJlPAeZOTkzV27FjftilTpshsNmvx4sWtfk9dpaeOmf/973+aOHGiZs6cqaysLI0cOVL33nuvnE6n7zWd9bNdsGCBTCaTCgoKJElLly6Vw+EI+B4PHTpUeXl5vu9xa8ZrV4nEMRMMl8ulioqKgK/NmGleTx0zEydO1M6dOzVnzhwZhqGioiL95z//0dlnn+07prN+tgUFBTKZTFqwYIEkadu2bSosLAw4b1JSksaPHx9w3iON167SGWPG6XTq9ddfV1VVlSZOnBiwL1w+m7ieCV5PHTNczwQvEsdMMLieab2eOma4nmmfjhw3M2fO1LRp05oc6y9cPp964jUNyctWqq2t1c0336xLL71UiYmJvu2LFi2SJI0fP16SVFhYGDAYJCkrK0vl5eWqqalpcl6Xy6Xrr79eJ5xwgkaOHOnbvnv3bq1cuVJnnXVWs/G8+eab+vbbbzVjxox2va+W4vXuO9wx3v2SlJ6ergEDBnTIef2/V0lJSRoyZEjAeTMzMwNeY7ValZqaesTz+n/trtCTx8zWrVv1n//8R06nU3PmzNGf/vQn/e1vf9Nf/vIX32s662cbGxurIUOGyGaz+bbb7faADzTv6xgzXTNmgvHXv/5VlZWVuuiii3zbGDNN9eQxc8IJJ+iVV17RxRdfLLvdruzsbCUlJenJJ5/0HdNZP1ubzaYhQ4YoNjY2YPvhPitbM167QkePmVWrVik+Pl5RUVG65ppr9M4772j48OG+/eH02cT1THB68pjheiY4kTpmgsH1TOv05DHD9UzwOnLcvP766/r+++913333tfj1wunzqSde05C8bAWHw6GLLrpIhmHo6aefDtj37rvv6gc/+IHM5uC+lTNnztTq1av1+uuvB2z/3//+pxNPPLHJLyRJmj9/vmbMmKHnnntOI0aMCOrrdrRZs2Zp3rx5HX7eH/3oR1q/fn2Hn7ez9fQx43K5lJmZqWeffVbHHnusLr74Yt166636xz/+4Tums36248aN0/r169W7d+8OP3dn6uljxt+rr76qO++8U2+++WbAByFjJlBPHzNr167Vddddpz//+c9aunSpPvroIxUUFOiaa67xHdNZP9vevXtr/fr1GjduXIeet7N1xpgZMmSIli9frsWLF+vaa6/V9OnTtXbtWt/+cBozrcH1TKCePma4nmm7nj5m/HE90zo9fcxwPROcjhw3O3fu1HXXXadXXnlF0dHRLR4XTuOmNSLtmobk5RF4/1Fs375dc+fODcjoS+4BfO655/qeZ2dnN1nlq6ioSImJiYqJiQnYPmvWLL3//vuaP3+++vTpc9jzen3++ec655xz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AAAAAAAAAGGJ5CUAAAAAAACAsETyEgAAAAAAAEBYInkJAAAAAAAAICyRvAQAAAAAAAAQlkheAgAAoEVXXXWV8vPz2/w6k8mkWbNmdXxAnSjY9woAAIDOQ/ISAACgB3rxxRdlMpl8/0VHR2vw4MGaNWuWioqKQh1eh/F/j4f7b8GCBaEOFQAAAM2whjoAAAAAhM5dd92lfv36qba2Vl999ZWefvppzZkzR6tXr1ZsbKyee+45uVyuUIcZtJdffjng+UsvvaS5c+c22T5s2LBu/14BAAAiEclLAACAHuyss87S2LFjJUk/+9nPlJaWpocffljvvvuuLr30UtlsthBH2D5XXHFFwPNFixZp7ty5TbYDAAAgPDFtHAAAAD6TJ0+WJG3btk1S830gXS6XHnvsMY0aNUrR0dHKyMjQmWeeqe++++6w5/7LX/4is9msv//975Kk/Px8XXXVVU2OmzRpkiZNmuR7vmDBAplMJr3xxhv64x//qOzsbMXFxencc8/Vzp07g3+zhzj0vRYUFMhkMumvf/2rnnzySfXv31+xsbE644wztHPnThmGobvvvlt9+vRRTEyMzjvvPJWUlDQ574cffqiTTjpJcXFxSkhI0LRp07RmzZoOixsAACCSUXkJAAAAny1btkiS0tLSWjzm6quv1osvvqizzjpLP/vZz9TQ0KAvv/xSixYt8lVxHuq2227Tvffeq2eeeUY///nPg4rtnnvukclk0s0336zi4mI9+uijmjJlipYvX66YmJigztkar7zyiurr6/XrX/9aJSUlevDBB3XRRRdp8uTJWrBggW6++WZt3rxZf//73/W73/1OL7zwgu+1L7/8sqZPn66pU6fqgQceUHV1tZ5++mmdeOKJWrZsGQsEAQAAHAHJSwAAgB6srKxM+/fvV21trb7++mvdddddiomJ0Q9+8INmj58/f75efPFF/eY3v9Fjjz3m2/7b3/5WhmE0+5rf/e53euSRRzR79mxNnz496FhLSkq0bt06JSQkSJKOOeYYXXTRRXruuef0m9/8JujzHsnu3bu1adMmJSUlSZKcTqfuu+8+1dTU6LvvvpPV6r6k3rdvn1555RU9/fTTioqKUmVlpX7zm9/oZz/7mZ599lnf+aZPn64hQ4bo3nvvDdgOAACAppg2DgAA0INNmTJFGRkZys3N1SWXXKL4+Hi988476t27d7PH/9///Z9MJpNuv/32JvtMJlPAc8MwNGvWLD322GP697//3a7EpST95Cc/8SUuJenCCy9UTk6O5syZ067zHsmPf/xjX+JSksaPHy/J3U/Tm7j0bq+vr9fu3bslSXPnzlVpaakuvfRS7d+/3/efxWLR+PHjNX/+/E6NGwAAIBJQeQkAANCDPfnkkxo8eLCsVquysrI0ZMgQmc0t39/esmWLevXqpdTU1COe+6WXXlJlZaWefvppXXrppe2OddCgQQHPTSaTBg4cqIKCgnaf+3Dy8vICnnsTmbm5uc1uP3jwoCRp06ZNkhr7iB4qMTGxQ+MEAACIRCQvAQAAerBx48a12KeyvU444QQtX75cTzzxhC666KImCc9DKzW9nE6nLBZLp8QUjJZiaWm7d/q8y+WS5O57mZ2d3eQ4/6pNAAAANI8rJgAAALTagAED9PHHH6ukpOSI1ZcDBw7Ugw8+qEmTJunMM8/UvHnzAqZ9p6SkqLS0tMnrtm/frv79+zfZ7q1k9DIMQ5s3b9bo0aODezOdbMCAAZKkzMxMTZkyJcTRAAAAdE/0vAQAAECrXXDBBTIMQ3feeWeTfc0t2DN69GjNmTNH69at0znnnKOamhrfvgEDBmjRokWqr6/3bXv//fe1c+fOZr/2Sy+9pIqKCt/z//znP9q7d6/OOuus9rylTjN16lQlJibq3nvvlcPhaLJ/3759IYgKAACge6HyEgAAAK126qmn6sorr9Tjjz+uTZs26cwzz5TL5dKXX36pU089VbNmzWrymgkTJujdd9/V2WefrQsvvFD//e9/ZbPZ9LOf/Uz/+c9/dOaZZ+qiiy7Sli1b9O9//9tXsXio1NRUnXjiiZoxY4aKior06KOPauDAgfr5z3/e2W87KImJiXr66ad15ZVX6phjjtEll1yijIwM7dixQx988IFOOOEEPfHEE6EOEwAAIKxReQkAAIA2mT17th566CFt27ZNN910k+69917V1NTo+OOPb/E1kydP1ptvvqlPPvlEV155pVwul6ZOnaq//e1v2rhxo66//notXLhQ77//vvr06dPsOf74xz9q2rRpuu+++/TYY4/ptNNO07x58xQbG9tZb7XdLrvsMs2bN0+9e/fWQw89pOuuu06vv/66xowZoxkzZoQ6PAAAgLBnMpqb3wMAAACEiQULFujUU0/VW2+9pQsvvDDU4QAAAKALUXkJAAAAAAAAICyRvAQAAAAAAAAQlkheAgAAAAAAAAhL9LwEAAAAAAAAEJaovAQAAAAAAAAQlkheAgAAAAAAAAhLJC8BAAAAAAAAhCWSlwAAAAAAAADCEslLAAAAAAAAAGGJ5CUAAAAAAACAsETyEgAAAAAAAEBYInkJAAAAAAAAICyRvAQAAAAAAAAQlv4fHTWyf+ju86YAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib import pyplot as plt\n", + "import matplotlib.dates as mdates\n", + "\n", + "plt.figure(figsize=(16,7))\n", + "ax = plt.gca()\n", + "formatter = mdates.DateFormatter(\"%D %H:%M:%S\")\n", + "ax.xaxis.set_major_formatter(formatter)\n", + "\n", + "ax.tick_params(axis='both', labelsize=10)\n", + "\n", + "two_day_trip_rolling_count.plot(ax=ax, legend=False)\n", + "plt.xlabel(\"Pickup Time\", fontsize=12)\n", + "plt.ylabel(\"Trip count in last 5 minutes\", fontsize=12)\n", + "plt.grid()\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "336b78ce", + "metadata": {}, + "source": [ + "The taxi ride count reached its lowest point around 5:00 a.m., and peaked around 7:00 p.m. on a workday. Such is the rhythm of NYC." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb new file mode 100644 index 0000000000..facefc6069 --- /dev/null +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -0,0 +1,1014 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "d10bfca4", + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "id": "acca43ae", + "metadata": {}, + "source": [ + "# Demo to Show Anywidget mode" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ca22f059", + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd" + ] + }, + { + "cell_type": "markdown", + "id": "04406a4d", + "metadata": {}, + "source": [ + "This notebook demonstrates the **anywidget** display mode for BigQuery DataFrames. This mode provides an interactive table experience for exploring your data directly within the notebook.\n", + "\n", + "**Key features:**\n", + "- **Rich DataFrames & Series:** Both DataFrames and Series are displayed as interactive widgets.\n", + "- **Pagination:** Navigate through large datasets page by page without overwhelming the output.\n", + "- **Column Sorting:** Click column headers to toggle between ascending, descending, and unsorted views.\n", + "- **Column Resizing:** Drag the dividers between column headers to adjust their width." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "1bc5aaf3", + "metadata": {}, + "outputs": [], + "source": [ + "bpd.options.bigquery.ordering_mode = \"partial\"\n", + "bpd.options.display.repr_mode = \"anywidget\"" + ] + }, + { + "cell_type": "markdown", + "id": "0a354c69", + "metadata": {}, + "source": [ + "Load Sample Data" + ] + }, + { + "cell_type": "markdown", + "id": "interactive-df-header", + "metadata": {}, + "source": [ + "## 1. Interactive DataFrame Display\n", + "Loading a dataset from BigQuery automatically renders the interactive widget." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "f289d250", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "state gender year name number\n", + " AL F 1910 Vera 71\n", + " AR F 1910 Viola 37\n", + " AR F 1910 Alice 57\n", + " AR F 1910 Edna 95\n", + " AR F 1910 Ollie 40\n", + " CA F 1910 Beatrice 37\n", + " CT F 1910 Marion 36\n", + " CT F 1910 Marie 36\n", + " FL F 1910 Alice 53\n", + " GA F 1910 Thelma 133\n", + "...\n", + "\n", + "[5552452 rows x 5 columns]\n" + ] + } + ], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.usa_names.usa_1910_2013\")\n", + "print(df)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "220340b0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "424cfa14088641518224b137b5444d58", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
stategenderyearnamenumber
0ALF1910Vera71
1ARF1910Viola37
2ARF1910Alice57
3ARF1910Edna95
4ARF1910Ollie40
5CAF1910Beatrice37
6CTF1910Marion36
7CTF1910Marie36
8FLF1910Alice53
9GAF1910Thelma133
\n", + "

10 rows × 5 columns

\n", + "
[5552452 rows x 5 columns in total]" + ], + "text/plain": [ + "state gender year name number\n", + " AL F 1910 Vera 71\n", + " AR F 1910 Viola 37\n", + " AR F 1910 Alice 57\n", + " AR F 1910 Edna 95\n", + " AR F 1910 Ollie 40\n", + " CA F 1910 Beatrice 37\n", + " CT F 1910 Marion 36\n", + " CT F 1910 Marie 36\n", + " FL F 1910 Alice 53\n", + " GA F 1910 Thelma 133\n", + "...\n", + "\n", + "[5552452 rows x 5 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df" + ] + }, + { + "cell_type": "markdown", + "id": "3a73e472", + "metadata": {}, + "source": [ + "## 2. Interactive Series Display\n", + "BigQuery DataFrames `Series` objects now also support the full interactive widget experience, including pagination and formatting." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "42bb02ab", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "3904868f71114a0c95c8c133a6c29d0b", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
0    1910\n",
+       "1    1910\n",
+       "2    1910\n",
+       "3    1910\n",
+       "4    1910\n",
+       "5    1910\n",
+       "6    1910\n",
+       "7    1910\n",
+       "8    1910\n",
+       "9    1910
[5552452 rows]" + ], + "text/plain": [ + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "Name: year, dtype: Int64\n", + "...\n", + "\n", + "[5552452 rows]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_series = df[\"year\"]\n", + "# Displaying the series triggers the interactive widget\n", + "test_series" + ] + }, + { + "cell_type": "markdown", + "id": "7bcf1bb7", + "metadata": {}, + "source": [ + "Display with Pagination" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "da23e0f3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "0fd0bd56db2348a68d5755a045652001", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
0    1910\n",
+       "1    1910\n",
+       "2    1910\n",
+       "3    1910\n",
+       "4    1910\n",
+       "5    1910\n",
+       "6    1910\n",
+       "7    1910\n",
+       "8    1910\n",
+       "9    1910
[5552452 rows]" + ], + "text/plain": [ + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "Name: year, dtype: Int64\n", + "...\n", + "\n", + "[5552452 rows]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_series" + ] + }, + { + "cell_type": "markdown", + "id": "sorting-intro", + "metadata": {}, + "source": [ + "### Sorting by Single-Column\n", + "You can sort the table by clicking on the headers of columns that have orderable data types (like numbers, strings, and dates). Non-orderable columns (like arrays or structs) do not have sorting controls.\n", + "\n", + "**Sorting indicators (▲, ▼) are always visible for sorted columns. The unsorted indicator (●) is only visible when you hover over an unsorted column header.** The sorting control cycles through three states:\n", + "- **Unsorted (no indicator by default, ● on hover):** The default state. Click the header to sort in ascending order.\n", + "- **Ascending (▲):** The data is sorted from smallest to largest. Click again to sort in descending order.\n", + "- **Descending (▼):** The data is sorted from largest to smallest. Click again to return to the unsorted state." + ] + }, + { + "cell_type": "markdown", + "id": "adjustable-width-intro", + "metadata": {}, + "source": [ + "### Adjustable Column Widths\n", + "You can easily adjust the width of any column in the table. Simply hover your mouse over the vertical dividers between column headers. When the cursor changes to a resize icon, click and drag to expand or shrink the column to your desired width. This allows for better readability and customization of your table view." + ] + }, + { + "cell_type": "markdown", + "id": "bb15bab6", + "metadata": {}, + "source": [ + "Programmatic Navigation Demo" + ] + }, + { + "cell_type": "markdown", + "id": "programmatic-header", + "metadata": {}, + "source": [ + "## 3. Programmatic Widget Control\n", + "You can also instantiate the `TableWidget` directly for more control, such as checking page counts or driving navigation programmatically." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "6920d49b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Total pages: 555246\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "13b063f7ea74473eb18de270c48c6417", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from bigframes.display.anywidget import TableWidget\n", + "import math\n", + " \n", + "# Create widget programmatically \n", + "widget = TableWidget(df)\n", + "print(f\"Total pages: {math.ceil(widget.row_count / widget.page_size)}\")\n", + " \n", + "# Display the widget\n", + "widget" + ] + }, + { + "cell_type": "markdown", + "id": "02cbd1be", + "metadata": {}, + "source": [ + "Test Navigation Programmatically" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "12b68f15", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Current page: 0\n", + "After next: 1\n", + "After prev: 0\n" + ] + } + ], + "source": [ + "# Simulate button clicks programmatically\n", + "print(\"Current page:\", widget.page)\n", + "\n", + "# Go to next page\n", + "widget.page = 1\n", + "print(\"After next:\", widget.page)\n", + "\n", + "# Go to previous page\n", + "widget.page = 0\n", + "print(\"After prev:\", widget.page)" + ] + }, + { + "cell_type": "markdown", + "id": "9d310138", + "metadata": {}, + "source": [ + "## 4. Edge Cases\n", + "The widget handles small datasets gracefully, disabling unnecessary pagination controls." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "a9d5d13a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 171.4 MB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Small dataset pages: 1\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "0918149d2d734296afb3243f283eb2d3", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Test with very small dataset\n", + "small_df = df.sort_values([\"name\", \"year\", \"state\"]).head(5)\n", + "small_widget = TableWidget(small_df)\n", + "print(f\"Small dataset pages: {math.ceil(small_widget.row_count / small_widget.page_size)}\")\n", + "small_widget" + ] + }, + { + "cell_type": "markdown", + "id": "added-cell-2", + "metadata": {}, + "source": [ + "### Displaying Generative AI results containing JSON\n", + "The `AI.GENERATE` function in BigQuery returns results in a JSON column. While BigQuery's JSON type is not natively supported by the underlying Arrow `to_pandas_batches()` method used in anywidget mode ([Apache Arrow issue #45262](https://github.com/apache/arrow/issues/45262)), BigQuery Dataframes automatically converts JSON columns to strings for display. This allows you to view the results of generative AI functions seamlessly." + ] + }, + { + "cell_type": "markdown", + "id": "ai-header", + "metadata": {}, + "source": [ + "## 5. Advanced Data Types (JSON/Structs)\n", + "The `AI.GENERATE` function in BigQuery returns results in a JSON column. BigQuery Dataframes automatically handles complex types like JSON strings for display, allowing you to view generative AI results seamlessly." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "added-cell-1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 85.9 kB in 24 seconds of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:987: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:987: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:987: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "9543a0ef6eb744f480e49d4876c31b84", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
resultgcs_pathissuerlanguagepublication_dateclass_internationalclass_usapplication_numberfiling_datepriority_date_eurepresentative_line_1_euapplicant_line_1inventor_line_1title_line_1number
0{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE29.08.018E04H 6/12<NA>18157874.121.02.201822.02.2017Liedtke & Partner PatentanwälteSHB Hebezeugbau GmbHVOLGER, AlexanderSTEUERUNGSSYSTEM FÜR AUTOMATISCHE PARKHÄUSEREP 3 366 869 A1
1{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE03.10.2018H01L 21/20<NA>18166536.516.02.2016<NA>Scheider, Sascha et alEV Group E. Thallner GmbHKurz, FlorianVORRICHTUNG ZUM BONDEN VON SUBSTRATENEP 3 382 744 A1
2{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE03.10.2018G06F 11/30<NA>18157347.819.02.201831.03.2017Hoffmann EitleFUJITSU LIMITEDKukihara, KensukeMETHOD EXECUTED BY A COMPUTER, INFORMATION PRO...EP 3 382 553 A1
3{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE03.10.2018A01K 31/00<NA>18171005.405.02.201505.02.2014Stork Bamberger PatentanwälteLinco Food Systems A/SThrane, UffeMASTHÄHNCHENCONTAINER ALS BESTANDTEIL EINER E...EP 3 381 276 A1
4{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE03.10.2018H05B 6/12<NA>18165514.303.04.201830.03.2017<NA>BSH Hausger√§te GmbHAcero Acero, JesusVORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNGEP 3 383 141 A2
\n", + "

5 rows × 15 columns

\n", + "
[5 rows x 15 columns in total]" + ], + "text/plain": [ + " result \\\n", + "0 {'application_number': None, 'class_internatio... \n", + "1 {'application_number': None, 'class_internatio... \n", + "2 {'application_number': None, 'class_internatio... \n", + "3 {'application_number': None, 'class_internatio... \n", + "4 {'application_number': None, 'class_internatio... \n", + "\n", + " gcs_path issuer language \\\n", + "0 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "1 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "2 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "3 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "4 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "\n", + " publication_date class_international class_us application_number \\\n", + "0 29.08.018 E04H 6/12 18157874.1 \n", + "1 03.10.2018 H01L 21/20 18166536.5 \n", + "2 03.10.2018 G06F 11/30 18157347.8 \n", + "3 03.10.2018 A01K 31/00 18171005.4 \n", + "4 03.10.2018 H05B 6/12 18165514.3 \n", + "\n", + " filing_date priority_date_eu representative_line_1_eu \\\n", + "0 21.02.2018 22.02.2017 Liedtke & Partner Patentanw√§lte \n", + "1 16.02.2016 Scheider, Sascha et al \n", + "2 19.02.2018 31.03.2017 Hoffmann Eitle \n", + "3 05.02.2015 05.02.2014 Stork Bamberger Patentanw√§lte \n", + "4 03.04.2018 30.03.2017 \n", + "\n", + " applicant_line_1 inventor_line_1 \\\n", + "0 SHB Hebezeugbau GmbH VOLGER, Alexander \n", + "1 EV Group E. Thallner GmbH Kurz, Florian \n", + "2 FUJITSU LIMITED Kukihara, Kensuke \n", + "3 Linco Food Systems A/S Thrane, Uffe \n", + "4 BSH Hausger√§te GmbH Acero Acero, Jesus \n", + "\n", + " title_line_1 number \n", + "0 STEUERUNGSSYSTEM F√úR AUTOMATISCHE PARKH√ÑUSER EP 3 366 869 A1 \n", + "1 VORRICHTUNG ZUM BONDEN VON SUBSTRATEN EP 3 382 744 A1 \n", + "2 METHOD EXECUTED BY A COMPUTER, INFORMATION PRO... EP 3 382 553 A1 \n", + "3 MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E... EP 3 381 276 A1 \n", + "4 VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG EP 3 383 141 A2 \n", + "\n", + "[5 rows x 15 columns]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bpd._read_gbq_colab(\"\"\"\n", + " SELECT\n", + " AI.GENERATE(\n", + " prompt=>(\\\"Extract the values.\\\", OBJ.GET_ACCESS_URL(OBJ.FETCH_METADATA(OBJ.MAKE_REF(gcs_path, \\\"us.conn\\\")), \\\"r\\\")),\n", + " connection_id=>\\\"bigframes-dev.us.bigframes-default-connection\\\",\n", + " output_schema=>\\\"publication_date string, class_international string, application_number string, filing_date string\\\") AS result,\n", + " *\n", + " FROM `bigquery-public-data.labeled_patents.extracted_data`\n", + " LIMIT 5;\n", + "\"\"\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/dataframes/index_col_null.ipynb b/notebooks/dataframes/index_col_null.ipynb index de373050fe..655745dd2b 100644 --- a/notebooks/dataframes/index_col_null.ipynb +++ b/notebooks/dataframes/index_col_null.ipynb @@ -38,23 +38,15 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "96757c59-fc22-420e-a42f-c6cb956110ec", "metadata": {}, "outputs": [], "source": [ - "import warnings\n", - "\n", "import bigframes.enums\n", "import bigframes.exceptions\n", "import bigframes.pandas as bpd\n", "\n", - "# Explicitly opt-in to the NULL index preview feature.\n", - "warnings.simplefilter(\n", - " \"ignore\",\n", - " bigframes.exceptions.NullIndexPreviewWarning,\n", - ")\n", - "\n", "df = bpd.read_gbq(\n", " \"bigquery-public-data.baseball.schedules\",\n", " index_col=bigframes.enums.DefaultIndexKind.NULL,\n", diff --git a/notebooks/dataframes/integrations.ipynb b/notebooks/dataframes/integrations.ipynb index 9edb174f18..8c7790b1ea 100644 --- a/notebooks/dataframes/integrations.ipynb +++ b/notebooks/dataframes/integrations.ipynb @@ -66,9 +66,21 @@ "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/google/home/swast/src/bigframes-2/bigframes/core/global_session.py:113: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", - " return func(get_global_session(), *args, **kwargs)\n" + "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/global_session.py:103: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", + " _global_session = bigframes.session.connect(\n" ] + }, + { + "data": { + "text/html": [ + "Query job 1772ca28-2ef5-425c-87fe-8227aeb9318c is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" } ], "source": [ @@ -96,13 +108,13 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "Query job eb7f3bbe-dda9-4d2f-b195-21de862d7055 is DONE. 0 Bytes processed. Open Job" + "Query job 33bd5814-b594-4ec4-baba-8f6b6e285e48 is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -128,13 +140,13 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "Query job 4ad50c3c-91d0-4fef-91f6-0a2c5a30c38f is DONE. 0 Bytes processed. Open Job" + "Query job 1594d97a-1203-4c28-8730-caffb3ac4e9e is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -146,10 +158,10 @@ { "data": { "text/plain": [ - "'swast-scratch._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bqdf20240710_sessionf75568_9a045ff143db4f8ab2018994287020f3'" + "'bigframes-dev._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bqdf20250530_session9fdc39_7578d5bd9949422599ccb9e4fe6451be'" ] }, - "execution_count": 7, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -172,13 +184,13 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "Query job 9e7d4b1a-d7fc-4599-bab4-40062c83288e is DONE. 0 Bytes processed. Open Job" + "Query job 8afc1538-9779-487a-a063-def5f438ee11 is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -192,11 +204,11 @@ "output_type": "stream", "text": [ " index int_col float_col string_col\n", - "0 3 4 -0.1250 d\n", - "1 1 2 -0.5000 b\n", + "0 1 2 -0.5000 b\n", + "1 2 3 0.2500 c\n", "2 0 1 1.0000 a\n", - "3 4 5 0.0625 e\n", - "4 2 3 0.2500 c\n" + "3 3 4 -0.1250 d\n", + "4 4 5 0.0625 e\n" ] } ], @@ -238,13 +250,13 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "Query job 62db313e-7632-4dbb-8eff-5035d0e6c27e is DONE. 0 Bytes processed. Open Job" + "Query job b6f68a49-5129-448d-bca3-62a23dced10d is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -258,11 +270,11 @@ "output_type": "stream", "text": [ " index int_col float_col string_col\n", - "0 1 2 -0.5000 b\n", - "1 3 4 -0.1250 d\n", - "2 0 1 1.0000 a\n", - "3 4 5 0.0625 e\n", - "4 2 3 0.2500 c\n" + "0 3 4 -0.1250 d\n", + "1 1 2 -0.5000 b\n", + "2 4 5 0.0625 e\n", + "3 2 3 0.2500 c\n", + "4 0 1 1.0000 a\n" ] } ], @@ -274,7 +286,7 @@ " table_id = df.to_gbq()\n", "\n", " bqclient = df.bqclient\n", - " token = bqclient._credentials.token\n", + " token = bqclient._http.credentials.token\n", " project_id = bqclient.project\n", "\n", " share_table_and_start_workload(table_id, token, project_id)\n", @@ -335,13 +347,13 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "Query job 1cbd8898-97c7-419e-87af-b72a9432afb6 is DONE. 0 Bytes processed. Open Job" + "Query job 0f205180-cf26-46e5-950d-109947b7f5a1 is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -353,10 +365,10 @@ { "data": { "text/plain": [ - "'swast-scratch._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bqdf20240710_sessionf75568_58b9b6fc0c3349bf8d3dd6fb29ab5322'" + "'bigframes-dev._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bqdf20250530_session9fdc39_240520e0723548f18fd3bd5d24cbbf82'" ] }, - "execution_count": 9, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -378,13 +390,13 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "Query job 40e54aa9-fad7-47c3-9bec-144f6c7106d8 is DONE. 0 Bytes processed. Open Job" + "Query job 80177f9a-4f6e-4a4e-97db-f119ea686c62 is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -396,10 +408,10 @@ { "data": { "text/plain": [ - "'swast-scratch._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bqdf20240710_sessionf75568_cdb4f54063b0417a8309c462b70239fa'" + "'bigframes-dev._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bqdf20250530_session9fdc39_4ca41d2f28f84feca1bbafe9304fd89f'" ] }, - "execution_count": 10, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -427,16 +439,16 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "Dataset(DatasetReference('swast-scratch', 'my_dataset'))" + "Dataset(DatasetReference('bigframes-dev', 'my_dataset'))" ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -451,33 +463,9 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "metadata": {}, "outputs": [ - { - "data": { - "text/html": [ - "Query job 73cf9e04-d5fa-4765-827c-665f0e6b9e00 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job b177eb37-197f-4732-8978-c74cccb36e01 is DONE. 270 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -593,7 +581,7 @@ "[10 rows x 3 columns]" ] }, - "execution_count": 12, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -683,7 +671,7 @@ ], "metadata": { "kernelspec": { - "display_name": "bigframes", + "display_name": "venv", "language": "python", "name": "python3" }, @@ -697,7 +685,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.9" + "version": "3.12.10" } }, "nbformat": 4, diff --git a/notebooks/dataframes/pypi.ipynb b/notebooks/dataframes/pypi.ipynb index a62bd45768..b1196cca8b 100644 --- a/notebooks/dataframes/pypi.ipynb +++ b/notebooks/dataframes/pypi.ipynb @@ -27,12 +27,7 @@ "source": [ "# Analyzing package downloads from PyPI with BigQuery DataFrames\n", "\n", - "In this notebook, you'll use the [PyPI public dataset](https://console.cloud.google.com/marketplace/product/gcp-public-data-pypi/pypi) and the [deps.dev public dataset](https://deps.dev/) to visualize Python package downloads for a package and its dependencies.\n", - "\n", - "> **⚠ Important**\n", - ">\n", - "> You'll use features that are currently in [preview](https://cloud.google.com/blog/products/gcp/google-cloud-gets-simplified-product-launch-stages): `ordering_mode=\"partial\"` and \"NULL\" indexes. There may be breaking changes to this functionality in future versions of the BigQuery DataFrames package.\n", - "\n" + "In this notebook, you'll use the [PyPI public dataset](https://console.cloud.google.com/marketplace/product/gcp-public-data-pypi/pypi) and the [deps.dev public dataset](https://deps.dev/) to visualize Python package downloads for a package and its dependencies." ] }, { @@ -53,33 +48,11 @@ "source": [ "import bigframes.pandas as bpd\n", "\n", - "# Preview feature warning:\n", "# Use `ordering_mode=\"partial\"` for more efficient query generation, but\n", "# some pandas-compatible methods may not be possible without a total ordering.\n", "bpd.options.bigquery.ordering_mode = \"partial\"" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Filter out the relevant warnings for preview features used." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "import warnings\n", - "\n", - "import bigframes.exceptions\n", - "\n", - "warnings.simplefilter(\"ignore\", category=bigframes.exceptions.NullIndexPreviewWarning)\n", - "warnings.simplefilter(\"ignore\", category=bigframes.exceptions.OrderingModePartialPreviewWarning)" - ] - }, { "cell_type": "markdown", "metadata": {}, diff --git a/notebooks/experimental/ai_operators.ipynb b/notebooks/experimental/ai_operators.ipynb new file mode 100644 index 0000000000..e24ec34d86 --- /dev/null +++ b/notebooks/experimental/ai_operators.ipynb @@ -0,0 +1,65 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "id": "UYeZd_I8iouP" + }, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "rWJnGj2ViouP" + }, + "source": [ + "All AI functions have moved to the `bigframes.bigquery.ai` module.\n", + "\n", + "The tutorial notebook for AI functions is located at https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/ai_functions.ipynb\n", + "\n", + "For `ai.forecast`, see https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb" + ] + } + ], + "metadata": { + "colab": { + "include_colab_link": true, + "provenance": [] + }, + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.17" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/notebooks/experimental/longer_ml_demo.ipynb b/notebooks/experimental/longer_ml_demo.ipynb deleted file mode 100644 index 793ff58ecd..0000000000 --- a/notebooks/experimental/longer_ml_demo.ipynb +++ /dev/null @@ -1,1925 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "id": "71fbfc47", - "metadata": {}, - "source": [ - "**Note: this notebook requires changes not yet checked in**\n", - "\n", - "# Introduction\n", - "\n", - "This is a prototype for how a minimal SKLearn-like wrapper for BQML might work in BigQuery DataFrames.\n", - "\n", - "Disclaimer - this is not a polished design or a robust implementation, this is a quick prototype to workshop some ideas. Design will be next.\n", - "\n", - "What is BigQuery DataFrame?\n", - "- Pandas API for BigQuery\n", - "- Lets data scientists quickly iterate and prepare their data as they do in Pandas, but executed by BigQuery\n", - "\n", - "What is meant by SKLearn-like?\n", - "- Follow the API design practices from the SKLearn project\n", - " - [API design for machine learning software: experiences from the scikit-learn project](https://arxiv.org/pdf/1309.0238.pdf)\n", - "- Not a copy of, or compatible with, SKLearn\n", - "\n", - "Briefly, patterns taken from SKLearn are:\n", - "- Models and transforms are 'Estimators'\n", - " - A bundle of parameters with a consistent way to initialize/get/set\n", - " - And a .fit(..) method to fit to training data\n", - "- Models additionally have a .predict(..)\n", - "- By default, these objects are transient, making them easy to play around with. No need to give them names or decide how to persist them.\n", - "\n", - "\n", - "Design goals:\n", - "- Zero friction ML capabilities for BigQuery DataFrames users (no extra auth, configuration, etc)\n", - "- Offers first class integration with the Pandas-like BigQuery DataFrames API\n", - "- Uses SKLearn-like design patterns that feel familiar to data scientists\n", - "- Also a first class BigQuery experience\n", - " - Offers BigQuery's scalability and storage / compute management\n", - " - Works naturally with BigQuery's other interfaces, e.g. GUI and SQL\n", - " - BQML features" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "345c2163", - "metadata": {}, - "source": [ - "# Linear regression tutorial\n", - "\n", - "Adapted from the \"Penguin weight\" Linear Regression tutorial for BQML: https://cloud.google.com/bigquery-ml/docs/linear-regression-tutorial\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "03c9e168", - "metadata": {}, - "source": [ - "## Setting the scene\n", - "\n", - "Our conservationists have sent us some measurements of penguins found in the Antarctic islands. They say that some of the body mass measurements for the Adelie penguins are missing, and ask if we can use some data science magic to estimate them. Sounds like a job for a linear regression!\n", - "\n", - "Lets take a look at the data..." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "d7a03de2-c0ef-4f80-9cd5-f96e87cf2d54", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
tag_numberspeciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
01225Gentoo penguin (Pygoscelis papua)Biscoe<NA><NA><NA><NA><NA>
11278Gentoo penguin (Pygoscelis papua)Biscoe42.013.5210.04150.0FEMALE
21275Gentoo penguin (Pygoscelis papua)Biscoe46.513.5210.04550.0FEMALE
31233Gentoo penguin (Pygoscelis papua)Biscoe43.314.0208.04575.0FEMALE
41311Gentoo penguin (Pygoscelis papua)Biscoe47.514.0212.04875.0FEMALE
51316Gentoo penguin (Pygoscelis papua)Biscoe49.114.5212.04625.0FEMALE
61313Gentoo penguin (Pygoscelis papua)Biscoe45.514.5212.04750.0FEMALE
71381Gentoo penguin (Pygoscelis papua)Biscoe47.614.5215.05400.0MALE
81377Gentoo penguin (Pygoscelis papua)Biscoe45.114.5207.05050.0FEMALE
91380Gentoo penguin (Pygoscelis papua)Biscoe45.114.5215.05000.0FEMALE
101257Gentoo penguin (Pygoscelis papua)Biscoe46.214.5209.04800.0FEMALE
111336Gentoo penguin (Pygoscelis papua)Biscoe46.514.5213.04400.0FEMALE
121237Gentoo penguin (Pygoscelis papua)Biscoe43.214.5208.04450.0FEMALE
131302Gentoo penguin (Pygoscelis papua)Biscoe48.515.0219.04850.0FEMALE
141325Gentoo penguin (Pygoscelis papua)Biscoe49.115.0228.05500.0MALE
151285Gentoo penguin (Pygoscelis papua)Biscoe47.515.0218.04950.0FEMALE
161242Gentoo penguin (Pygoscelis papua)Biscoe49.615.0216.04750.0MALE
171246Gentoo penguin (Pygoscelis papua)Biscoe47.715.0216.04750.0FEMALE
181320Gentoo penguin (Pygoscelis papua)Biscoe45.515.0220.05000.0MALE
191244Gentoo penguin (Pygoscelis papua)Biscoe46.415.0216.04700.0FEMALE
\n", - "
[347 rows x 8 columns in total]" - ], - "text/plain": [ - " tag_number species island culmen_length_mm \\\n", - "0 1225 Gentoo penguin (Pygoscelis papua) Biscoe \n", - "1 1278 Gentoo penguin (Pygoscelis papua) Biscoe 42.0 \n", - "2 1275 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 \n", - "3 1233 Gentoo penguin (Pygoscelis papua) Biscoe 43.3 \n", - "4 1311 Gentoo penguin (Pygoscelis papua) Biscoe 47.5 \n", - "5 1316 Gentoo penguin (Pygoscelis papua) Biscoe 49.1 \n", - "6 1313 Gentoo penguin (Pygoscelis papua) Biscoe 45.5 \n", - "7 1381 Gentoo penguin (Pygoscelis papua) Biscoe 47.6 \n", - "8 1377 Gentoo penguin (Pygoscelis papua) Biscoe 45.1 \n", - "9 1380 Gentoo penguin (Pygoscelis papua) Biscoe 45.1 \n", - "10 1257 Gentoo penguin (Pygoscelis papua) Biscoe 46.2 \n", - "11 1336 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 \n", - "12 1237 Gentoo penguin (Pygoscelis papua) Biscoe 43.2 \n", - "13 1302 Gentoo penguin (Pygoscelis papua) Biscoe 48.5 \n", - "14 1325 Gentoo penguin (Pygoscelis papua) Biscoe 49.1 \n", - "15 1285 Gentoo penguin (Pygoscelis papua) Biscoe 47.5 \n", - "16 1242 Gentoo penguin (Pygoscelis papua) Biscoe 49.6 \n", - "17 1246 Gentoo penguin (Pygoscelis papua) Biscoe 47.7 \n", - "18 1320 Gentoo penguin (Pygoscelis papua) Biscoe 45.5 \n", - "19 1244 Gentoo penguin (Pygoscelis papua) Biscoe 46.4 \n", - "20 1390 Gentoo penguin (Pygoscelis papua) Biscoe 50.7 \n", - "21 1379 Gentoo penguin (Pygoscelis papua) Biscoe 47.8 \n", - "22 1267 Gentoo penguin (Pygoscelis papua) Biscoe 50.1 \n", - "23 1389 Gentoo penguin (Pygoscelis papua) Biscoe 47.2 \n", - "24 1269 Gentoo penguin (Pygoscelis papua) Biscoe 49.6 \n", - "\n", - " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "0 \n", - "1 13.5 210.0 4150.0 FEMALE \n", - "2 13.5 210.0 4550.0 FEMALE \n", - "3 14.0 208.0 4575.0 FEMALE \n", - "4 14.0 212.0 4875.0 FEMALE \n", - "5 14.5 212.0 4625.0 FEMALE \n", - "6 14.5 212.0 4750.0 FEMALE \n", - "7 14.5 215.0 5400.0 MALE \n", - "8 14.5 207.0 5050.0 FEMALE \n", - "9 14.5 215.0 5000.0 FEMALE \n", - "10 14.5 209.0 4800.0 FEMALE \n", - "11 14.5 213.0 4400.0 FEMALE \n", - "12 14.5 208.0 4450.0 FEMALE \n", - "13 15.0 219.0 4850.0 FEMALE \n", - "14 15.0 228.0 5500.0 MALE \n", - "15 15.0 218.0 4950.0 FEMALE \n", - "16 15.0 216.0 4750.0 MALE \n", - "17 15.0 216.0 4750.0 FEMALE \n", - "18 15.0 220.0 5000.0 MALE \n", - "19 15.0 216.0 4700.0 FEMALE \n", - "20 15.0 223.0 5550.0 MALE \n", - "21 15.0 215.0 5650.0 MALE \n", - "22 15.0 225.0 5000.0 MALE \n", - "23 15.5 215.0 4975.0 FEMALE \n", - "24 16.0 225.0 5700.0 MALE \n", - "...\n", - "\n", - "[347 rows x 8 columns]" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import bigframes.pandas\n", - "\n", - "df = bigframes.pandas.read_gbq(\"bigframes-dev.bqml_tutorial.penguins\")\n", - "df" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "359524c4", - "metadata": {}, - "source": [ - "First we note that while we have a default numbered index generated by BigQuery, actually the penguins are uniquely identified by their tags.\n", - "\n", - "Lets make the data a bit friendlier to work with by setting the tag number column as the index." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "93d01411", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
speciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
tag_number
1225Gentoo penguin (Pygoscelis papua)Biscoe<NA><NA><NA><NA><NA>
1278Gentoo penguin (Pygoscelis papua)Biscoe42.013.5210.04150.0FEMALE
1275Gentoo penguin (Pygoscelis papua)Biscoe46.513.5210.04550.0FEMALE
1233Gentoo penguin (Pygoscelis papua)Biscoe43.314.0208.04575.0FEMALE
1311Gentoo penguin (Pygoscelis papua)Biscoe47.514.0212.04875.0FEMALE
1316Gentoo penguin (Pygoscelis papua)Biscoe49.114.5212.04625.0FEMALE
1313Gentoo penguin (Pygoscelis papua)Biscoe45.514.5212.04750.0FEMALE
1381Gentoo penguin (Pygoscelis papua)Biscoe47.614.5215.05400.0MALE
1377Gentoo penguin (Pygoscelis papua)Biscoe45.114.5207.05050.0FEMALE
1380Gentoo penguin (Pygoscelis papua)Biscoe45.114.5215.05000.0FEMALE
1257Gentoo penguin (Pygoscelis papua)Biscoe46.214.5209.04800.0FEMALE
1336Gentoo penguin (Pygoscelis papua)Biscoe46.514.5213.04400.0FEMALE
1237Gentoo penguin (Pygoscelis papua)Biscoe43.214.5208.04450.0FEMALE
1302Gentoo penguin (Pygoscelis papua)Biscoe48.515.0219.04850.0FEMALE
1325Gentoo penguin (Pygoscelis papua)Biscoe49.115.0228.05500.0MALE
1285Gentoo penguin (Pygoscelis papua)Biscoe47.515.0218.04950.0FEMALE
1242Gentoo penguin (Pygoscelis papua)Biscoe49.615.0216.04750.0MALE
1246Gentoo penguin (Pygoscelis papua)Biscoe47.715.0216.04750.0FEMALE
1320Gentoo penguin (Pygoscelis papua)Biscoe45.515.0220.05000.0MALE
1244Gentoo penguin (Pygoscelis papua)Biscoe46.415.0216.04700.0FEMALE
\n", - "
[347 rows x 7 columns in total]" - ], - "text/plain": [ - " species island culmen_length_mm \\\n", - "tag_number \n", - "1225 Gentoo penguin (Pygoscelis papua) Biscoe \n", - "1278 Gentoo penguin (Pygoscelis papua) Biscoe 42.0 \n", - "1275 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 \n", - "1233 Gentoo penguin (Pygoscelis papua) Biscoe 43.3 \n", - "1311 Gentoo penguin (Pygoscelis papua) Biscoe 47.5 \n", - "1316 Gentoo penguin (Pygoscelis papua) Biscoe 49.1 \n", - "1313 Gentoo penguin (Pygoscelis papua) Biscoe 45.5 \n", - "1381 Gentoo penguin (Pygoscelis papua) Biscoe 47.6 \n", - "1377 Gentoo penguin (Pygoscelis papua) Biscoe 45.1 \n", - "1380 Gentoo penguin (Pygoscelis papua) Biscoe 45.1 \n", - "1257 Gentoo penguin (Pygoscelis papua) Biscoe 46.2 \n", - "1336 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 \n", - "1237 Gentoo penguin (Pygoscelis papua) Biscoe 43.2 \n", - "1302 Gentoo penguin (Pygoscelis papua) Biscoe 48.5 \n", - "1325 Gentoo penguin (Pygoscelis papua) Biscoe 49.1 \n", - "1285 Gentoo penguin (Pygoscelis papua) Biscoe 47.5 \n", - "1242 Gentoo penguin (Pygoscelis papua) Biscoe 49.6 \n", - "1246 Gentoo penguin (Pygoscelis papua) Biscoe 47.7 \n", - "1320 Gentoo penguin (Pygoscelis papua) Biscoe 45.5 \n", - "1244 Gentoo penguin (Pygoscelis papua) Biscoe 46.4 \n", - "1390 Gentoo penguin (Pygoscelis papua) Biscoe 50.7 \n", - "1379 Gentoo penguin (Pygoscelis papua) Biscoe 47.8 \n", - "1267 Gentoo penguin (Pygoscelis papua) Biscoe 50.1 \n", - "1389 Gentoo penguin (Pygoscelis papua) Biscoe 47.2 \n", - "1269 Gentoo penguin (Pygoscelis papua) Biscoe 49.6 \n", - "\n", - " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "tag_number \n", - "1225 \n", - "1278 13.5 210.0 4150.0 FEMALE \n", - "1275 13.5 210.0 4550.0 FEMALE \n", - "1233 14.0 208.0 4575.0 FEMALE \n", - "1311 14.0 212.0 4875.0 FEMALE \n", - "1316 14.5 212.0 4625.0 FEMALE \n", - "1313 14.5 212.0 4750.0 FEMALE \n", - "1381 14.5 215.0 5400.0 MALE \n", - "1377 14.5 207.0 5050.0 FEMALE \n", - "1380 14.5 215.0 5000.0 FEMALE \n", - "1257 14.5 209.0 4800.0 FEMALE \n", - "1336 14.5 213.0 4400.0 FEMALE \n", - "1237 14.5 208.0 4450.0 FEMALE \n", - "1302 15.0 219.0 4850.0 FEMALE \n", - "1325 15.0 228.0 5500.0 MALE \n", - "1285 15.0 218.0 4950.0 FEMALE \n", - "1242 15.0 216.0 4750.0 MALE \n", - "1246 15.0 216.0 4750.0 FEMALE \n", - "1320 15.0 220.0 5000.0 MALE \n", - "1244 15.0 216.0 4700.0 FEMALE \n", - "1390 15.0 223.0 5550.0 MALE \n", - "1379 15.0 215.0 5650.0 MALE \n", - "1267 15.0 225.0 5000.0 MALE \n", - "1389 15.5 215.0 4975.0 FEMALE \n", - "1269 16.0 225.0 5700.0 MALE \n", - "...\n", - "\n", - "[347 rows x 7 columns]" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = df.set_index(\"tag_number\")\n", - "df" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "f95fda6a", - "metadata": {}, - "source": [ - "We saw in the first view that there were some missing values. We're especially interested in observations that are missing just the body_mass_g, so lets look at those:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "941cb6c3-8c54-42ce-a945-4fa604176b2e", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
speciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
tag_number
1225Gentoo penguin (Pygoscelis papua)Biscoe<NA><NA><NA><NA><NA>
1393Adelie Penguin (Pygoscelis adeliae)Torgersen<NA><NA><NA><NA><NA>
1524Adelie Penguin (Pygoscelis adeliae)Dream41.620.0204.0<NA>MALE
1523Adelie Penguin (Pygoscelis adeliae)Dream38.017.5194.0<NA>FEMALE
1525Adelie Penguin (Pygoscelis adeliae)Dream36.318.5194.0<NA>MALE
\n", - "
[5 rows x 7 columns in total]" - ], - "text/plain": [ - " species island culmen_length_mm \\\n", - "tag_number \n", - "1225 Gentoo penguin (Pygoscelis papua) Biscoe \n", - "1393 Adelie Penguin (Pygoscelis adeliae) Torgersen \n", - "1524 Adelie Penguin (Pygoscelis adeliae) Dream 41.6 \n", - "1523 Adelie Penguin (Pygoscelis adeliae) Dream 38.0 \n", - "1525 Adelie Penguin (Pygoscelis adeliae) Dream 36.3 \n", - "\n", - " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "tag_number \n", - "1225 \n", - "1393 \n", - "1524 20.0 204.0 MALE \n", - "1523 17.5 194.0 FEMALE \n", - "1525 18.5 194.0 MALE \n", - "\n", - "[5 rows x 7 columns]" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df[df.body_mass_g.isnull()]" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "a70c2027", - "metadata": {}, - "source": [ - "Here we see three Adelie penguins with tag numbers 1523, 1524, 1525 are missing their body_mass_g but have the other measurements. These are the ones we need to guess. We can do this by training a statistical model on the measurements that we do have, and then using it to predict the missing values.\n", - "\n", - "Our conservationists warned us that trying to generalize across species is a bad idea, so for now lets just try building a model for Adelie penguins. We can revisit it later and see if including the other observations improves the model performance." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "93ff013a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
speciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
tag_number
1172Adelie Penguin (Pygoscelis adeliae)Dream32.115.5188.03050.0FEMALE
1371Adelie Penguin (Pygoscelis adeliae)Biscoe37.716.0183.03075.0FEMALE
1417Adelie Penguin (Pygoscelis adeliae)Torgersen38.617.0188.02900.0FEMALE
1204Adelie Penguin (Pygoscelis adeliae)Dream40.717.0190.03725.0MALE
1251Adelie Penguin (Pygoscelis adeliae)Biscoe37.617.0185.03600.0FEMALE
1422Adelie Penguin (Pygoscelis adeliae)Torgersen35.717.0189.03350.0FEMALE
1394Adelie Penguin (Pygoscelis adeliae)Torgersen40.217.0176.03450.0FEMALE
1163Adelie Penguin (Pygoscelis adeliae)Dream36.417.0195.03325.0FEMALE
1329Adelie Penguin (Pygoscelis adeliae)Biscoe38.117.0181.03175.0FEMALE
1406Adelie Penguin (Pygoscelis adeliae)Torgersen44.118.0210.04000.0MALE
1196Adelie Penguin (Pygoscelis adeliae)Dream36.518.0182.03150.0FEMALE
1228Adelie Penguin (Pygoscelis adeliae)Biscoe41.618.0192.03950.0MALE
1412Adelie Penguin (Pygoscelis adeliae)Torgersen40.318.0195.03250.0FEMALE
1142Adelie Penguin (Pygoscelis adeliae)Dream35.718.0202.03550.0FEMALE
1430Adelie Penguin (Pygoscelis adeliae)Torgersen33.519.0190.03600.0FEMALE
1333Adelie Penguin (Pygoscelis adeliae)Biscoe43.219.0197.04775.0MALE
1414Adelie Penguin (Pygoscelis adeliae)Torgersen38.719.0195.03450.0FEMALE
1197Adelie Penguin (Pygoscelis adeliae)Dream41.119.0182.03425.0MALE
1443Adelie Penguin (Pygoscelis adeliae)Torgersen40.619.0199.04000.0MALE
1295Adelie Penguin (Pygoscelis adeliae)Biscoe41.020.0203.04725.0MALE
\n", - "
[146 rows x 7 columns in total]" - ], - "text/plain": [ - " species island culmen_length_mm \\\n", - "tag_number \n", - "1172 Adelie Penguin (Pygoscelis adeliae) Dream 32.1 \n", - "1371 Adelie Penguin (Pygoscelis adeliae) Biscoe 37.7 \n", - "1417 Adelie Penguin (Pygoscelis adeliae) Torgersen 38.6 \n", - "1204 Adelie Penguin (Pygoscelis adeliae) Dream 40.7 \n", - "1251 Adelie Penguin (Pygoscelis adeliae) Biscoe 37.6 \n", - "1422 Adelie Penguin (Pygoscelis adeliae) Torgersen 35.7 \n", - "1394 Adelie Penguin (Pygoscelis adeliae) Torgersen 40.2 \n", - "1163 Adelie Penguin (Pygoscelis adeliae) Dream 36.4 \n", - "1329 Adelie Penguin (Pygoscelis adeliae) Biscoe 38.1 \n", - "1406 Adelie Penguin (Pygoscelis adeliae) Torgersen 44.1 \n", - "1196 Adelie Penguin (Pygoscelis adeliae) Dream 36.5 \n", - "1228 Adelie Penguin (Pygoscelis adeliae) Biscoe 41.6 \n", - "1412 Adelie Penguin (Pygoscelis adeliae) Torgersen 40.3 \n", - "1142 Adelie Penguin (Pygoscelis adeliae) Dream 35.7 \n", - "1430 Adelie Penguin (Pygoscelis adeliae) Torgersen 33.5 \n", - "1333 Adelie Penguin (Pygoscelis adeliae) Biscoe 43.2 \n", - "1414 Adelie Penguin (Pygoscelis adeliae) Torgersen 38.7 \n", - "1197 Adelie Penguin (Pygoscelis adeliae) Dream 41.1 \n", - "1443 Adelie Penguin (Pygoscelis adeliae) Torgersen 40.6 \n", - "1295 Adelie Penguin (Pygoscelis adeliae) Biscoe 41.0 \n", - "1207 Adelie Penguin (Pygoscelis adeliae) Dream 38.8 \n", - "1349 Adelie Penguin (Pygoscelis adeliae) Biscoe 38.2 \n", - "1350 Adelie Penguin (Pygoscelis adeliae) Biscoe 37.8 \n", - "1351 Adelie Penguin (Pygoscelis adeliae) Biscoe 38.1 \n", - "1116 Adelie Penguin (Pygoscelis adeliae) Dream 37.0 \n", - "\n", - " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "tag_number \n", - "1172 15.5 188.0 3050.0 FEMALE \n", - "1371 16.0 183.0 3075.0 FEMALE \n", - "1417 17.0 188.0 2900.0 FEMALE \n", - "1204 17.0 190.0 3725.0 MALE \n", - "1251 17.0 185.0 3600.0 FEMALE \n", - "1422 17.0 189.0 3350.0 FEMALE \n", - "1394 17.0 176.0 3450.0 FEMALE \n", - "1163 17.0 195.0 3325.0 FEMALE \n", - "1329 17.0 181.0 3175.0 FEMALE \n", - "1406 18.0 210.0 4000.0 MALE \n", - "1196 18.0 182.0 3150.0 FEMALE \n", - "1228 18.0 192.0 3950.0 MALE \n", - "1412 18.0 195.0 3250.0 FEMALE \n", - "1142 18.0 202.0 3550.0 FEMALE \n", - "1430 19.0 190.0 3600.0 FEMALE \n", - "1333 19.0 197.0 4775.0 MALE \n", - "1414 19.0 195.0 3450.0 FEMALE \n", - "1197 19.0 182.0 3425.0 MALE \n", - "1443 19.0 199.0 4000.0 MALE \n", - "1295 20.0 203.0 4725.0 MALE \n", - "1207 20.0 190.0 3950.0 MALE \n", - "1349 20.0 190.0 3900.0 MALE \n", - "1350 20.0 190.0 4250.0 MALE \n", - "1351 16.5 198.0 3825.0 FEMALE \n", - "1116 16.5 185.0 3400.0 FEMALE \n", - "...\n", - "\n", - "[146 rows x 7 columns]" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# get all the rows with adelie penguins\n", - "adelie_data = df[df.species == \"Adelie Penguin (Pygoscelis adeliae)\"]\n", - "\n", - "# separate out the rows that have a body mass measurement\n", - "training_data = adelie_data[adelie_data.body_mass_g.notnull()]\n", - "\n", - "# we noticed there were also some rows that were missing other values,\n", - "# lets remove these so they don't affect our results\n", - "training_data = training_data.dropna()\n", - "\n", - "# lets take a quick peek and make sure things look right:\n", - "training_data" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "d55a39f9", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "species string[pyarrow]\n", - "island string[pyarrow]\n", - "culmen_length_mm Float64\n", - "culmen_depth_mm Float64\n", - "flipper_length_mm Float64\n", - "body_mass_g Float64\n", - "sex string[pyarrow]\n", - "dtype: object" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# we'll look at the schema too:\n", - "training_data.dtypes" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "59d374b5", - "metadata": {}, - "source": [ - "Great! Now lets configure a linear regression model to predict body mass from the other columns" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "18c4cecf", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LinearRegression()" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import bigframes.ml.linear_model as ml\n", - "\n", - "model = ml.LinearRegression()\n", - "model" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "6e54a1a2", - "metadata": {}, - "source": [ - "As in SKLearn, an unfitted model object is just a bundle of parameters." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "a2060cf1", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'fit_intercept': True}" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# lets view the parameters\n", - "model.get_params()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "8e25fe41", - "metadata": {}, - "source": [ - "For this task, really all the default options are fine. But just so we can see how configuration works, lets specify that we want to use gradient descent to find the solution:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "327e2232", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LinearRegression()" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model.optimize_strategy = \"BATCH_GRADIENT_DESCENT\"\n", - "model" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "2c2e0835", - "metadata": {}, - "source": [ - "BigQuery models provide a couple of extra conveniences:\n", - "\n", - "1. By default, they will automatically perform feature engineering on the inputs - encoding our string columns and scaling our numeric columns.\n", - "2. By default, they will also automatically manage the test/training data split for us.\n", - "\n", - "So all we need to do is hook our chosen feature and label columns into the model and call .fit()!" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "085c9a99", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LinearRegression()" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "X_train = training_data[['island', 'culmen_length_mm', 'culmen_depth_mm', 'flipper_length_mm', 'sex']]\n", - "y_train = training_data[['body_mass_g']]\n", - "model.fit(X_train, y_train)\n", - "model" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "9e76e10c", - "metadata": {}, - "source": [ - "...and there, we've successfully trained a linear regressor model. Lets see how it performs, using the automatic data split:" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "c9458c02", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
mean_absolute_errormean_squared_errormean_squared_log_errormedian_absolute_errorr2_scoreexplained_variance
0223.87876378553.6016340.005614181.3309110.6239510.623951
\n", - "
[1 rows x 6 columns in total]" - ], - "text/plain": [ - " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - "0 223.878763 78553.601634 0.005614 \n", - "\n", - " median_absolute_error r2_score explained_variance \n", - "0 181.330911 0.623951 0.623951 \n", - "\n", - "[1 rows x 6 columns]" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model.score(X_train, y_train)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "f0b39603", - "metadata": {}, - "source": [ - "Great! The model seems useful, predicting 62% of the variance.\n", - "\n", - "We realize we made a mistake though - we're trying to predict mass using a linear model, mass will increase with the cube of the penguin's size, whereas our inputs are linear with size. Can we improve our model by cubing them?" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "b94eddc7", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'\\ndef cubify(penguin_df):\\n penguin_df.culmen_length_mm = train_x.culmen_length_mm.pow(3)\\n penguin_df.culmen_depth_mm = train_x.culmen_depth_mm.pow(3)\\n penguin_df.flipper_length_mm = train_x.flipper_length_mm.pow(3)\\n\\ncubify(train_x)\\ntrain_x\\n'" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# SKIP THIS STEP (not yet work working in BigQuery DataFrame)\n", - "\n", - "# lets define a preprocessing step that adjust the linear measurements to use the cube\n", - "'''\n", - "def cubify(penguin_df):\n", - " penguin_df.culmen_length_mm = X_train.culmen_length_mm.pow(3)\n", - " penguin_df.culmen_depth_mm = X_train.culmen_depth_mm.pow(3)\n", - " penguin_df.flipper_length_mm = X_train.flipper_length_mm.pow(3)\n", - "\n", - "cubify(X_train)\n", - "X_train\n", - "'''" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "1b0e3f02", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'\\nmodel.fit(train_x, train_y)\\nmodel.evaluate()\\n'" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# AS ABOVE, SKIP FOR NOW\n", - "'''\n", - "model.fit(X_train, y_train)\n", - "model.evaluate()\n", - "'''" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "45c5e755", - "metadata": {}, - "source": [ - "Now that we're satisfied with our model, lets see what it predicts for those Adelie penguins with no body mass measurement:" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "f21ebc1f", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
predicted_body_mass_g
tag_number
13933459.735118
15244304.175638
15233471.668379
15253947.881639
\n", - "
[4 rows x 1 columns in total]" - ], - "text/plain": [ - " predicted_body_mass_g\n", - "tag_number \n", - "1393 3459.735118\n", - "1524 4304.175638\n", - "1523 3471.668379\n", - "1525 3947.881639\n", - "\n", - "[4 rows x 1 columns]" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Lets predict the missing observations\n", - "missing_body_mass = adelie_data[adelie_data.body_mass_g.isnull()]\n", - "\n", - "model.predict(missing_body_mass)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "e66bd0b0", - "metadata": {}, - "source": [ - "Because we created it without a name, it was just a temporary model that will disappear after 24 hours. \n", - "\n", - "We decide that this approach is promising, so lets tell BigQuery to save it." - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "c508691b", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LinearRegression()" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model.to_gbq(\"bqml_tutorial.penguins_model\", replace=True)\n", - "model" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "46abef08", - "metadata": {}, - "source": [ - "We can now use this model anywhere in BigQuery with this name. We can also load\n", - "it again in our BigQuery DataFrames session and evaluate or inference it without\n", - "needing to retrain it:" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "0c87e972", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LinearRegression()" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model = bigframes.pandas.read_gbq_model(\"bqml_tutorial.penguins_model\")\n", - "model" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "d6ab8def", - "metadata": {}, - "source": [ - "And of course we can retrain it if we like. Lets make another version that is based on all the penguins, so we can test that assumption we made at the beginning that it would be best to separate them:" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "f4960452", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
mean_absolute_errormean_squared_errormean_squared_log_errormedian_absolute_errorr2_scoreexplained_variance
0224.71743379527.8796230.005693169.2358690.6192870.619287
\n", - "
[1 rows x 6 columns in total]" - ], - "text/plain": [ - " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - "0 224.717433 79527.879623 0.005693 \n", - "\n", - " median_absolute_error r2_score explained_variance \n", - "0 169.235869 0.619287 0.619287 \n", - "\n", - "[1 rows x 6 columns]" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# This time we'll take all the training data, for all species\n", - "training_data = df[df.body_mass_g.notnull()]\n", - "training_data = training_data.dropna()\n", - "\n", - "# And we'll include species in our features\n", - "X_train = training_data[['species', 'island', 'culmen_length_mm', 'culmen_depth_mm', 'flipper_length_mm', 'sex']]\n", - "y_train = training_data[['body_mass_g']]\n", - "model.fit(X_train, y_train)\n", - "\n", - "# And we'll evaluate it on the Adelie penguins only\n", - "adelie_data = training_data[training_data.species == \"Adelie Penguin (Pygoscelis adeliae)\"]\n", - "X_test = adelie_data[['species', 'island', 'culmen_length_mm', 'culmen_depth_mm', 'flipper_length_mm', 'sex']]\n", - "y_test = adelie_data[['body_mass_g']]\n", - "model.score(X_test, y_test)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "7d101140", - "metadata": {}, - "source": [ - "It looks like the conservationists were right! Including other species, even though it gave us more training data, worsened prediction on the Adelie penguins." - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "7f3fe50d", - "metadata": {}, - "source": [ - "===============================================\n", - "\n", - "**Everything below this line not yet implemented**" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "62577c72", - "metadata": {}, - "source": [ - "We want to productionalize this model, so lets start publishing it to the vertex model registry ([prerequisites](https://cloud.google.com/bigquery-ml/docs/managing-models-vertex#prerequisites))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b82e79ee", - "metadata": {}, - "outputs": [], - "source": [ - "model.publish(\n", - " registry=\"vertex_ai\",\n", - " vertex_ai_model_version_aliases=[\"experimental\"])" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "69d2482c", - "metadata": {}, - "source": [ - "Now when we fit the model, we can see it published here: https://console.cloud.google.com/vertex-ai/models" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "b97d9b64", - "metadata": {}, - "source": [ - "# Custom feature engineering" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "c837ace9", - "metadata": {}, - "source": [ - "So far, we've relied on BigQuery to do our feature engineering for us. What if we want to do it manually?\n", - "\n", - "BigQuery DataFrames provides a way to do this using Pipelines." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "480cb12f", - "metadata": {}, - "outputs": [], - "source": [ - "from bigframes.ml.pipeline import Pipeline\n", - "from bigframes.ml.preprocessing import StandardScaler\n", - "\n", - "pipe = Pipeline([\n", - " ('scaler', StandardScaler()),\n", - " ('linreg', LinearRegression())\n", - "])\n", - "\n", - "pipe.fit(X_train, y_train)\n", - "pipe.evaluate()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "9a0e7d19", - "metadata": {}, - "source": [ - "We then can then save the entire pipeline to BigQuery, BigQuery will save this as a single model, with the pre-processing steps embedded in the TRANSFORM property:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0d1831ed", - "metadata": {}, - "outputs": [], - "source": [ - "pipe.to_gbq(\"bqml_tutorial.penguins_pipeline\")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "f6b60898", - "metadata": {}, - "source": [ - "# Custom data split" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "60ac0174", - "metadata": {}, - "source": [ - "BigQuery has also managed splitting out our training data. What if we want to do this manually?\n", - "\n", - "*TODO: Write this section*" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.9" - }, - "vscode": { - "interpreter": { - "hash": "a850322d07d9bdc9ec5f301d307e048bcab2390ae395e1cbce9335f4e081e5e2" - } - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/experimental/semantic_operators.ipynb b/notebooks/experimental/semantic_operators.ipynb index d3fec469b4..c32ac9042b 100644 --- a/notebooks/experimental/semantic_operators.ipynb +++ b/notebooks/experimental/semantic_operators.ipynb @@ -25,3164 +25,11 @@ }, { "cell_type": "markdown", - "metadata": { - "id": "rWJnGj2ViouP" - }, - "source": [ - "# BigFrames AI (semantic) Operator Tutorial\n", - "\n", - "\n", - "\n", - " \n", - " \n", - " \n", - "
\n", - " \n", - " \"Colab Run in Colab\n", - " \n", - " \n", - " \n", - " \"GitHub\n", - " View on GitHub\n", - " \n", - " \n", - " \n", - " \"BQ\n", - " Open in BQ Studio\n", - " \n", - "
" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "mgOrr256iouQ" - }, - "source": [ - "This notebook provides a hands-on preview of AI operator APIs powered by the Gemini model.\n", - "\n", - "The notebook is divided into two sections. The first section introduces the API syntax with examples, aiming to familiarize you with how AI operators work. The second section applies AI operators to a large real-world dataset and presents performance statistics.\n", - "\n", - "This work is inspired by [this paper](https://arxiv.org/pdf/2407.11418) and powered by BigQuery ML and Vertex AI." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "2ymVbJV2iouQ" - }, - "source": [ - "# Preparation" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "vvVzFzo3iouQ" - }, - "source": [ - "First, import the BigFrames modules.\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "Jb9glT2ziouQ" - }, - "outputs": [], - "source": [ - "import bigframes\n", - "import bigframes.pandas as bpd" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "xQiCWj7OiouQ" - }, - "source": [ - "Make sure the BigFrames version is at least `1.23.0`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "LTPpI8IpiouQ" - }, - "outputs": [], - "source": [ - "from packaging.version import Version\n", - "\n", - "assert Version(bigframes.__version__) >= Version(\"1.23.0\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "agxLmtlbiouR" - }, - "source": [ - "Turn on the semantic operator experiment. You will see a warning sign saying that these operators are still under experiments. If you don't turn on the experiment before using the operators, you will get `NotImplemenetedError`s." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "1wXqdDr8iouR" - }, - "outputs": [], - "source": [ - "bigframes.options.experiments.semantic_operators = True" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "W8TPUvnsqxhv" - }, - "source": [ - "Specify your GCP project and location." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "vCkraKOeqJFl" - }, - "outputs": [], - "source": [ - "bpd.options.bigquery.project = 'YOUR_PROJECT_ID'\n", - "bpd.options.bigquery.location = 'US'" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "n95MFlS0iouR" - }, - "source": [ - "**Optional**: turn off the display of progress bar so that only the operation results will be printed out" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "5r6ahx7MiouR" - }, - "outputs": [], - "source": [ - "# bpd.options.display.progress_bar = None" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "93iYvp7niouR" - }, - "source": [ - "Create LLM instances. They will be passed in as parameters for each semantic operator.\n", - "\n", - "This tutorial uses the \"gemini-1.5-flash-002\" model for text generation and \"text-embedding-005\" for embedding. While these are recommended, you can choose [other Vertex AI LLM models](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models) based on your needs and availability. Ensure you have [sufficient quota](https://cloud.google.com/vertex-ai/generative-ai/docs/quotas) for your chosen models and adjust it if necessary." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "tHkymaLNiouR" - }, - "outputs": [], - "source": [ - "from bigframes.ml import llm\n", - "gemini_model = llm.GeminiTextGenerator(model_name=\"gemini-1.5-flash-001\")\n", - "text_embedding_model = llm.TextEmbeddingGenerator(model_name=\"text-embedding-005\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "mbFDcvnPiouR" - }, - "source": [ - "**Note**: semantic operators could be expensive over a large set of data. As a result, our team added this option `bigframes.options.compute.sem_ops_confirmation_threshold` at `version 1.31.0` so that the BigFrames will ask for your confirmation if the amount of data to be processed is too large. If the amount of rows exceeds your threshold, you will see a prompt for your keyboard input -- 'y' to proceed and 'n' to abort. If you abort the operation, no LLM processing will be done.\n", - "\n", - "The default threshold is 0, which means the operators will always ask for confirmations. You are free to adjust the value as needed. You can also set the threshold to `None` to disable this feature." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "F4dZm4b7iouR" - }, - "outputs": [], - "source": [ - "if Version(bigframes.__version__) >= Version(\"1.31.0\"):\n", - " bigframes.options.compute.semantic_ops_confirmation_threshold = 1000" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "_dEA3G9RiouR" - }, - "source": [ - "If you would like your operations to fail automatically when the data is too large, set `bigframes.options.compute.semantic_ops_threshold_autofail` to `True`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "BoUK-cpbiouS" - }, - "outputs": [], - "source": [ - "# if Version(bigframes.__version__) >= Version(\"1.31.0\"):\n", - "# bigframes.options.compute.semantic_ops_threshold_autofail = True" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "hQft3o3OiouS" - }, - "source": [ - "# API Samples" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "dt5Kl-QGiouS" - }, - "source": [ - "You will learn about each semantic operator by trying some examples." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "J7XAT459iouS" - }, - "source": [ - "## Semantic Filtering" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "9d5HUIvliouS" - }, - "source": [ - "Semantic filtering allows you to filter your dataframe based on the instruction (i.e. prompt) you provided.\n", - "\n", - "First, create a dataframe:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 190 - }, - "id": "NDpCRGd_iouS", - "outputId": "5048c935-06d3-4ef1-ad87-72e14a30b1b7" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
countrycity
0USASeattle
1GermanyBerlin
2JapanKyoto
\n", - "

3 rows × 2 columns

\n", - "
[3 rows x 2 columns in total]" - ], - "text/plain": [ - " country city\n", - "0 USA Seattle\n", - "1 Germany Berlin\n", - "2 Japan Kyoto\n", - "\n", - "[3 rows x 2 columns]" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = bpd.DataFrame({'country': ['USA', 'Germany', 'Japan'], 'city': ['Seattle', 'Berlin', 'Kyoto']})\n", - "df" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "6AXmT7sniouS" - }, - "source": [ - "Now, filter this dataframe by keeping only the rows where the value in `city` column is the capital of the value in `country` column. The column references could be \"escaped\" by using a pair of braces in your instruction. In this example, your instruction should be like this:\n", - "```\n", - "The {city} is the capital of the {country}.\n", - "```\n", - "\n", - "Note that this is not a Python f-string, so you shouldn't prefix your instruction with an `f`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 127 - }, - "id": "ipW3Z_l4iouS", - "outputId": "ad447459-225a-419c-d4c8-fedac4a9ed0f" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
countrycity
1GermanyBerlin
\n", - "

1 rows × 2 columns

\n", - "
[1 rows x 2 columns in total]" - ], - "text/plain": [ - " country city\n", - "1 Germany Berlin\n", - "\n", - "[1 rows x 2 columns]" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.semantics.filter(\"The {city} is the capital of the {country}\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "swKvgfm1iouS" - }, - "source": [ - "The filter operator extracts the information from the referenced column to enrich your instruction with context. The instruction is then sent for the designated model for evaluation. For filtering operations, the LLM is asked to return only `True` and `False` for each row, and the operator removes the rows accordingly." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "r_2AAGGoiouS" - }, - "source": [ - "## Semantic Mapping" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "vT6skC57iouS" - }, - "source": [ - "Semantic mapping allows to you to combine values from multiple columns into a single output based your instruction.\n", - "\n", - "Here is an example:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 190 - }, - "id": "BQ7xeUK3iouS", - "outputId": "33dcb742-77ed-4bea-8dbc-1cf775102a25" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ingredient_1ingredient_2
0BunBeef Patty
1Soy BeanBittern
2SausageLong Bread
\n", - "

3 rows × 2 columns

\n", - "
[3 rows x 2 columns in total]" - ], - "text/plain": [ - " ingredient_1 ingredient_2\n", - "0 Bun Beef Patty\n", - "1 Soy Bean Bittern\n", - "2 Sausage Long Bread\n", - "\n", - "[3 rows x 2 columns]" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = bpd.DataFrame({\n", - " \"ingredient_1\": [\"Bun\", \"Soy Bean\", \"Sausage\"],\n", - " \"ingredient_2\": [\"Beef Patty\", \"Bittern\", \"Long Bread\"]\n", - " })\n", - "df" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "VFObP2aFiouS" - }, - "source": [ - "Now, you ask LLM what kind of food can be made from the two ingredients in each row. The column reference syntax in your instruction stays the same. In addition, you need to specify the column name by setting the `output_column` parameter to hold the mapping results." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 190 - }, - "id": "PpL24AQFiouS", - "outputId": "e7aff038-bf4b-4833-def8-fe2648e8885b" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ingredient_1ingredient_2food
0BunBeef PattyBurger
1Soy BeanBitternTofu
2SausageLong BreadHotdog
\n", - "

3 rows × 3 columns

\n", - "
[3 rows x 3 columns in total]" - ], - "text/plain": [ - " ingredient_1 ingredient_2 food\n", - "0 Bun Beef Patty Burger \n", - "\n", - "1 Soy Bean Bittern Tofu \n", - "\n", - "2 Sausage Long Bread Hotdog \n", - "\n", - "\n", - "[3 rows x 3 columns]" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.semantics.map(\"What is the food made from {ingredient_1} and {ingredient_2}? One word only.\", output_column=\"food\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "70WTZZfdiouS" - }, - "source": [ - "## Semantic Joining" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "u93uieRaiouS" - }, - "source": [ - "Semantic joining can join two dataframes based on the instruction you provided.\n", - "\n", - "First, you prepare two dataframes:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "dffIGEUEiouS" - }, - "outputs": [], - "source": [ - "cities = bpd.DataFrame({'city': ['Seattle', 'Ottawa', 'Berlin', 'Shanghai', 'New Delhi']})\n", - "continents = bpd.DataFrame({'continent': ['North America', 'Africa', 'Asia']})" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Hz0X-0RtiouS" - }, - "source": [ - "You want to join the `cities` with `continents` to form a new dataframe such that, in each row the city from the `cities` data frame is in the continent from the `continents` dataframe. You could re-use the aforementioned column reference syntax:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 221 - }, - "id": "WPIOHEwCiouT", - "outputId": "976586c3-b5db-4088-a46a-44dfbf822ecb" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
citycontinent
0SeattleNorth America
1OttawaNorth America
2ShanghaiAsia
3New DelhiAsia
\n", - "

4 rows × 2 columns

\n", - "
[4 rows x 2 columns in total]" - ], - "text/plain": [ - " city continent\n", - "0 Seattle North America\n", - "1 Ottawa North America\n", - "2 Shanghai Asia\n", - "3 New Delhi Asia\n", - "\n", - "[4 rows x 2 columns]" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cities.semantics.join(continents, \"{city} is in {continent}\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "4Qc97GMWiouT" - }, - "source": [ - "!! **Important:** Semantic join can trigger probihitively expensitve operations! This operation first cross joins two dataframes, then invokes semantic filter on each row. That means if you have two dataframes of sizes `M` and `N`, the total amount of queries sent to the LLM is on the scale of `M * N`." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "MUEJXT1IiouT" - }, - "source": [ - "### Self Joins" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "QvX-nCogiouT" - }, - "source": [ - "This self-join example is for demonstrating a special case: what happens when the joining columns exist in both data frames? It turns out that you need to provide extra information in your column references: by attaching \"left.\" and \"right.\" prefixes to your column names.\n", - "\n", - "Create an example data frame:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "OIGz5sqxiouW" - }, - "outputs": [], - "source": [ - "animals = bpd.DataFrame({'animal': ['cow', 'cat', 'spider', 'elephant']})" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "VmJbuWNniouX" - }, - "source": [ - "You want to compare the weights of these animals, and output all the pairs where the animal on the left is heavier than the animal on the right. In this case, you use `left.animal` and `right.animal` to differentiate the data sources:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 284 - }, - "id": "UHfggdhBiouX", - "outputId": "a439e3aa-1382-4244-951f-127dc8da0fe3" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
animal_leftanimal_right
0cowcat
1cowspider
2catspider
3elephantcow
4elephantcat
5elephantspider
\n", - "

6 rows × 2 columns

\n", - "
[6 rows x 2 columns in total]" - ], - "text/plain": [ - " animal_left animal_right\n", - "0 cow cat\n", - "1 cow spider\n", - "2 cat spider\n", - "3 elephant cow\n", - "4 elephant cat\n", - "5 elephant spider\n", - "\n", - "[6 rows x 2 columns]" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "animals.semantics.join(animals, \"{left.animal} generally weighs heavier than {right.animal}\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "KONR7ywqiouX" - }, - "source": [ - "## Semantic Aggregation" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "I8iNRogoiouX" - }, - "source": [ - "Semantic aggregation merges all the values in a column into one. At this moment you can only aggregate a single column in each oeprator call.\n", - "\n", - "Here is an example:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 315 - }, - "id": "9tsem17aiouX", - "outputId": "1db5fa6e-b59d-41f5-9c13-db2c9ed0415b" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Movies
0Titanic
1The Wolf of Wall Street
2Killers of the Flower Moon
3The Revenant
4Inception
5Shuttle Island
6The Great Gatsby
\n", - "

7 rows × 1 columns

\n", - "
[7 rows x 1 columns in total]" - ], - "text/plain": [ - " Movies\n", - "0 Titanic\n", - "1 The Wolf of Wall Street\n", - "2 Killers of the Flower Moon\n", - "3 The Revenant\n", - "4 Inception\n", - "5 Shuttle Island\n", - "6 The Great Gatsby\n", - "\n", - "[7 rows x 1 columns]" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = bpd.DataFrame({\n", - " \"Movies\": [\n", - " \"Titanic\",\n", - " \"The Wolf of Wall Street\",\n", - " \"Killers of the Flower Moon\",\n", - " \"The Revenant\",\n", - " \"Inception\",\n", - " \"Shuttle Island\",\n", - " \"The Great Gatsby\",\n", - " ],\n", - "})\n", - "df" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "uA9XpV0aiouX" - }, - "source": [ - "You ask LLM to find the oldest movie:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "KzYoX3mRiouX", - "outputId": "1ac50d7b-dfa7-4c16-8daf-aeb03b6df7a5" - }, - "outputs": [ - { - "data": { - "text/plain": [ - "0 Titanic \n", - "\n", - "Name: Movies, dtype: string" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "agg_df = df.semantics.agg(\"Find the oldest movie from {Movies}. Reply with only the movie title\", model=gemini_model)\n", - "agg_df" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "drvn75qJiouX" - }, - "source": [ - "Instead of going through each row one by one, this operator first batches rows to get many aggregation results. It then repeatly batches those results for aggregation, until there is only one value left. You could set the batch size with `max_agg_rows` parameter, which defaults to 10." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "kU7BsyTyiouX" - }, - "source": [ - "## Semantic Top K" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "s9QePXEoiouX" - }, - "source": [ - "Semantic Top K selects the top K values based on your instruction. Here is an example:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "bMQqtyZ2iouX" - }, - "outputs": [], - "source": [ - "df = bpd.DataFrame({\"Animals\": [\"Corgi\", \"Orange Cat\", \"Parrot\", \"Tarantula\"]})" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "KiljGBSCiouX" - }, - "source": [ - "You want to find the top two most popular pets:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 159 - }, - "id": "OZv5WUGIiouX", - "outputId": "ae1cee27-cc31-455e-c4ac-c0a9a5cf4ca5" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Animals
0Corgi
1Orange Cat
\n", - "

2 rows × 1 columns

\n", - "
[2 rows x 1 columns in total]" - ], - "text/plain": [ - " Animals\n", - "0 Corgi\n", - "1 Orange Cat\n", - "\n", - "[2 rows x 1 columns]" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.semantics.top_k(\"{Animals} are more popular as pets\", model=gemini_model, k=2)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "dC8fyu3aiouX" - }, - "source": [ - "Under the hood, the semantic top K operator performs pair-wise comparisons with LLM. The top K results are returned in the order of their indices instead of their ranks." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "sIszJ0zPiouX" - }, - "source": [ - "## Semantic Search" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "e4ojHRKAiouX" - }, - "source": [ - "Semantic search searches the most similar values to your query within a single column. Here is an example:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 253 - }, - "id": "gnQSIZ5SiouX", - "outputId": "dd6e1ecb-1bad-4a7c-8065-e56c697d0863" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
creatures
0salmon
1sea urchin
2baboons
3frog
4chimpanzee
\n", - "

5 rows × 1 columns

\n", - "
[5 rows x 1 columns in total]" - ], - "text/plain": [ - " creatures\n", - "0 salmon\n", - "1 sea urchin\n", - "2 baboons\n", - "3 frog\n", - "4 chimpanzee\n", - "\n", - "[5 rows x 1 columns]" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = bpd.DataFrame({\"creatures\": [\"salmon\", \"sea urchin\", \"baboons\", \"frog\", \"chimpanzee\"]})\n", - "df" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "5apfIaZMiouX" - }, - "source": [ - "You want to get the top 2 creatures that are most similar to \"monkey\":" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 159 - }, - "id": "CkAuFgPYiouY", - "outputId": "723c7604-f53c-43d7-c754-4c91ec198dff" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
creaturessimilarity score
2baboons0.708434
4chimpanzee0.635844
\n", - "

2 rows × 2 columns

\n", - "
[2 rows x 2 columns in total]" - ], - "text/plain": [ - " creatures similarity score\n", - "2 baboons 0.708434\n", - "4 chimpanzee 0.635844\n", - "\n", - "[2 rows x 2 columns]" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.semantics.search(\"creatures\", query=\"monkey\", top_k = 2, model = text_embedding_model, score_column='similarity score')" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "GDZeVzFTiouY" - }, - "source": [ - "Note that you are using a text embedding model this time. This model generates embedding vectors for both your query as well as the values in the search space. The operator then uses BigQuery's built-in VECTOR_SEARCH function to find the nearest neighbors of your query.\n", - "\n", - "In addition, `score_column` is an optional parameter for storing the distances between the results and your query. If not set, the score column won't be attached to the result." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "EXNutIXqiouY" - }, - "source": [ - "## Semantic Similarity Join" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "BhWrhQMjiouY" - }, - "source": [ - "When you want to perform multiple similarity queries in the same value space, you could use similarity join to simplify your call. For example:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "cUc7-8O6iouY" - }, - "outputs": [], - "source": [ - "df1 = bpd.DataFrame({'animal': ['monkey', 'spider', 'salmon', 'giraffe', 'sparrow']})\n", - "df2 = bpd.DataFrame({'animal': ['scorpion', 'baboon', 'owl', 'elephant', 'tuna']})" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "k96WerOviouY" - }, - "source": [ - "In this example, you want to pick the most related animal from `df2` for each value in `df1`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 253 - }, - "id": "wPV5EkfpiouY", - "outputId": "4be1211d-0353-4b94-8c27-ebd568e8e104" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
animalanimal_1distance
0monkeybaboon0.620521
1spiderscorpion0.728024
2salmontuna0.782141
3giraffeelephant0.7135
4sparrowowl0.810864
\n", - "

5 rows × 3 columns

\n", - "
[5 rows x 3 columns in total]" - ], - "text/plain": [ - " animal animal_1 distance\n", - "0 monkey baboon 0.620521\n", - "1 spider scorpion 0.728024\n", - "2 salmon tuna 0.782141\n", - "3 giraffe elephant 0.7135\n", - "4 sparrow owl 0.810864\n", - "\n", - "[5 rows x 3 columns]" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df1.semantics.sim_join(df2, left_on='animal', right_on='animal', top_k=1, model=text_embedding_model, score_column='distance')" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "GplzD7v0iouY" - }, - "source": [ - "!! **Important** Like semantic join, this operator can also be very expensive. To guard against unexpected processing of large dataset, use the `bigframes.options.compute.sem_ops_confirmation_threshold` option to specify a threshold." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "uG6FyMH_iouY" - }, - "source": [ - "## Semantic Cluster" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "uIh3ViNciouY" - }, - "source": [ - "Semantic Cluster group similar values together. For example:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "jyQ_aT9qiouY" - }, - "outputs": [], - "source": [ - "df = bpd.DataFrame({'Product': ['Smartphone', 'Laptop', 'Coffee Maker', 'T-shirt', 'Jeans']})" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "K3IMIFrtiouY" - }, - "source": [ - "You want to cluster these products into 3 groups:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 253 - }, - "id": "0Tc0DqXJiouY", - "outputId": "1c8b6e28-713c-4666-e623-3b2c42c50b30" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ProductCluster ID
0Smartphone1
1Laptop1
2Coffee Maker1
3T-shirt1
4Jeans1
\n", - "

5 rows × 2 columns

\n", - "
[5 rows x 2 columns in total]" - ], - "text/plain": [ - " Product Cluster ID\n", - "0 Smartphone 1\n", - "1 Laptop 1\n", - "2 Coffee Maker 1\n", - "3 T-shirt 1\n", - "4 Jeans 1\n", - "\n", - "[5 rows x 2 columns]" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.semantics.cluster_by(column='Product', output_column='Cluster ID', model=text_embedding_model, n_clusters=3)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "zWIzYX3niouY" - }, - "source": [ - "This operator uses the the embedding model to generate vectors for each value, and then the KMeans algorithm for clustering." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "hgj8GoQhiouY" - }, - "source": [ - "# Performance Analyses" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "EZomL0BciouY" - }, - "source": [ - "In this section, you will use BigQuery's public data of hacker news to perform some heavy work. We recommend you to check the code without executing them in order to save your time and money. The execution results are attached after each cell for your reference.\n", - "\n", - "First, load 3k rows from the table:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 880 - }, - "id": "wRR0SrcSiouY", - "outputId": "3b25f3a3-09c7-4396-9107-4aa4cdb4b963" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptype
0<NA>Well, most people aren&#x27;t alcoholics, so I...slipframe<NA>2021-06-26 02:37:56+00:00comment
1<NA>No, you don&#x27;t really <i>need</i> a smartp...vetinari<NA>2023-04-19 15:56:34+00:00comment
2<NA>It&#x27;s for the late Paul Allen RIP. Should&...lsr_ssri<NA>2018-10-16 01:07:55+00:00comment
3<NA>Yup they are dangerous. Be careful Donald Trump.Sven7<NA>2015-08-10 16:05:54+00:00comment
4<NA>Sure, it&#x27;s totally reasonable. Just point...nicoburns<NA>2020-10-05 11:20:51+00:00comment
5<NA>I wonder how long before special forces start ...autisticcurio<NA>2020-09-01 15:38:50+00:00comment
6The Impending NY Tech Apocalypse: Here's What ...<NA>gaoprea32011-09-27 22:43:27+00:00story
7<NA>Where would you relocate to? I'm assuming that...pavel_lishin<NA>2011-09-16 19:02:01+00:00comment
8Eureca beta is live. A place for your business...<NA>ricardos12012-10-15 13:09:32+00:00story
9<NA>It doesn’t work on Safari, and WebKit based br...archiewood<NA>2023-04-21 16:45:13+00:00comment
10<NA>I guess I don’t see the relevance. Vegans eat ...stevula<NA>2023-01-19 20:05:54+00:00comment
11<NA>I remember watching the American news media go...fareesh<NA>2019-06-17 19:49:17+00:00comment
12<NA>This article is incorrectly using the current ...stale2002<NA>2018-03-18 18:57:21+00:00comment
13<NA>In the firm I made my internship, we have to u...iserlohnmage<NA>2019-10-22 10:41:01+00:00comment
14<NA>The main reason it requires unsafe is for memo...comex<NA>2017-05-05 20:45:37+00:00comment
15Discord vs. IRC Rough Notes<NA>todsacerdoti482024-07-12 18:39:52+00:00story
16<NA>you have to auth again when you use apple pay.empath75<NA>2017-09-12 18:58:20+00:00comment
17<NA>It goes consumer grade, automotive, military, ...moftz<NA>2021-04-13 01:24:03+00:00comment
18<NA>I don&#x27;t have a link handy but the differe...KennyBlanken<NA>2022-05-13 16:08:38+00:00comment
19<NA>&gt; I don&#x27;t think the use case you menti...colanderman<NA>2017-09-28 05:16:06+00:00comment
20<NA>I think you need to watch it again, because yo...vladimirralev<NA>2018-12-07 11:25:52+00:00comment
21Oh dear: new Yahoo anti-spoofing measures brea...<NA>joshreads12014-04-08 13:29:50+00:00story
22How Much Warmer Was Your City in 2016?<NA>smb0612017-02-16 23:26:34+00:00story
23<NA>Except that they clearly never tried to incent...aenis<NA>2022-01-31 17:08:57+00:00comment
24Working Best at Coffee Shops<NA>GiraffeNecktie2492011-04-19 14:25:17+00:00story
\n", - "

25 rows × 6 columns

\n", - "
[3000 rows x 6 columns in total]" - ], - "text/plain": [ - " title \\\n", - "0 \n", - "1 \n", - "2 \n", - "3 \n", - "4 \n", - "5 \n", - "6 The Impending NY Tech Apocalypse: Here's What ... \n", - "7 \n", - "8 Eureca beta is live. A place for your business... \n", - "9 \n", - "10 \n", - "11 \n", - "12 \n", - "13 \n", - "14 \n", - "15 Discord vs. IRC Rough Notes \n", - "16 \n", - "17 \n", - "18 \n", - "19 \n", - "20 \n", - "21 Oh dear: new Yahoo anti-spoofing measures brea... \n", - "22 How Much Warmer Was Your City in 2016? \n", - "23 \n", - "24 Working Best at Coffee Shops \n", - "\n", - " text by score \\\n", - "0 Well, most people aren't alcoholics, so I... slipframe \n", - "1 No, you don't really need a smartp... vetinari \n", - "2 It's for the late Paul Allen RIP. Should&... lsr_ssri \n", - "3 Yup they are dangerous. Be careful Donald Trump. Sven7 \n", - "4 Sure, it's totally reasonable. Just point... nicoburns \n", - "5 I wonder how long before special forces start ... autisticcurio \n", - "6 gaoprea 3 \n", - "7 Where would you relocate to? I'm assuming that... pavel_lishin \n", - "8 ricardos 1 \n", - "9 It doesn’t work on Safari, and WebKit based br... archiewood \n", - "10 I guess I don’t see the relevance. Vegans eat ... stevula \n", - "11 I remember watching the American news media go... fareesh \n", - "12 This article is incorrectly using the current ... stale2002 \n", - "13 In the firm I made my internship, we have to u... iserlohnmage \n", - "14 The main reason it requires unsafe is for memo... comex \n", - "15 todsacerdoti 48 \n", - "16 you have to auth again when you use apple pay. empath75 \n", - "17 It goes consumer grade, automotive, military, ... moftz \n", - "18 I don't have a link handy but the differe... KennyBlanken \n", - "19 > I don't think the use case you menti... colanderman \n", - "20 I think you need to watch it again, because yo... vladimirralev \n", - "21 joshreads 1 \n", - "22 smb06 1 \n", - "23 Except that they clearly never tried to incent... aenis \n", - "24 GiraffeNecktie 249 \n", - "\n", - " timestamp type \n", - "0 2021-06-26 02:37:56+00:00 comment \n", - "1 2023-04-19 15:56:34+00:00 comment \n", - "2 2018-10-16 01:07:55+00:00 comment \n", - "3 2015-08-10 16:05:54+00:00 comment \n", - "4 2020-10-05 11:20:51+00:00 comment \n", - "5 2020-09-01 15:38:50+00:00 comment \n", - "6 2011-09-27 22:43:27+00:00 story \n", - "7 2011-09-16 19:02:01+00:00 comment \n", - "8 2012-10-15 13:09:32+00:00 story \n", - "9 2023-04-21 16:45:13+00:00 comment \n", - "10 2023-01-19 20:05:54+00:00 comment \n", - "11 2019-06-17 19:49:17+00:00 comment \n", - "12 2018-03-18 18:57:21+00:00 comment \n", - "13 2019-10-22 10:41:01+00:00 comment \n", - "14 2017-05-05 20:45:37+00:00 comment \n", - "15 2024-07-12 18:39:52+00:00 story \n", - "16 2017-09-12 18:58:20+00:00 comment \n", - "17 2021-04-13 01:24:03+00:00 comment \n", - "18 2022-05-13 16:08:38+00:00 comment \n", - "19 2017-09-28 05:16:06+00:00 comment \n", - "20 2018-12-07 11:25:52+00:00 comment \n", - "21 2014-04-08 13:29:50+00:00 story \n", - "22 2017-02-16 23:26:34+00:00 story \n", - "23 2022-01-31 17:08:57+00:00 comment \n", - "24 2011-04-19 14:25:17+00:00 story \n", - "...\n", - "\n", - "[3000 rows x 6 columns]" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news = bpd.read_gbq(\"bigquery-public-data.hacker_news.full\")[['title', 'text', 'by', 'score', 'timestamp', 'type']].head(3000)\n", - "hacker_news" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "3e94DPOdiouY" - }, - "source": [ - "Then, keep only the rows that have text content:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "mQl8hc1biouY", - "outputId": "2b4ffa85-9d95-4a20-9040-0420c67da2d4" - }, - "outputs": [ - { - "data": { - "text/plain": [ - "2556" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news_with_texts = hacker_news[hacker_news['text'].isnull() == False]\n", - "len(hacker_news_with_texts)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "JWalDtLDiouZ" - }, - "source": [ - "You can get an idea of the input token length by calculating the average string length." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "PZeg4LCUiouZ", - "outputId": "05b67cac-6b3d-42ef-d6d6-b578a9734f4c" - }, - "outputs": [ - { - "data": { - "text/plain": [ - "390.05125195618155" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news_with_texts['text'].str.len().mean()" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "2IXqskHHiouZ" - }, - "source": [ - "**Optional**: You can raise the confirmation threshold for a smoother experience." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "EpjXQ4FViouZ" - }, - "outputs": [], - "source": [ - "if Version(bigframes.__version__) >= Version(\"1.31.0\"):\n", - " bigframes.options.compute.semantic_ops_confirmation_threshold = 5000" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "SYFB-X1RiouZ" - }, - "source": [ - "Now it's LLM's turn. You want to keep only the rows whose texts are talking about iPhone. This will take several minutes to finish." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 253 - }, - "id": "rditQlmoiouZ", - "outputId": "2b44dcbf-2ef5-4119-ca05-9b082db9c0c1" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptype
9<NA>It doesn’t work on Safari, and WebKit based br...archiewood<NA>2023-04-21 16:45:13+00:00comment
420<NA>Well last time I got angry down votes for sayi...drieddust<NA>2021-01-11 19:27:27+00:00comment
815<NA>New iPhone should be announced on September. L...meerita<NA>2019-07-30 20:54:42+00:00comment
1516<NA>Why would this take a week? i(phone)OS was ori...TheOtherHobbes<NA>2021-06-08 09:25:24+00:00comment
1563<NA>&gt;or because Apple drama brings many clicks?...weberer<NA>2022-09-05 13:16:02+00:00comment
\n", - "

5 rows × 6 columns

\n", - "
[5 rows x 6 columns in total]" - ], - "text/plain": [ - " title text by \\\n", - "9 It doesn’t work on Safari, and WebKit based br... archiewood \n", - "420 Well last time I got angry down votes for sayi... drieddust \n", - "815 New iPhone should be announced on September. L... meerita \n", - "1516 Why would this take a week? i(phone)OS was ori... TheOtherHobbes \n", - "1563 >or because Apple drama brings many clicks?... weberer \n", - "\n", - " score timestamp type \n", - "9 2023-04-21 16:45:13+00:00 comment \n", - "420 2021-01-11 19:27:27+00:00 comment \n", - "815 2019-07-30 20:54:42+00:00 comment \n", - "1516 2021-06-08 09:25:24+00:00 comment \n", - "1563 2022-09-05 13:16:02+00:00 comment \n", - "\n", - "[5 rows x 6 columns]" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "iphone_comments = hacker_news_with_texts.semantics.filter(\"The {text} is mainly focused on iPhone\", gemini_model)\n", - "iphone_comments" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "yl24sJFIiouZ" - }, + "metadata": {}, "source": [ - "The performance of the semantic operators depends on the length of your input as well as your quota. Here are our benchmarks for running the previous operation over data of different sizes. Here are the estimates supposing your quota is [the default 200 requests per minute](https://cloud.google.com/vertex-ai/generative-ai/docs/quotas):\n", + "Semantic Operators have been deprecated since version 1.42.0. Please use AI functions instead.\n", "\n", - "* 800 Rows -> ~4m\n", - "* 2550 Rows -> ~13m\n", - "* 8500 Rows -> ~40m\n", - "\n", - "These numbers can give you a general idea of how fast the operators run." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "eo4nfISuiouZ" - }, - "source": [ - "Now, use LLM to summarize the sentiments towards iPhone:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 253 - }, - "id": "IlKBrNxUiouZ", - "outputId": "818d01e4-1cdf-42a2-9e02-61c4736a8905" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptypesentiment
9<NA>It doesn’t work on Safari, and WebKit based br...archiewood<NA>2023-04-21 16:45:13+00:00commentFrustrated, but hopeful.
420<NA>Well last time I got angry down votes for sayi...drieddust<NA>2021-01-11 19:27:27+00:00commentFrustrated and angry.
815<NA>New iPhone should be announced on September. L...meerita<NA>2019-07-30 20:54:42+00:00commentExcited anticipation.
1516<NA>Why would this take a week? i(phone)OS was ori...TheOtherHobbes<NA>2021-06-08 09:25:24+00:00commentFrustrated, critical, obvious.
1563<NA>&gt;or because Apple drama brings many clicks?...weberer<NA>2022-09-05 13:16:02+00:00commentNegative, clickbait, Apple.
\n", - "

5 rows × 7 columns

\n", - "
[5 rows x 7 columns in total]" - ], - "text/plain": [ - " title text by \\\n", - "9 It doesn’t work on Safari, and WebKit based br... archiewood \n", - "420 Well last time I got angry down votes for sayi... drieddust \n", - "815 New iPhone should be announced on September. L... meerita \n", - "1516 Why would this take a week? i(phone)OS was ori... TheOtherHobbes \n", - "1563 >or because Apple drama brings many clicks?... weberer \n", - "\n", - " score timestamp type \\\n", - "9 2023-04-21 16:45:13+00:00 comment \n", - "420 2021-01-11 19:27:27+00:00 comment \n", - "815 2019-07-30 20:54:42+00:00 comment \n", - "1516 2021-06-08 09:25:24+00:00 comment \n", - "1563 2022-09-05 13:16:02+00:00 comment \n", - "\n", - " sentiment \n", - "9 Frustrated, but hopeful. \n", - " \n", - "420 Frustrated and angry. \n", - " \n", - "815 Excited anticipation. \n", - " \n", - "1516 Frustrated, critical, obvious. \n", - " \n", - "1563 Negative, clickbait, Apple. \n", - " \n", - "\n", - "[5 rows x 7 columns]" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "iphone_comments.semantics.map(\"Summarize the sentiment of the {text}. Your answer should have at most 3 words\", output_column=\"sentiment\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "y7_16T2xiouZ" - }, - "source": [ - "Here is another example: count the number of rows whose authors have animals in their names." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 880 - }, - "id": "CbGwc_uXiouZ", - "outputId": "138acca0-7fb9-495a-e797-0d42495d65e6" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptype
0<NA>Well, most people aren&#x27;t alcoholics, so I...slipframe<NA>2021-06-26 02:37:56+00:00comment
1<NA>No, you don&#x27;t really <i>need</i> a smartp...vetinari<NA>2023-04-19 15:56:34+00:00comment
2<NA>It&#x27;s for the late Paul Allen RIP. Should&...lsr_ssri<NA>2018-10-16 01:07:55+00:00comment
3<NA>Yup they are dangerous. Be careful Donald Trump.Sven7<NA>2015-08-10 16:05:54+00:00comment
4<NA>Sure, it&#x27;s totally reasonable. Just point...nicoburns<NA>2020-10-05 11:20:51+00:00comment
5<NA>I wonder how long before special forces start ...autisticcurio<NA>2020-09-01 15:38:50+00:00comment
6The Impending NY Tech Apocalypse: Here's What ...<NA>gaoprea32011-09-27 22:43:27+00:00story
7<NA>Where would you relocate to? I'm assuming that...pavel_lishin<NA>2011-09-16 19:02:01+00:00comment
8Eureca beta is live. A place for your business...<NA>ricardos12012-10-15 13:09:32+00:00story
9<NA>It doesn’t work on Safari, and WebKit based br...archiewood<NA>2023-04-21 16:45:13+00:00comment
10<NA>I guess I don’t see the relevance. Vegans eat ...stevula<NA>2023-01-19 20:05:54+00:00comment
11<NA>I remember watching the American news media go...fareesh<NA>2019-06-17 19:49:17+00:00comment
12<NA>This article is incorrectly using the current ...stale2002<NA>2018-03-18 18:57:21+00:00comment
13<NA>In the firm I made my internship, we have to u...iserlohnmage<NA>2019-10-22 10:41:01+00:00comment
14<NA>The main reason it requires unsafe is for memo...comex<NA>2017-05-05 20:45:37+00:00comment
15Discord vs. IRC Rough Notes<NA>todsacerdoti482024-07-12 18:39:52+00:00story
16<NA>you have to auth again when you use apple pay.empath75<NA>2017-09-12 18:58:20+00:00comment
17<NA>It goes consumer grade, automotive, military, ...moftz<NA>2021-04-13 01:24:03+00:00comment
18<NA>I don&#x27;t have a link handy but the differe...KennyBlanken<NA>2022-05-13 16:08:38+00:00comment
19<NA>&gt; I don&#x27;t think the use case you menti...colanderman<NA>2017-09-28 05:16:06+00:00comment
20<NA>I think you need to watch it again, because yo...vladimirralev<NA>2018-12-07 11:25:52+00:00comment
21Oh dear: new Yahoo anti-spoofing measures brea...<NA>joshreads12014-04-08 13:29:50+00:00story
22How Much Warmer Was Your City in 2016?<NA>smb0612017-02-16 23:26:34+00:00story
23<NA>Except that they clearly never tried to incent...aenis<NA>2022-01-31 17:08:57+00:00comment
24Working Best at Coffee Shops<NA>GiraffeNecktie2492011-04-19 14:25:17+00:00story
\n", - "

25 rows × 6 columns

\n", - "
[3000 rows x 6 columns in total]" - ], - "text/plain": [ - " title \\\n", - "0 \n", - "1 \n", - "2 \n", - "3 \n", - "4 \n", - "5 \n", - "6 The Impending NY Tech Apocalypse: Here's What ... \n", - "7 \n", - "8 Eureca beta is live. A place for your business... \n", - "9 \n", - "10 \n", - "11 \n", - "12 \n", - "13 \n", - "14 \n", - "15 Discord vs. IRC Rough Notes \n", - "16 \n", - "17 \n", - "18 \n", - "19 \n", - "20 \n", - "21 Oh dear: new Yahoo anti-spoofing measures brea... \n", - "22 How Much Warmer Was Your City in 2016? \n", - "23 \n", - "24 Working Best at Coffee Shops \n", - "\n", - " text by score \\\n", - "0 Well, most people aren't alcoholics, so I... slipframe \n", - "1 No, you don't really need a smartp... vetinari \n", - "2 It's for the late Paul Allen RIP. Should&... lsr_ssri \n", - "3 Yup they are dangerous. Be careful Donald Trump. Sven7 \n", - "4 Sure, it's totally reasonable. Just point... nicoburns \n", - "5 I wonder how long before special forces start ... autisticcurio \n", - "6 gaoprea 3 \n", - "7 Where would you relocate to? I'm assuming that... pavel_lishin \n", - "8 ricardos 1 \n", - "9 It doesn’t work on Safari, and WebKit based br... archiewood \n", - "10 I guess I don’t see the relevance. Vegans eat ... stevula \n", - "11 I remember watching the American news media go... fareesh \n", - "12 This article is incorrectly using the current ... stale2002 \n", - "13 In the firm I made my internship, we have to u... iserlohnmage \n", - "14 The main reason it requires unsafe is for memo... comex \n", - "15 todsacerdoti 48 \n", - "16 you have to auth again when you use apple pay. empath75 \n", - "17 It goes consumer grade, automotive, military, ... moftz \n", - "18 I don't have a link handy but the differe... KennyBlanken \n", - "19 > I don't think the use case you menti... colanderman \n", - "20 I think you need to watch it again, because yo... vladimirralev \n", - "21 joshreads 1 \n", - "22 smb06 1 \n", - "23 Except that they clearly never tried to incent... aenis \n", - "24 GiraffeNecktie 249 \n", - "\n", - " timestamp type \n", - "0 2021-06-26 02:37:56+00:00 comment \n", - "1 2023-04-19 15:56:34+00:00 comment \n", - "2 2018-10-16 01:07:55+00:00 comment \n", - "3 2015-08-10 16:05:54+00:00 comment \n", - "4 2020-10-05 11:20:51+00:00 comment \n", - "5 2020-09-01 15:38:50+00:00 comment \n", - "6 2011-09-27 22:43:27+00:00 story \n", - "7 2011-09-16 19:02:01+00:00 comment \n", - "8 2012-10-15 13:09:32+00:00 story \n", - "9 2023-04-21 16:45:13+00:00 comment \n", - "10 2023-01-19 20:05:54+00:00 comment \n", - "11 2019-06-17 19:49:17+00:00 comment \n", - "12 2018-03-18 18:57:21+00:00 comment \n", - "13 2019-10-22 10:41:01+00:00 comment \n", - "14 2017-05-05 20:45:37+00:00 comment \n", - "15 2024-07-12 18:39:52+00:00 story \n", - "16 2017-09-12 18:58:20+00:00 comment \n", - "17 2021-04-13 01:24:03+00:00 comment \n", - "18 2022-05-13 16:08:38+00:00 comment \n", - "19 2017-09-28 05:16:06+00:00 comment \n", - "20 2018-12-07 11:25:52+00:00 comment \n", - "21 2014-04-08 13:29:50+00:00 story \n", - "22 2017-02-16 23:26:34+00:00 story \n", - "23 2022-01-31 17:08:57+00:00 comment \n", - "24 2011-04-19 14:25:17+00:00 story \n", - "...\n", - "\n", - "[3000 rows x 6 columns]" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news = bpd.read_gbq(\"bigquery-public-data.hacker_news.full\")[['title', 'text', 'by', 'score', 'timestamp', 'type']].head(3000)\n", - "hacker_news" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 880 - }, - "id": "9dzU8SNziouZ", - "outputId": "da8815c1-c411-4afc-d1ca-5e44c75b5b48" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptype
24Working Best at Coffee Shops<NA>GiraffeNecktie2492011-04-19 14:25:17+00:00story
98<NA>i resisted switching to chrome for months beca...catshirt<NA>2011-04-06 08:02:24+00:00comment
137FDA reverses marketing ban on Juul e-cigarettes<NA>anigbrowl22024-06-06 16:42:40+00:00story
188<NA>I think it&#x27;s more than hazing. It may be ...bayesianhorse<NA>2015-06-18 16:42:53+00:00comment
209<NA>I like the idea of moving that arrow the way h...rattray<NA>2015-06-08 02:15:30+00:00comment
228<NA>I don&#x27;t understand why a beginner would s...wolco<NA>2019-02-03 14:35:43+00:00comment
290<NA>I leaerned more with one minute of this than a...agumonkey<NA>2016-07-16 06:19:39+00:00comment
303<NA>I've suggested a <i>rationale</i> for the tabo...mechanical_fish<NA>2008-12-17 04:42:02+00:00comment
312<NA>Do you have any reference for this?<p>I&#x27;m...banashark<NA>2023-11-13 19:57:00+00:00comment
322<NA>Default search scope is an option in the Finde...kitsunesoba<NA>2017-08-13 17:15:19+00:00comment
391<NA>Orthogonality and biology aren&#x27;t friends.agumonkey<NA>2016-04-24 16:33:41+00:00comment
396<NA>I chose some random physics book that was good...prawn<NA>2011-03-27 22:29:51+00:00comment
424<NA>Seeing this get huge on Twitter. It&#x27;s the...shenanigoat<NA>2016-01-09 03:04:22+00:00comment
428<NA>Looking through the comments there are a numbe...moomin<NA>2024-10-01 14:37:04+00:00comment
429<NA>Legacy media is a tough business. GBTC is payi...arcticbull<NA>2021-04-16 16:30:33+00:00comment
436<NA>Same thing if you sell unsafe food, yet we hav...jabradoodle<NA>2023-08-03 20:47:52+00:00comment
438<NA>There was briefly a thing called HSCSD (&quot;...LeoPanthera<NA>2019-02-11 19:49:29+00:00comment
446<NA>&gt; This article is a bit comical to read and...lapcat<NA>2023-01-02 16:00:49+00:00comment
453<NA>Large positions are most likely sold off in sm...meowkit<NA>2021-01-27 23:22:48+00:00comment
507<NA>A US-based VPN (or really any VPN) is only goi...RandomBacon<NA>2019-04-05 00:58:58+00:00comment
543<NA><a href=\"https:&#x2F;&#x2F;codeberg.org&#x2F;A...ElectronBadger<NA>2023-12-13 08:13:15+00:00comment
565<NA>It’s much harder for people without hands to w...Aeolun<NA>2024-05-03 11:58:13+00:00comment
612<NA>So by using ADMIN_SL0T instead was it just set...minitoar<NA>2021-03-05 16:07:56+00:00comment
660<NA>Outstanding!cafard<NA>2022-06-09 09:51:54+00:00comment
673<NA>On the other hand, something can be said for &...babby<NA>2013-08-12 00:31:02+00:00comment
\n", - "

25 rows × 6 columns

\n", - "
[123 rows x 6 columns in total]" - ], - "text/plain": [ - " title \\\n", - "24 Working Best at Coffee Shops \n", - "98 \n", - "137 FDA reverses marketing ban on Juul e-cigarettes \n", - "188 \n", - "209 \n", - "228 \n", - "290 \n", - "303 \n", - "312 \n", - "322 \n", - "391 \n", - "396 \n", - "424 \n", - "428 \n", - "429 \n", - "436 \n", - "438 \n", - "446 \n", - "453 \n", - "507 \n", - "543 \n", - "565 \n", - "612 \n", - "660 \n", - "673 \n", - "\n", - " text by \\\n", - "24 GiraffeNecktie \n", - "98 i resisted switching to chrome for months beca... catshirt \n", - "137 anigbrowl \n", - "188 I think it's more than hazing. It may be ... bayesianhorse \n", - "209 I like the idea of moving that arrow the way h... rattray \n", - "228 I don't understand why a beginner would s... wolco \n", - "290 I leaerned more with one minute of this than a... agumonkey \n", - "303 I've suggested a rationale for the tabo... mechanical_fish \n", - "312 Do you have any reference for this?

I'm... banashark \n", - "322 Default search scope is an option in the Finde... kitsunesoba \n", - "391 Orthogonality and biology aren't friends. agumonkey \n", - "396 I chose some random physics book that was good... prawn \n", - "424 Seeing this get huge on Twitter. It's the... shenanigoat \n", - "428 Looking through the comments there are a numbe... moomin \n", - "429 Legacy media is a tough business. GBTC is payi... arcticbull \n", - "436 Same thing if you sell unsafe food, yet we hav... jabradoodle \n", - "438 There was briefly a thing called HSCSD ("... LeoPanthera \n", - "446 > This article is a bit comical to read and... lapcat \n", - "453 Large positions are most likely sold off in sm... meowkit \n", - "507 A US-based VPN (or really any VPN) is only goi... RandomBacon \n", - "543 2011-04-06 08:02:24+00:00 comment \n", - "137 2 2024-06-06 16:42:40+00:00 story \n", - "188 2015-06-18 16:42:53+00:00 comment \n", - "209 2015-06-08 02:15:30+00:00 comment \n", - "228 2019-02-03 14:35:43+00:00 comment \n", - "290 2016-07-16 06:19:39+00:00 comment \n", - "303 2008-12-17 04:42:02+00:00 comment \n", - "312 2023-11-13 19:57:00+00:00 comment \n", - "322 2017-08-13 17:15:19+00:00 comment \n", - "391 2016-04-24 16:33:41+00:00 comment \n", - "396 2011-03-27 22:29:51+00:00 comment \n", - "424 2016-01-09 03:04:22+00:00 comment \n", - "428 2024-10-01 14:37:04+00:00 comment \n", - "429 2021-04-16 16:30:33+00:00 comment \n", - "436 2023-08-03 20:47:52+00:00 comment \n", - "438 2019-02-11 19:49:29+00:00 comment \n", - "446 2023-01-02 16:00:49+00:00 comment \n", - "453 2021-01-27 23:22:48+00:00 comment \n", - "507 2019-04-05 00:58:58+00:00 comment \n", - "543 2023-12-13 08:13:15+00:00 comment \n", - "565 2024-05-03 11:58:13+00:00 comment \n", - "612 2021-03-05 16:07:56+00:00 comment \n", - "660 2022-06-09 09:51:54+00:00 comment \n", - "673 2013-08-12 00:31:02+00:00 comment \n", - "...\n", - "\n", - "[123 rows x 6 columns]" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news.semantics.filter(\"{by} contains animal name\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "3bpkaspoiouZ" - }, - "source": [ - "Here are the runtime numbers with 500 requests per minute [raised quota](https://cloud.google.com/vertex-ai/generative-ai/docs/quotas):\n", - "* 3000 rows -> ~6m\n", - "* 10000 rows -> ~26m" + "The tutorial notebook for AI functions is located at https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/ai_functions.ipynb" ] } ], @@ -3206,7 +53,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.15" + "version": "3.11.9" } }, "nbformat": 4, diff --git a/notebooks/generative_ai/ai_functions.ipynb b/notebooks/generative_ai/ai_functions.ipynb new file mode 100644 index 0000000000..3783ad8365 --- /dev/null +++ b/notebooks/generative_ai/ai_functions.ipynb @@ -0,0 +1,555 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "acd53f9d", + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "id": "e75ce682", + "metadata": {}, + "source": [ + "# BigQuery DataFrames (BigFrames) AI Functions\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "aee05821", + "metadata": {}, + "source": [ + "This notebook provides a brief introduction to AI functions in BigQuery Dataframes." + ] + }, + { + "cell_type": "markdown", + "id": "1232f400", + "metadata": {}, + "source": [ + "## Preparation\n", + "\n", + "First, set up your BigFrames environment:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c9f924aa", + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd \n", + "\n", + "PROJECT_ID = \"\" # Your project ID here\n", + "\n", + "bpd.options.bigquery.project = PROJECT_ID\n", + "bpd.options.bigquery.ordering_mode = \"partial\"\n", + "bpd.options.display.progress_bar = None" + ] + }, + { + "cell_type": "markdown", + "id": "e2188773", + "metadata": {}, + "source": [ + "## ai.generate\n", + "\n", + "The `ai.generate` function lets you analyze any combination of text and unstructured data from BigQuery. You can mix BigFrames or Pandas series with string literals as your prompt in the form of a tuple. You are also allowed to provide only a series. Here is an example:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "471a47fe", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/global_session.py:103: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", + " _global_session = bigframes.session.connect(\n" + ] + }, + { + "data": { + "text/plain": [ + "0 {'result': 'Salad\\n', 'full_response': '{\"cand...\n", + "1 {'result': 'Sausageroll\\n', 'full_response': '...\n", + "dtype: struct>, status: string>[pyarrow]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import bigframes.bigquery as bbq\n", + "\n", + "ingredients1 = bpd.Series([\"Lettuce\", \"Sausage\"])\n", + "ingredients2 = bpd.Series([\"Cucumber\", \"Long Bread\"])\n", + "\n", + "prompt = (\"What's the food made from \", ingredients1, \" and \", ingredients2, \" One word only\")\n", + "bbq.ai.generate(prompt)" + ] + }, + { + "cell_type": "markdown", + "id": "03953835", + "metadata": {}, + "source": [ + "The function returns a series of structs. The `'result'` field holds the answer, while more metadata can be found in the `'full_response'` field. The `'status'` field tells you whether LLM made a successful response for that specific row. " + ] + }, + { + "cell_type": "markdown", + "id": "b606c51f", + "metadata": {}, + "source": [ + "You can also include additional model parameters into your function call, as long as they conform to the structure of `generateContent` [request body format](https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.endpoints/generateContent#request-body). In the next example, you use `maxOutputTokens` to limit the length of the generated content." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "4a3229a8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 Lettuce\n", + "1 The food\n", + "Name: result, dtype: string" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model_params = {\n", + " \"generationConfig\": {\"maxOutputTokens\": 2}\n", + "}\n", + "\n", + "ingredients1 = bpd.Series([\"Lettuce\", \"Sausage\"])\n", + "ingredients2 = bpd.Series([\"Cucumber\", \"Long Bread\"])\n", + "\n", + "prompt = (\"What's the food made from \", ingredients1, \" and \", ingredients2)\n", + "bbq.ai.generate(prompt, model_params=model_params).struct.field(\"result\")" + ] + }, + { + "cell_type": "markdown", + "id": "3acba92d", + "metadata": {}, + "source": [ + "The answers are cut short as expected.\n", + "\n", + "In addition to `ai.generate`, you can use `ai.generate_bool`, `ai.generate_int`, and `ai.generate_double` for other output types." + ] + }, + { + "cell_type": "markdown", + "id": "0bf9f1de", + "metadata": {}, + "source": [ + "## ai.if_\n", + "\n", + "`ai.if_` generates a series of booleans. It's a handy tool for joining and filtering your data, not only because it directly returns boolean values, but also because it provides more optimization during data processing. Here is an example of using `ai.if_`:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "718c6622", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

[2 rows x 2 columns in total]" + ], + "text/plain": [ + "creature category\n", + " Cat mammal\n", + " Salmon fish\n", + "\n", + "[2 rows x 2 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "creatures = bpd.DataFrame({\"creature\": [\"Cat\", \"Salmon\"]})\n", + "categories = bpd.DataFrame({\"category\": [\"mammal\", \"fish\"]})\n", + "\n", + "joined_df = creatures.merge(categories, how=\"cross\")\n", + "condition = bbq.ai.if_((joined_df[\"creature\"], \" is a \", joined_df[\"category\"]))\n", + "\n", + "# Filter our dataframe\n", + "joined_df = joined_df[condition]\n", + "joined_df" + ] + }, + { + "cell_type": "markdown", + "id": "bb0999df", + "metadata": {}, + "source": [ + "## ai.score" + ] + }, + { + "cell_type": "markdown", + "id": "63b5a59f", + "metadata": {}, + "source": [ + "`ai.score` ranks your input based on the prompt and assigns a double value (i.e. a score) to each item. You can then sort your data based on their scores. For example:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "6875fe36", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
animalsrelative_weight
1spider1.0
0tiger8.0
2blue whale10.0
\n", + "

3 rows × 2 columns

\n", + "
[3 rows x 2 columns in total]" + ], + "text/plain": [ + " animals relative_weight\n", + "1 spider 1.0\n", + "0 tiger 8.0\n", + "2 blue whale 10.0\n", + "\n", + "[3 rows x 2 columns]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.DataFrame({'animals': ['tiger', 'spider', 'blue whale']})\n", + "\n", + "df['relative_weight'] = bbq.ai.score((\"Rank the relative weight of \", df['animals'], \" on the scale from 1 to 10\"))\n", + "df.sort_values(by='relative_weight')" + ] + }, + { + "cell_type": "markdown", + "id": "1ed0dff1", + "metadata": {}, + "source": [ + "## ai.classify" + ] + }, + { + "cell_type": "markdown", + "id": "c56b91cf", + "metadata": {}, + "source": [ + "`ai.classify` categories your inputs into the specified categories. " + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8cfb844b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
animalcategory
0tigermammal
1spideranthropod
2blue whalemammal
3salmonfish
\n", + "

4 rows × 2 columns

\n", + "
[4 rows x 2 columns in total]" + ], + "text/plain": [ + " animal category\n", + "0 tiger mammal\n", + "1 spider anthropod\n", + "2 blue whale mammal\n", + "3 salmon fish\n", + "\n", + "[4 rows x 2 columns]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.DataFrame({'animal': ['tiger', 'spider', 'blue whale', 'salmon']})\n", + "\n", + "df['category'] = bbq.ai.classify(df['animal'], categories=['mammal', 'fish', 'anthropod'])\n", + "df" + ] + }, + { + "cell_type": "markdown", + "id": "9e4037bc", + "metadata": {}, + "source": [ + "Note that this function can only return the values that are provided in the `categories` argument. If your categories do not cover all cases, your may get wrong answers:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2e66110a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
animalcategory
0tigermammal
1spidermammal
\n", + "

2 rows × 2 columns

\n", + "
[2 rows x 2 columns in total]" + ], + "text/plain": [ + " animal category\n", + "0 tiger mammal\n", + "1 spider mammal\n", + "\n", + "[2 rows x 2 columns]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.DataFrame({'animal': ['tiger', 'spider']})\n", + "\n", + "df['category'] = bbq.ai.classify(df['animal'], categories=['mammal', 'fish']) # Spider belongs to neither category\n", + "df" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv (3.10.17)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.17" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb b/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb new file mode 100644 index 0000000000..b9599282b3 --- /dev/null +++ b/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb @@ -0,0 +1,901 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# BigFrames AI Forecast\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This Notebook introduces forecasting with GenAI Fundation Model with BigFrames AI." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "PROJECT = \"bigframes-dev\" # replace with your project\n", + "\n", + "import bigframes.pandas as bpd\n", + "bpd.options.bigquery.project = PROJECT\n", + "bpd.options.display.progress_bar = None\n", + "\n", + "# Optional, but recommended: partial ordering mode can accelerate executions and save costs.\n", + "bpd.options.bigquery.ordering_mode = \"partial\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1. Create a BigFrames DataFrames from BigQuery public data." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
trip_idduration_secstart_datestart_station_namestart_station_idend_dateend_station_nameend_station_idbike_numberzip_code...c_subscription_typestart_station_latitudestart_station_longitudeend_station_latitudeend_station_longitudemember_birth_yearmember_genderbike_share_for_all_tripstart_station_geomend_station_geom
0201712151647221445012017-12-15 16:47:22+00:0010th St at Fallon St2012017-12-15 16:55:44+00:0010th Ave at E 15th St222144<NA>...<NA>37.797673-122.26299737.792714-122.248781984Male<NA>POINT (-122.263 37.79767)POINT (-122.24878 37.79271)
12017080523460515857122017-08-05 23:46:05+00:0010th St at Fallon St2012017-08-05 23:57:57+00:0010th Ave at E 15th St2221585<NA>...<NA>37.797673-122.26299737.792714-122.24878<NA><NA><NA>POINT (-122.263 37.79767)POINT (-122.24878 37.79271)
22017111114472028802722017-11-11 14:47:20+00:0012th St at 4th Ave2332017-11-11 14:51:53+00:0010th Ave at E 15th St2222880<NA>...<NA>37.795812-122.25555537.792714-122.248781965Female<NA>POINT (-122.25555 37.79581)POINT (-122.24878 37.79271)
32018042517262737557572018-04-25 17:26:27+00:0013th St at Franklin St3382018-04-25 17:39:05+00:0010th Ave at E 15th St2223755<NA>...<NA>37.803189-122.27057937.792714-122.248781982OtherNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
42018040815560118311052018-04-08 15:56:01+00:0013th St at Franklin St3382018-04-08 16:14:26+00:0010th Ave at E 15th St222183<NA>...<NA>37.803189-122.27057937.792714-122.248781987FemaleNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
52018041916485015608572018-04-19 16:48:50+00:0013th St at Franklin St3382018-04-19 17:03:08+00:0010th Ave at E 15th St2221560<NA>...<NA>37.803189-122.27057937.792714-122.248781982OtherNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
62017081020445483912562017-08-10 20:44:54+00:002nd Ave at E 18th St2002017-08-10 21:05:50+00:0010th Ave at E 15th St222839<NA>...<NA>37.800214-122.2538137.792714-122.24878<NA><NA><NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
7201710122044386666302017-10-12 20:44:38+00:002nd Ave at E 18th St2002017-10-12 20:55:09+00:0010th Ave at E 15th St222666<NA>...<NA>37.800214-122.2538137.792714-122.24878<NA><NA><NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
82017111818232819603532017-11-18 18:23:28+00:002nd Ave at E 18th St2002017-11-18 18:29:22+00:0010th Ave at E 15th St2221960<NA>...<NA>37.800214-122.2538137.792714-122.248781988Male<NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
9201708061839175102982017-08-06 18:39:17+00:002nd Ave at E 18th St2002017-08-06 18:44:15+00:0010th Ave at E 15th St222510<NA>...<NA>37.800214-122.2538137.792714-122.248781969Male<NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
\n", + "

10 rows × 21 columns

\n", + "
[1947417 rows x 21 columns in total]" + ], + "text/plain": [ + " trip_id duration_sec start_date \\\n", + " 20171215164722144 501 2017-12-15 16:47:22+00:00 \n", + "201708052346051585 712 2017-08-05 23:46:05+00:00 \n", + "201711111447202880 272 2017-11-11 14:47:20+00:00 \n", + "201804251726273755 757 2018-04-25 17:26:27+00:00 \n", + " 20180408155601183 1105 2018-04-08 15:56:01+00:00 \n", + "201804191648501560 857 2018-04-19 16:48:50+00:00 \n", + " 20170810204454839 1256 2017-08-10 20:44:54+00:00 \n", + " 20171012204438666 630 2017-10-12 20:44:38+00:00 \n", + "201711181823281960 353 2017-11-18 18:23:28+00:00 \n", + " 20170806183917510 298 2017-08-06 18:39:17+00:00 \n", + "\n", + " start_station_name start_station_id end_date \\\n", + " 10th St at Fallon St 201 2017-12-15 16:55:44+00:00 \n", + " 10th St at Fallon St 201 2017-08-05 23:57:57+00:00 \n", + " 12th St at 4th Ave 233 2017-11-11 14:51:53+00:00 \n", + "13th St at Franklin St 338 2018-04-25 17:39:05+00:00 \n", + "13th St at Franklin St 338 2018-04-08 16:14:26+00:00 \n", + "13th St at Franklin St 338 2018-04-19 17:03:08+00:00 \n", + " 2nd Ave at E 18th St 200 2017-08-10 21:05:50+00:00 \n", + " 2nd Ave at E 18th St 200 2017-10-12 20:55:09+00:00 \n", + " 2nd Ave at E 18th St 200 2017-11-18 18:29:22+00:00 \n", + " 2nd Ave at E 18th St 200 2017-08-06 18:44:15+00:00 \n", + "\n", + " end_station_name end_station_id bike_number zip_code ... \\\n", + "10th Ave at E 15th St 222 144 ... \n", + "10th Ave at E 15th St 222 1585 ... \n", + "10th Ave at E 15th St 222 2880 ... \n", + "10th Ave at E 15th St 222 3755 ... \n", + "10th Ave at E 15th St 222 183 ... \n", + "10th Ave at E 15th St 222 1560 ... \n", + "10th Ave at E 15th St 222 839 ... \n", + "10th Ave at E 15th St 222 666 ... \n", + "10th Ave at E 15th St 222 1960 ... \n", + "10th Ave at E 15th St 222 510 ... \n", + "\n", + "c_subscription_type start_station_latitude start_station_longitude \\\n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.795812 -122.255555 \n", + " 37.803189 -122.270579 \n", + " 37.803189 -122.270579 \n", + " 37.803189 -122.270579 \n", + " 37.800214 -122.25381 \n", + " 37.800214 -122.25381 \n", + " 37.800214 -122.25381 \n", + " 37.800214 -122.25381 \n", + "\n", + " end_station_latitude end_station_longitude member_birth_year \\\n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1965 \n", + " 37.792714 -122.24878 1982 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1982 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1988 \n", + " 37.792714 -122.24878 1969 \n", + "\n", + " member_gender bike_share_for_all_trip start_station_geom \\\n", + " Male POINT (-122.263 37.79767) \n", + " POINT (-122.263 37.79767) \n", + " Female POINT (-122.25555 37.79581) \n", + " Other No POINT (-122.27058 37.80319) \n", + " Female No POINT (-122.27058 37.80319) \n", + " Other No POINT (-122.27058 37.80319) \n", + " POINT (-122.25381 37.80021) \n", + " POINT (-122.25381 37.80021) \n", + " Male POINT (-122.25381 37.80021) \n", + " Male POINT (-122.25381 37.80021) \n", + "\n", + " end_station_geom \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "...\n", + "\n", + "[1947417 rows x 21 columns]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.san_francisco_bikeshare.bikeshare_trips\")\n", + "df" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2. Preprocess Data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Only take the start_date after 2018 and the \"Subscriber\" category as input. start_date are truncated to each hour." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "df = df[df[\"start_date\"] >= \"2018-01-01\"]\n", + "df = df[df[\"subscriber_type\"] == \"Subscriber\"]\n", + "df[\"trip_hour\"] = df[\"start_date\"].dt.floor(\"h\")\n", + "df = df[[\"trip_hour\", \"trip_id\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Group and count each hour's num of trips." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
trip_hournum_trips
02018-01-01 00:00:00+00:0020
12018-01-01 01:00:00+00:0025
22018-01-01 02:00:00+00:0013
32018-01-01 03:00:00+00:0011
42018-01-01 05:00:00+00:004
52018-01-01 06:00:00+00:008
62018-01-01 07:00:00+00:008
72018-01-01 08:00:00+00:0020
82018-01-01 09:00:00+00:0030
92018-01-01 10:00:00+00:0041
\n", + "

10 rows × 2 columns

\n", + "
[2842 rows x 2 columns in total]" + ], + "text/plain": [ + " trip_hour num_trips\n", + "2018-01-01 00:00:00+00:00 20\n", + "2018-01-01 01:00:00+00:00 25\n", + "2018-01-01 02:00:00+00:00 13\n", + "2018-01-01 03:00:00+00:00 11\n", + "2018-01-01 05:00:00+00:00 4\n", + "2018-01-01 06:00:00+00:00 8\n", + "2018-01-01 07:00:00+00:00 8\n", + "2018-01-01 08:00:00+00:00 20\n", + "2018-01-01 09:00:00+00:00 30\n", + "2018-01-01 10:00:00+00:00 41\n", + "...\n", + "\n", + "[2842 rows x 2 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_grouped = df.groupby(\"trip_hour\").count()\n", + "df_grouped = df_grouped.reset_index().rename(columns={\"trip_id\": \"num_trips\"})\n", + "df_grouped" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 3. Make forecastings for next 1 week with DataFrames.ai.forecast API" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
forecast_timestampforecast_valueconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundai_forecast_status
02018-04-24 12:00:00+00:00147.0237430.9598.736624195.310862
12018-04-25 00:00:00+00:006.9550320.95-6.09423220.004297
22018-04-26 05:00:00+00:00-37.1965330.95-88.75956614.366499
32018-04-26 14:00:00+00:00115.6351320.9530.120832201.149432
42018-04-27 02:00:00+00:002.5160060.95-69.09559174.127604
52018-04-29 03:00:00+00:0022.5033260.95-38.71437883.721031
62018-04-24 04:00:00+00:00-12.2590790.95-45.37726220.859104
72018-04-24 14:00:00+00:00126.5192110.9596.837778156.200644
82018-04-26 11:00:00+00:00120.905670.9535.781735206.029606
92018-04-27 13:00:00+00:00162.0230260.95103.946307220.099744
\n", + "

10 rows × 6 columns

\n", + "
[168 rows x 6 columns in total]" + ], + "text/plain": [ + " forecast_timestamp forecast_value confidence_level \\\n", + "2018-04-24 12:00:00+00:00 147.023743 0.95 \n", + "2018-04-25 00:00:00+00:00 6.955032 0.95 \n", + "2018-04-26 05:00:00+00:00 -37.196533 0.95 \n", + "2018-04-26 14:00:00+00:00 115.635132 0.95 \n", + "2018-04-27 02:00:00+00:00 2.516006 0.95 \n", + "2018-04-29 03:00:00+00:00 22.503326 0.95 \n", + "2018-04-24 04:00:00+00:00 -12.259079 0.95 \n", + "2018-04-24 14:00:00+00:00 126.519211 0.95 \n", + "2018-04-26 11:00:00+00:00 120.90567 0.95 \n", + "2018-04-27 13:00:00+00:00 162.023026 0.95 \n", + "\n", + " prediction_interval_lower_bound prediction_interval_upper_bound \\\n", + " 98.736624 195.310862 \n", + " -6.094232 20.004297 \n", + " -88.759566 14.366499 \n", + " 30.120832 201.149432 \n", + " -69.095591 74.127604 \n", + " -38.714378 83.721031 \n", + " -45.377262 20.859104 \n", + " 96.837778 156.200644 \n", + " 35.781735 206.029606 \n", + " 103.946307 220.099744 \n", + "\n", + "ai_forecast_status \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "...\n", + "\n", + "[168 rows x 6 columns]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import bigframes.bigquery as bbq\n", + "\n", + "# Using all the data except the last week (2842-168) for training. And predict the last week (168).\n", + "result = bbq.ai.forecast(df_grouped.head(2842-168), timestamp_col=\"trip_hour\", data_col=\"num_trips\", horizon=168) \n", + "result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4. Process the raw result and draw a line plot along with the training data" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "result = result.sort_values(\"forecast_timestamp\")\n", + "result = result[[\"forecast_timestamp\", \"forecast_value\"]]\n", + "result = result.rename(columns={\"forecast_timestamp\": \"trip_hour\", \"forecast_value\": \"num_trips_forecast\"})\n", + "df_all = bpd.concat([df_grouped, result])\n", + "df_all = df_all.tail(672) # 4 weeks" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Plot a line chart and compare with the actual result." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABREAAAKnCAYAAAARNgr5AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjMsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvZiW1igAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs/Xu8LEdd741/qntm1toXdm4n2TvREILEA8EAMfjANio8EBNCRLmJIkeJ5uBLfkEEHlB5zIEQEJADKJegHIWAIsdz9BEOIpeESABJCNcggoICIYFcBZKdZGfvNTNdvz+6q7uqpmet1VU1VbN6fd6v137NuuzV0z0zXd31rc/n+xFSSglCCCGEEEIIIYQQQgiZQ5Z6BwghhBBCCCGEEEIIIcsNi4iEEEIIIYQQQgghhJB1YRGREEIIIYQQQgghhBCyLiwiEkIIIYQQQgghhBBC1oVFREIIIYQQQgghhBBCyLqwiEgIIYQQQgghhBBCCFkXFhEJIYQQQgghhBBCCCHrwiIiIYQQQgghhBBCCCFkXQapd8CVoihw00034T73uQ+EEKl3hxBCCCGEEEIIIYSQLYWUEnfddRdOOOEEZNn6WsMtW0S86aabcOKJJ6beDUIIIYQQQgghhBBCtjQ33ngjfvAHf3Dd/7Nli4j3uc99AJQHuWfPnsR7QwghhBBCCCGEEELI1uLAgQM48cQT6zrbemzZIqKyMO/Zs4dFREIIIYQQQgghhBBCHNlMq0AGqxBCCCGEEEIIIYQQQtaFRURCCCGEEEIIIYQQQsi6sIhICCGEEEIIIYQQQghZly3bE3EzSCkxmUwwnU5T7woh3uR5jsFgsKk+BYQQQgghhBBCCCEh6W0RcW1tDTfffDMOHjyYelcICcbOnTtx/PHHYzQapd4VQgghhBBCCCGEbCN6WUQsigLf/OY3kec5TjjhBIxGI6q3yJZGSom1tTXcfvvt+OY3v4lTTjkFWcZuBIQQQgghhBBCCIlDL4uIa2trKIoCJ554Inbu3Jl6dwgJwo4dOzAcDvGtb30La2trWF1dTb1LhBBCCCGEEEII2Sb0WspEpRbpG/xME0IIIYQQQgghJAWsSBBCCCGEEEIIIYQQQtaFRURCCCGEEEIIIYQQQsi6sIhIgnLxxRfjYQ97WOrdIIQQQgghhBBCCCEBYRGRbMijH/1oPO95z9vU/33hC1+IK6+8crE7RAghhBBCCCGEEEKi0st0ZhIfKSWm0yl2796N3bt3p94dQgghhBBCCCGEEBKQbaNElFLi4NokyT8p5ab389GPfjSe+9zn4rd/+7dx9NFHY9++fbj44osBANdffz2EELjuuuvq/3/HHXdACIGrrroKAHDVVVdBCIEPf/jDOP3007Fjxw485jGPwW233YYPfvCDeNCDHoQ9e/bgl37pl3Dw4MEN9+f888/Hxz72MbzhDW+AEAJCCFx//fX183zwgx/EGWecgZWVFfzjP/7jjJ35/PPPxxOf+ES87GUvw7HHHos9e/bgN37jN7C2tlb/n7/5m7/Baaedhh07duCYY47BWWedhXvuuWfTrxkhhBBCCCGEEEIIWSzbRol473iKU1/y4STP/ZVLzsHO0eZf6ne+8514wQtegGuvvRbXXHMNzj//fJx55pk45ZRTNr2Niy++GG9+85uxc+dOPO1pT8PTnvY0rKys4N3vfjfuvvtuPOlJT8Kb3vQm/M7v/M6623nDG96Ar33ta/iRH/kRXHLJJQCAY489Ftdffz0A4Hd/93fx2te+Fve///1x1FFH1cVMnSuvvBKrq6u46qqrcP311+NXf/VXccwxx+D3f//3cfPNN+PpT386XvOa1+BJT3oS7rrrLnziE5/oVHglhBBCCCGEEEIIIYtl2xQRtxIPechD8NKXvhQAcMopp+DNb34zrrzyyk5FxFe84hU488wzAQAXXHABXvziF+PrX/867n//+wMAnvrUp+KjH/3ohkXEI444AqPRCDt37sS+fftmfn/JJZfgp3/6p9fdxmg0wtvf/nbs3LkTD37wg3HJJZfgRS96EV7+8pfj5ptvxmQywZOf/GScdNJJAIDTTjtt08dJCCGEEEIIIYQQQhbPtiki7hjm+Mol5yR77i485CEPMb4//vjjcdtttzlvY+/evdi5c2ddQFQ/+/SnP91pm208/OEP3/D/PPShD8XOnTvr7/fv34+7774bN954Ix760IfisY99LE477TScc845OPvss/HUpz4VRx11lPe+EUIIIYQQQgghhJAwbJsiohCik6U4JcPh0PheCIGiKJBlZQtL3eo7Ho833IYQYu42fdm1a5fX3+d5jiuuuAJXX301Lr/8crzpTW/C7/3e7+Haa6/FySef7L1/hBBCCCGEEEIIIcSfbROs0geOPfZYAMDNN99c/0wPWVkUo9EI0+nU+e+/+MUv4t57762//9SnPoXdu3fjxBNPBFAWNM8880y87GUvwxe+8AWMRiO85z3v8d5vQgghhBBCCCGEEBKGrSHNIwCAHTt24JGPfCRe/epX4+STT8Ztt92Giy66aOHPe7/73Q/XXnstrr/+euzevRtHH310p79fW1vDBRdcgIsuugjXX389XvrSl+I5z3kOsizDtddeiyuvvBJnn302jjvuOFx77bW4/fbb8aAHPWhBR0MIIYQQQgghhBBCukIl4hbj7W9/OyaTCc444ww873nPwyte8YqFP+cLX/hC5HmOU089FcceeyxuuOGGTn//2Mc+Fqeccgp+6qd+Cr/wC7+An/3Zn8XFF18MANizZw8+/vGP4/GPfzx++Id/GBdddBFe97rX4dxzz13AkRBCCCGEEEIIIYQQF4TUG+xtIQ4cOIAjjjgCd955J/bs2WP87tChQ/jmN7+Jk08+Gaurq4n2kADA+eefjzvuuAPvfe97U+9KL+BnmxBCCCGEEEIIIaFYr75mQyUiIYQQQgghhBBCCCFkXVhE3ObccMMN2L1799x/Xa3LhBBCCCGEEEIIIcvGeFrg/1z3Hdxy56HUu7JlYbDKNueEE05YN+H5hBNO8Nr+O97xDq+/J4QQQgghhBBCCPHlY1+9Hb/1V9fhCQ89AW96+umpd2dLwiLiNmcwGOABD3hA6t0ghBBCCCGEEEIIWRjfO7hWPt5zOPGebF1oZyaEEEIIIYQQQgghvUblCo+nWzJfeClgEZEQQgghhBBCCCGE9Jqiqh2Op0XaHdnCsIhICCGEEEIIIWThHDg0xp994hu4+c57U+8KIWQbUtRKRBYRXWERkRBCCCGEEELIwnnP57+DV/z9v+CtH/tG6l0hhGxDlBJxQjuzMywiEkIIIYQQQghZOHcdGgMoFYmEEBIb1RNxjUpEZ1hEJEG5+OKL8bCHPSzq8+3duxdCCLz3ve+N9ryEEEIIIYSQbigVUFFQBUQIic+0GnuoRHSHRUSyIY9+9KPxvOc9b1P/94UvfCGuvPLKxe5Qxb/8y7/gZS97Gd761rfi5ptvxrnnnhvleRdBl9eYEEIIIYSQrYjqRzZhEZEQkgAGq/gzSL0DpB9IKTGdTrF7927s3r07ynN+/etfBwD83M/9HIQQztsZj8cYDoehdosQQgghhBDSgprAT1lEJIQkQNbBKhyDXNk+SkQpgbV70vyTm/+APvrRj8Zzn/tc/PZv/zaOPvpo7Nu3DxdffDEA4Prrr4cQAtddd139/++44w4IIXDVVVcBAK666ioIIfDhD38Yp59+Onbs2IHHPOYxuO222/DBD34QD3rQg7Bnzx780i/9Eg4ePLjh/px//vn42Mc+hje84Q0QQkAIgeuvv75+ng9+8IM444wzsLKygn/8x3+csTOff/75eOITn4iXvexlOPbYY7Fnzx78xm/8BtbW1ur/8zd/8zc47bTTsGPHDhxzzDE466yzcM8996y7XxdffDGe8IQnAACyLKuLiEVR4JJLLsEP/uAPYmVlBQ972MPwoQ99qP479Rr+r//1v/CoRz0Kq6ur+Mu//EsAwJ/92Z/hQQ96EFZXV/HABz4Qb3nLW4zn/Pa3v42nP/3pOProo7Fr1y48/OEPx7XXXgugLGj+3M/9HPbu3Yvdu3fjx37sx/CRj3zE+Pu3vOUtOOWUU7C6uoq9e/fiqU996rqvMSGEEEIIIX1CUolICEkI05n92T5KxPFB4JUnpHnu//cmYLRr0//9ne98J17wghfg2muvxTXXXIPzzz8fZ555Jk455ZRNb+Piiy/Gm9/8ZuzcuRNPe9rT8LSnPQ0rKyt497vfjbvvvhtPetKT8KY3vQm/8zu/s+523vCGN+BrX/safuRHfgSXXHIJAODYY4+ti1y/+7u/i9e+9rW4//3vj6OOOqouZupceeWVWF1dxVVXXYXrr78ev/qrv4pjjjkGv//7v4+bb74ZT3/60/Ga17wGT3rSk3DXXXfhE5/4RH2DMY8XvvCFuN/97odf/dVfxc0332zs7+te9zq89a1vxemnn463v/3t+Nmf/Vl8+ctfNl6/3/3d38XrXvc6nH766XUh8SUveQne/OY34/TTT8cXvvAFPOtZz8KuXbvwzGc+E3fffTce9ahH4Qd+4Afwvve9D/v27cPnP/95FEU5+Nx99914/OMfj9///d/HysoK/vzP/xxPeMIT8NWvfhX3ve998dnPfhbPfe5z8Rd/8Rf48R//cXzve9/DJz7xiXVfY0IIIYQQQvqEmsBTiUgISUGTzswioivbp4i4hXjIQx6Cl770pQCAU045BW9+85tx5ZVXdioivuIVr8CZZ54JALjgggvw4he/GF//+tdx//vfHwDw1Kc+FR/96Ec3LCIeccQRGI1G2LlzJ/bt2zfz+0suuQQ//dM/ve42RqMR3v72t2Pnzp148IMfjEsuuQQvetGL8PKXvxw333wzJpMJnvzkJ+Okk04CAJx22mkbHt/u3btx5JFHAoCxX6997WvxO7/zO/jFX/xFAMAf/MEf4KMf/Sj+6I/+CJdeemn9/573vOfhyU9+cv39S1/6Urzuda+rf3byySfjK1/5Ct761rfimc98Jt797nfj9ttvx2c+8xkcffTRAIAHPOAB9d8/9KEPxUMf+tD6+5e//OV4z3veg/e97314znOegxtuuAG7du3Cz/zMz+A+97kPTjrpJJx++umbeo0JIYQQQgjpA/UEnkVEQkgCCtqZvdk+RcThzlIRmOq5O/CQhzzE+P7444/Hbbfd5ryNvXv3YufOnXUBUf3s05/+dKdttvHwhz98w//z0Ic+FDt3Nq/B/v37cffdd+PGG2/EQx/6UDz2sY/FaaedhnPOOQdnn302nvrUp+Koo47qvC8HDhzATTfdVBdPFWeeeSa++MUvzt3ve+65B1//+tdxwQUX4FnPelb988lkgiOOOAIAcN111+H000+vC4g2d999Ny6++GL8/d//fV0Yvffee3HDDTcAAH76p38aJ510Eu5///vjcY97HB73uMfhSU96kvG6EEIIIYQQ0mcaJSJVQISQ+Khk+HFRQErpla2wXdk+RUQhOlmKU2KHfAghUBQFsqxsYalbfcfj8YbbEELM3aYvu3b5vaZ5nuOKK67A1VdfjcsvvxxvetOb8Hu/93u49tprcfLJJ3vv3zz0/b777rsBAH/6p3+KRzziETP7BwA7duxYd3svfOELccUVV+C1r30tHvCAB2DHjh146lOfWvd+vM997oPPf/7zuOqqq3D55ZfjJS95CS6++GJ85jOfqRWVhBBCCCGE9BlZWwmpAiKExEeJoKUs2yoMchYRu7J9glV6gOqTp/cA1ENWFsVoNMJ0OnX++y9+8Yu499576+8/9alPYffu3TjxxBMBlAXNM888Ey972cvwhS98AaPRCO95z3s6P8+ePXtwwgkn4JOf/KTx809+8pM49dRT5/7d3r17ccIJJ+Ab3/gGHvCABxj/VCHzIQ95CK677jp873vfa93GJz/5SZx//vl40pOehNNOOw379u2bCUcZDAY466yz8JrXvAb/9E//hOuvvx7/8A//AMD/NSaEEEIIIWTZUb0Q2ROREJKCQhNksa2CG9tHidgDduzYgUc+8pF49atfjZNPPhm33XYbLrroooU/7/3udz9ce+21uP7667F79+65lt55rK2t4YILLsBFF12E66+/Hi996UvxnOc8B1mW4dprr8WVV16Js88+G8cddxyuvfZa3H777XjQgx7ktK8vetGL8NKXvhQ/9EM/hIc97GG47LLLcN1119UJzPN42ctehuc+97k44ogj8LjHPQ6HDx/GZz/7WXz/+9/HC17wAjz96U/HK1/5SjzxiU/Eq171Khx//PH4whe+gBNOOAH79+/HKaecgr/927/FE57wBAgh8N/+238zlJ7vf//78Y1vfAM/9VM/haOOOgof+MAHUBQF/vN//s8A2l9jpTwlhBBCCCGkD9R25g1CFAkhZBHodcO1aYHVYZ5uZ7YorFJsMd7+9rdjMpngjDPOwPOe9zy84hWvWPhzvvCFL0Se5zj11FNx7LHH1n3+NstjH/tYnHLKKfipn/op/MIv/AJ+9md/FhdffDGAUj348Y9/HI9//OPxwz/8w7jooovwute9Dueee67Tvj73uc/FC17wAvw//8//g9NOOw0f+tCH8L73vW/DUJr/+l//K/7sz/4Ml112GU477TQ86lGPwjve8Y5aiTgajXD55ZfjuOOOw+Mf/3icdtppePWrX13bnV//+tfjqKOOwo//+I/jCU94As455xz86I/+aL39I488En/7t3+LxzzmMXjQgx6EP/mTP8H//J//Ew9+8IMB+L/GhBBCCCGELDuqdkglIiEkBXprOLZVcENIuTWXgQ4cOIAjjjgCd955J/bs2WP87tChQ/jmN7+Jk08+Gaurq4n2kADA+eefjzvuuAPvfe97U+9KL+BnmxBCCCGEbFVe8n/+GX9+zbdw6vF78IHf+snUu0MI2Wb89w//Ky796NcBANf+v4/F3j2cUwPr19dsqEQkhBBCCCGEELJwmnTmLaljWZe1CROnCVl29KFnPOU56wKLiNucG264Abt37577L6Wtdr39+sQnPpFsvwghhBBCyPLwr7ccwL/eciD1bpBNoCbwk6Jfk/e3fuzreMjLPozrbrwj9a4QQtah0KqIY9qZnWCwyjbnhBNOWDfh+YQTTvDa/jve8Q7nv11vv37gB37AebuEEEIIIaQfjKcFfv6PrwEE8Pn/9tMY5tRILDOyp0rEz1z/fRwaF/jSd+7Ew048MvXuEELmYKQzU4noBIuI25zBYIAHPOABqXejlWXdL0IIIYQQshwcnhS46/AEAHBoPGURcclRAsRJz4qIdXGURQlClho7nZl0p9dX2S2aGUPIXPiZJoQQQghp0FUlPXPI9pK+9kRUx9W34ighfaNgOrM3vSwiDodDAMDBgwcT7wkhYVGfafUZJ4QQQgjZzkitcDjlYuvS0/RE7Nd7pQ6nb8VRQvqGZLCKN720M+d5jiOPPBK33XYbAGDnzp0QQiTeK0LckVLi4MGDuO2223DkkUciz/PUu0QIIYQQkhxdVcICzvKjXDVFz94rKhEJ2Rro1wwGq7jRyyIiAOzbtw8A6kIiIX3gyCOPrD/bhBBCCCHbHcPOTCXi0jPtabFNUolIyJZgaqQzU4noQm+LiEIIHH/88TjuuOMwHo9T7w4h3gyHQyoQCSGEEEI09JpN3wpTfaSvtl8qEQnZGpjXDBYRXehtEVGR5zkLL4QQQgghhPQQaQSrsICz7DTFtn5N3pvAmH4dFyF9Q79mrE14zXChl8EqhBBCCCGEkP6j1w37pm7rI7K36czlI5WIhCw3Zk9EFv1dYBGREEIIIYQQsiUxglXYE3HpUUK9vhXb6uIogxoIWWpoZ/aHRURCCCGEEELIlqSgnXlLod4vKfv1flGJSMjWwFAi0s7sBIuIhBBCCCGEkC2JNFQlnBAuO30Nwil6atMmpG/oixdjKhGdYBGREEIIIYQQsiUx7Mw9K+D0SamnkAt4v5bhdWqUiCxKELLM6MPFeMLz1QWnIuL97nc/CCFm/l144YUAgEOHDuHCCy/EMcccg927d+MpT3kKbr31VmMbN9xwA8477zzs3LkTxx13HF70ohdhMpn4HxEhhBBCCCFkW6BPCIse9UT87t2H8YhXXYmX/d2XU+9KUEL3sLz5znvxY7//Efz3D/+r97Z8UMXRCXsiErLU6GNQn9TQMXEqIn7mM5/BzTffXP+74oorAAA///M/DwB4/vOfj7/7u7/DX//1X+NjH/sYbrrpJjz5yU+u/346neK8887D2toarr76arzzne/EO97xDrzkJS8JcEiEEEIIIYSQ7UBflYj/estduP2uw/jEv/1H6l0JipGmHaDg9s/fOYDv3rOGj38t7etEOzMhWwN97WKN6cxOOBURjz32WOzbt6/+9/73vx8/9EM/hEc96lG488478ba3vQ2vf/3r8ZjHPAZnnHEGLrvsMlx99dX41Kc+BQC4/PLL8ZWvfAXvete78LCHPQznnnsuXv7yl+PSSy/F2tpa0AMkhBBCCCGE9BPdHtsnJWJfi1KmCsh/Aq9en3HiYkBfU6cJ6RvGGETlsBPePRHX1tbwrne9C7/2a78GIQQ+97nPYTwe46yzzqr/zwMf+EDc9773xTXXXAMAuOaaa3Daaadh79699f8555xzcODAAXz5y+2S/cOHD+PAgQPGP0IIIYQQQsj2xVC29UhU0tcee6GVo2p7yYuIPS36EtI3jHTmPl00IuJdRHzve9+LO+64A+effz4A4JZbbsFoNMKRRx5p/L+9e/filltuqf+PXkBUv1e/a+NVr3oVjjjiiPrfiSee6LvrhBBCCCGEkC1MaGXbslD0tMee/haFUO01SsS0r5PsadGXkL6h1w1TjxtbFe8i4tve9jace+65OOGEE0Lsz1xe/OIX484776z/3XjjjQt9PkIIIYQQQshyo9ds+lS/qYM6eqZsW5QScUIlIiFkE0gqEb0Z+Pzxt771LXzkIx/B3/7t39Y/27dvH9bW1nDHHXcYasRbb70V+/btq//Ppz/9aWNbKr1Z/R+blZUVrKys+OwuIYQQQgghpEeETvtdFlRBtG9FKf0tCqlEXEusKCp6WvQlpG+YPRFZRHTBS4l42WWX4bjjjsN5551X/+yMM87AcDjElVdeWf/sq1/9Km644Qbs378fALB//3586Utfwm233Vb/nyuuuAJ79uzBqaee6rNLhBBCCCGEkG2CXpQqelTAWRaFXWhMJWJ/glXUYfWt6EtI39BP0dSLD1sVZyViURS47LLL8MxnPhODQbOZI444AhdccAFe8IIX4Oijj8aePXvwm7/5m9i/fz8e+chHAgDOPvtsnHrqqfjlX/5lvOY1r8Ett9yCiy66CBdeeCHVhoQQQgghhJBNEdoeuywUPS1KmT0s+2dn7lsPS0L6BpWI/jgXET/ykY/ghhtuwK/92q/N/O4P//APkWUZnvKUp+Dw4cM455xz8Ja3vKX+fZ7neP/7349nP/vZ2L9/P3bt2oVnPvOZuOSSS1x3hxBCCCGEELLNCF2UWhZU365xwGO6894xBpnArhWvjlZemGnaIYqI5WPqgIS+Fn0J6Ru6ej21gnmr4nwFOfvss42mlDqrq6u49NJLcemll879+5NOOgkf+MAHXJ+eEEIIIYQQss3RazZFn3oiBi5KHZ5M8ZjXXoU9O4b46AsfHWSbLsjAytGmJ2IBKSWEEN7bdKHpiciiBCHLjD7uhFyk2U54pzMTQgghhBBCSApCF6WWBT3td55wowt3Hhzju/es4Zv/cU9S9Y3+FoW0MwNp33/2RCRkcRyeTINtSx8zxhMW/V1gEZEQQgghhBCyJemvEjGwYk/b3qFxuAl5V4Ifl64qSmhpZjozIYvhA1+6GT/y0g/j7754U5DthU6I346wiEgIIYQQQgjZkiyLEi00oSe6+iYOjdOpb/T3KEQIib69taQKy0Y5SggJx3U33oHxVOK6G+8Isj1DicieiE6wiNhzvn/PGh713z+K113+1dS7QgghhBBCSFD6WkQMnmKsbSOkNbArenE0TLDKciStqkOhsomQsKhxItT5zSKiPywi9pwvfedOfOu7B/HhL9+SeleC8v171vDGK/8NN37vYOpdIYQQQgghiQhdlFoW9GOZBlbspVQimsVR//3QawAp7cySSkRCFkIROKle30zqVPetCouIPaev0vr/7/Pfxuuv+Bre9o/fTL0rhBBCCCEkEYYSsUc9EU07c4BiW097Ii6LqkgdCpVNhISlDi0KVPBbFvXyVoZFxJ7T16Swg2vlzc/dhyeJ94QQQgghhKTCCFbp0f1uaDuznvC8LHbm0DbttEXEfgo3CEmNOqfGARZTAHNsXaMS0QkWEXtO3UOgZxc0dVy8UBNCCCGEbF/62xOx+TrEfbxeX1sWO3Po1Omk6cw9nXMRkpo6+TyUEtFogUAlogssIvacvq6KqdVUXqgJIYQQQrYv0rAzJ9yRwBjFtsA9EVMqEfVb9yB25iVRIvbV/UVIakLXM2hn9odFxJ7T16SwaT2Y8MQnhBBCCNmu6LeCfbovlIEDSAqjJ2JflYjpj4tFCULCooa/UOe33lKBwSpusIjYc/qqRKyLozzxCSGEEEK2LWZRKuGOBCa4Ym9JglVC90RclnTmgkpEQhbCIpWItDO7wSJiz+ltEZE9EQkhhBBCtj1GsEqP0pmLwL3+9Hvm5VEihlVYLoUSkXMTQoKi1MZjFhGXBhYRe05fV8V4oSaEEEIIITKwPXZZWKQSMWVPRP1YwigRl6Mg0FfhBiGpUUNXqFYBRmgVXY1OsIjYc5qksH5V2dUYwgs1IYQQQsj2JXSxbVkI3RNxedKZm6+D9EQ0iojp7cyTQhrvHTG5+c578b4v3sTekWTTTIuw4iF9QWWNn0MnWETsOX1dFVPHRQkyIYQQQsj2JXRQx7IQ+riWpyeinozqf1xySayJ+uvbo49hcF7x9/+C5/7PL+Dj/3Z76l0hW4TQoUVGOjNPVidYROw5fU1nlj0tjhJCCCGEkM1jFNt6pADTb3FDKOwKbYOHEtqZ+5jOLKW0AmMocpjH9+5eAwB8/55x4j0hW4XgwSra6TktpDE2ks3BImLPUSeFlOjVCTJlT0RCCCGEkG2PXrzp073uIotth5fEztyXdGa7dk2Rw3zU57BPBX+yWFTRL9T5bbcbGLPo3xkWEXtOX+W6fQ2MIYQQAhw4NMbtdx1OvRuEkC1AX+3MoZVt+muTMljFtP2GVVimUiLax9GnOVdo1OewTwV/sliaQNXwwSpA2l6qWxUWEXtOX5tNN4Ex/TkmQgghJU+69JN4zGuvwr1r6Sa6hJCtgXGv2yN1k37fHuIeXn9pUgarGMXRAJP3ZbAz22/PlEWJuajPcp/OVbJYisAORPuzx5Cf7rCI2HP62ydG9UbgSU8IIX3jW989iLsOT/C9g2upd4UQsuQYyrYeLS4XRnEsbIpxymAVUznqfx9vKhHTvP+LUiLec3gSZDvLBJWIpCt1xsOC7MxMaO4Oi4g9x7hQ92hVTJ3rVCISQkj/qBeKenTdIoQsBtnz1j1A+J6Iy1JEDNITcQmUiIvoifiBL92MH7n4w/jfn7nRe1vLRF1E7M+pShaM+syES2c2vw9VnNxOsIjYc/RVnj4lhTGdmRBC+omUsul72yMFPSFkMei3giF67ClstUpszOJoWMVeSjtz8OKorkScLEtPRP/9+Ofv3Akpgeu+fYf3tpaJ2s7MORzZJKHtzPb5mmrxYSvDImLP6WtPxDqdmSsHhBDSK6Rx3eKNHSFkfRYRrPJ77/kSfvI1H8Vdh8ZBtudC6OPSN5EyWCW0clS/ZowTzXVmiogBez32rTewOq6QBX/Sb4IXEQu7iMjPYldYROw5TGcmhBCylTAnzgl3hBCyJTAXzMNs86qv3o5vf/9efO3Wu8Ns0AH9uEIHkKRUIoYOjDGUiEsSrBJizqUKHb0rIlKJSDqi1pND2Znt+jWViN1hEbHnLGJ1dhlgOjMhhPQTY+JMJSIhZAN0ZVsodZPaTsrJZWghgGFnTqhEDD3GGz0RE9mZbet7mOJo+Xhvwv6Vi4A9EUlXFp/OzA9jV1hE7Dnmhbo/J0gzmHCCSQghfcJMWk24I4SQLYHZ/7s/RcTQrR30wtbhRErERRTbFvH+d96HGSVigB6WfbUz10XE/sxLyWIpArcxU9vLRPk905m7wyJizwltGVgWaik8Vw4IIaRXGAp6TjIIIRtgBKuEUqpUc8qkSsTAxTF9bE3VE9E+jNCp06mKAXZBLKRNu69KxD7NS8liUR+VUOIhtb2VQV5ul0XEzrCI2HNkX+3M9WDSn2MihBBi9zfjjR0hZH0W0bpH3T+vTdLdZ4YORyyWoCfibIpxX9OZAwar9K2IKFlEJN3QLfAhForU+L4yLEthDFbpDouIPaevvaUkL0CEENJLGKxCCOmCYfvtaU/EEJNc/VAOJSpMLUKxtwwhkvbHLqRNu692ZtvaTsg89M/KOEirgPJxlGfBtrndYBGx5/TWzsyeiIQQ0ktMCx/HeELI+ph9VEPZmdMXEU03UdgAkkkhk1j47LpRaCXistiZg6Rp993OzCIi2STTwGrzwlYiJlIwb2VYROw5cglW5xaBOpRQsmZCCNmKfOnbd+LN//BvWOvRDZDZ3yzdfhBCtgaLCBFUt89plYjN1yGOy1Z+HUpw3ZhRIgYotunXiXR2ZvP7kL0e+6pEpNOAbBbjHPccM6SU9fheKxFpZ+4Mi4g9J3Q/lWVBLxxyJYsQsl15zYf/Fa+9/Gv4x3+/PfWuBMO0pnGWQQhZH0OJGNjOvJZwchm616O9jcMJFG6zKcb9sDPbgoYg6cyaErFPggmmM5OuhBwL9T+vg1V4r9kZFhF7jmFd6FGVfRFNtAkhZKtxz+EJAOCuQ5PEexKORRQEloUPfOlm/PN37ky9G4T0ikWECKrNpLS56YcSpifiEioRA9u0UylHF9ETUX/LD/fIbVAXETl/I5vEWCjwPMf1ba1WduY+uXliwSJiz+nrZEy/OPfJpk0IIV2ok+r7tEik3cv16bhu+O5B/P/+8vP4rb/6QupdIaRXLMJ1owpTSe3MRl/zEGECVhExhRJxRrEXLoAESFcMWEQ6s35cfeqLWKcz92heShaLsaDirURs/n40KEthrCV0h0XEnqOPz306QYwkvh5NMgkhpAvLkCAamr4uft157xgAcMfBceI9IaRfLGLMkEswtoa26dqbSFJElMD9xU34i+Er8X+JfwnaOxBIaGdeQOq0vo2Da/1wG0gpqUQknTEWVLx7IjZfKztzn+6hY8EiYs+ZBl7FXBbMG4b+HBchhHRhGRJEQ7MM/a0WgTquPh0TIcvAIpSIajNpeyI2Xy+iJ+KhcRo78znZZ/GT+T/jqfnHAx1X83Wqa+Eiej3qc50UBd9FYHyme7RISBaLfl849pz3tykRGazSHRYRe47ZQ6A/Jwh7IhJCiNa3K9D4Pi0k/uXmA0kVAnIBBYFlQE2YfPv5EEJMFtETcRkWaPTjCjHG2+P64UkKJaJEjvJ5R2Ic5P1aBjuznXwdxH6u25nX+nHd0IUfvBSSzTINOMbrf79SFxH5YewKi4g9p6+TMf3C6tsbgRBCtipF4Inun3zs6zj3DZ/A//f5bwfZngumgr4/47ukEpGQhWAsLAdQNxnFu6TBKmHdRPZrcziBElFKIEf5vENMgiv20tmZze9DCDf04+qLnVn/GNuFV0LmUQRUG+vnat0TkUXEzrCI2HP6GkBiyOF7pLAkhJAuhG7+/+3vHwQA3Pi9g0G250JflebqLerTMRGyDJi237DbS9sTsfm6Pz0RJTJR7sgI0+BKxHR25sX2ROxLsEpIRRnZPsiAzkp9W6onYsq2FVsVFhF7Tn8nY+yJSAghjRIxUB+wajhNqfBeRH+zZUDviUgFBiHhMIJVAhdv0vZEDHsPb782h5LYmYGsUiKOMA5UHE2vHLWLiCGuofo2+9ITURd+sCci2Swh1cb6n69QiegMi4g9J/Qq5rLQ1+IoIYR0IXQ6c61sTGjhk4GticsCr1uELAajdU+AMcMoSiXtidh8HdoeC6SxMxeFrIuIQ0yC27RTLYDZH7tpgM+Nmc7ckyJi4II/2R4Y9QxvOzN7IoaARcSeI3s6WMueFkcJIaQLU03dFgJ1nUg5rvZ1kqHPlXndIiQc+jgRokCv32OmtTOHdd3MKBETqNvKnojlfgxFmJ6IIfulOe+DVUUMfVx9sTPrn2NeBslm0ccufyVi+feZAAa5AMB0ZhdYROw5fe2JyJ4ahBDSTDJCJVKqm6u1lBPnnhbbioB2HEJIQ+gWCMuiRAytXra3cSiB4ryQUrMzT4IfVzo7s/l9kOPS3v97e6JE1K/vfXIakMUS8v5JfQYzITDMqUR0hUXEnmPeWPXnBOFkjBBCFmFnLh+XJ5G0P+O7sfjFVW9CghG82KZtb22yHP1hexWsohURQ9u0U9mZF6FENIJVelJENJSIPbq+k8ViiKIC2Zn1ImKIcWi7wSJiz5E9LbaZ1pX+FEcJIaQL6sYq1A1QEdge7YLR36xH1y1pTHR53SIkFEbrngDqJrkE9ljA6g8bWGEJAIdS9ESUErnWEzHE+2WnM6cIrrKfM/T71Rc7s6FE7NH1nSyWkG3M1HklBDCs7cy8J+sKi4g9p6+2X7PBan+O64qv3Iqn/49P4eY77029K4SQLUBoJaKajKW0MxvXrR7ZnTh5ImQx9NfO3HwdYj/s1+ZwonRmoXoiIkxPRP06IWWa8dV+yuBKxJ4UEdkTkbhgOBA95/1qU3kmMMgqOzM/jJ1hEbHn9DWdeVr0szj6vz5zI675xnfxsa/ennpXCCFbADX8hSr6NcrGJbEz92iRaLokhQlC+sYi7cy96olYbW+Qleqb1ErEkQjfExFIM9+xrblBUqd7aGcuAquGyfZAH5N9Q6YMO7NKZ07YwmerwiJiz+lrb6m+2rTVTQcnmISQzRDezlw+pkyqkz1VIoa2JhJCSgwlYgh7rN4TMeFYGDocUY1BO0c5AOBwip6IBZDpSsQA97v2W55CSU8l4uaY9FQEQhZLSAei2pYQwKiyM/sWJrcjLCL2nKKng3VfbdpqXOxTYZQQsjjUGB/MzhzYHu22D83XvRrfe5o6TUhqQhfo9aJUSoVK6P6wahs7RwMAwKEkdubFpjMDad6zmZ6IgQNj+qJE1N8rKhHJZimMBZVwSkRlZ065WLRVYRGx5/Q1xTh0n5hlQQ2SfZo4E0IWhxrjQykvlqGI2Nd2FSF7+hBCGgyLZK96Iur7EaLYVj4qJWIKO7OUaOzMGAfviQgksjMvQIlY9FCJyCIicSFkPUOdV5lAbWdO2cJnq8IiYs8xFR1hTpDv3bOGuw6Ng2zLld4qLGtVUX+OiRCyONTkKVRRKrQ92oW+tuEwb4LDXI+f/7+uw6//+WeTpJESsiyEtjPr486yFBFD3MOr7e1cqezMqZSIorEzBwnCsbaxlkCJaBfEQvfm7KMSsU/Xd7JYFmFnzoTAMGM6syuD1DtAFosp//UfrA+Np3jM667CUTtH+OgLH+29PVf6q7BUSkQOZoSQjVFDRZ/szKEtfMtCaCXi2qTAe77wHQDAHQfHOGrXyHubhGxFTCWi//YMO3PSBZXm6yDKtronYmVnThSsouzMuZAoiimklBBCOG/TLhynuH7ZRcQwSsTm64M9KSKGPldJ/7EXCXzP79rOnAkM8ypYheKdzrCI2HNCp1zecXBc/ysKiSxzv+j70NeVrGYC359jIoQsjnrMCDQONkXJ5VAi9mqRKHBPRMPqyNkY2cZIo9gWTrEHpAnpUITu9ai2sau2M6dQIqIuIgKlGrGQQO44nZBSzgSrpLhu2PsQOp05xXulKAqJtWmB1WHuvS19Aa1PwWlkcYRW+TY9EYFBTiWiK7Qz95xFrWICqW+smq/7NMlUA2OfCqOEkMWhbsJDNZKfLoESsa89k0IHgrHHIiElhrpJzoZcdGV57MzN1yHO8VklYvzClJSy7okIACsYexV+9fdqpepvlsLObI/poXs9puyJ+Ot/8Tk88lVX4s57/VtZ9TUYkyyO0D1P1eYyITCqlYgsInaFRcSeE7q3lL6NlEXE0H1ilgWmMxNCNouuwAhmZ1Y9EROOQX21M0uj6BdWpcIiItnO2MOE77BhhPclTGcO3UdVjRk7RqonYppim0BzXENMvGyteoFBKeWWwc4cutdjSjvzF799B+44OMY3/+Me723prwt7+ZLNYH9MgtmZhcAgV8Eq/Cx2hUXEnhPaFmYoERPeWE0DH9eyoC6oTIkihGyEUUQKZWdWac9LMnHuUxExuDNAe4toZybbmdAFHL24sSw9EcOol8vHXQnTmQsJQ4k4xMSrQKq/9UqJ2Jd05mVRIqpiZohwF6MdFYuIZBPYY5/vWKj+XghgWNmZUwqjtiosIvYc/bpsNyZ12p6+OrskFo8+TTLrZNQeHRMhZDHow0Sool+jhub4HprQvXyntDMTAmBW0eTbBkE/t9amRTLFlAwtBKi2sXOltDMfTmRnzjQl4kj4JTTrf1srERMsgtmfkdBq87VJkex6qM6HEPZ38zrovTmyDbDHc9+FHSOdmUpEZ1hE7DmhFXv2BS0VRup0j078pojIKyshZH2MYI1Ad+NqIpRSfdPX4CwZ+P1axPtPyFbEvmXyvd8NvT3n/QgcjqjmBDurYtuhyXIEq/i8vlMpIVDg57J/xP2zWwCkURXZhxDazgykUyPWSsTARcQQ4hbSf2bPLb/zW9Z2ZmjpzLyH6gqLiD3HTHbrz6Slv3a38lj6dEyEkMWwCDuz2mbK8d24bvXI7hS6OFos4P0nZCuyqPRORarxMHTLArsn4ngqo99vFlawygh+SsSikHi4+BreMHoLfuvwWwGkWQSzPzOh7cxAGDuxC+pQQtuZ+xScRhaHXWwOpkTMBNOZPXAqIn7nO9/Bf/kv/wXHHHMMduzYgdNOOw2f/exn699LKfGSl7wExx9/PHbs2IGzzjoL//Zv/2Zs43vf+x6e8YxnYM+ePTjyyCNxwQUX4O677/Y7GjLDItOZUzRkBsrPV+jjWhbUoaRUARFCtgbGok4oO/MSFBH7Pr4DwDi4nZk3wGT7MhOsEii9UzGeLIESMcCYoTa3q7IzA8DhyGrEwrIzeysRC4mjxF0AgKPkHQDSjIeLCVYxv0+Rpg00xxJEidjTRUKyOGYL9OGCVZp0Zn4Wu9K5iPj9738fZ555JobDIT74wQ/iK1/5Cl73utfhqKOOqv/Pa17zGrzxjW/En/zJn+Daa6/Frl27cM455+DQoUP1/3nGM56BL3/5y7jiiivw/ve/Hx//+Mfx67/+62GOitQE78G0BHZm+5rTp8mTuvGlEpEQshH6fVQo+5YaX1O2idBvGPtkdyoCOwP06wRvgMl2xu5H51ucsP8+VdN9fTdCtiTaWSkRgfjhKtKyM48w9rJqT7Wi5EiOAaR5v2bnJuGViKkSmtW1K3RPxD5d38nisM+DUErzTJRqxLbnIBsz2Pi/mPzBH/wBTjzxRFx22WX1z04++eT6aykl/uiP/ggXXXQRfu7nfg4A8Od//ufYu3cv3vve9+IXf/EX8S//8i/40Ic+hM985jN4+MMfDgB405vehMc//vF47WtfixNOOMH3uEhF+KbMzdepJi32id4npYo6tj4dEyFkMSwipV4fg6SUEEIE2W4XigUc1zJgHFeA66dZYOjPYhohXbGVKr7FiaWxM2v7IWV5XGrS64Ia3wdZhlGeYW1aRFe3FVJiqPdEFFOv8asomrTnEdYALIedOaRwY2WQ4fCkSNcTsTo2pjOTFMwow33tzNVwkwmBvLrHZUG7O52ViO973/vw8Ic/HD//8z+P4447Dqeffjr+9E//tP79N7/5Tdxyyy0466yz6p8dccQReMQjHoFrrrkGAHDNNdfgyCOPrAuIAHDWWWchyzJce+21rc97+PBhHDhwwPhHNiZ0yqV+kUylRFzEhXpZUMfWJ3UlIWQx2Fa3EDdBZt/b9Ba+Pt3YhbZpGz0xqUQk2xj7dPI9v2xlY6oiol1k8e2LqMbTTAArw3IKGL+ICOR6OrNnT8Sp1mNxJMsiYho7s/m9t+VS2+Duyn6euifiweBKRO/NkW2APT6EUiIKIZBVlTAWtLvTuYj4jW98A3/8x3+MU045BR/+8Ifx7Gc/G8997nPxzne+EwBwyy1lMtbevXuNv9u7d2/9u1tuuQXHHXec8fvBYICjjz66/j82r3rVq3DEEUfU/0488cSuu74tCd1PRT/J1qapUsLM73ulVKmOrU/HRAhZDDPNpkOEZxkW2fRhAn0aC0MH4Uxl+veKkGUg9OKyfTqlOr/seW0wG18msDKoEpoj25kLKZEJy87sMYEvClnbo4co7cwp3q/gn0Fte6qH5b3jidc2nfdF9URksApJQGhluG5nVkpEKWcXj8j6dC4iFkWBH/3RH8UrX/lKnH766fj1X/91POtZz8Kf/MmfLGL/al784hfjzjvvrP/deOONC32+vrDINMi1JWg0DYTpLbUsqPeISkRCyEbMqFQC92BKpW4zlIg9uqkz2osEGONDtyshZKtiDxO+44b998tyv+t7nqshPRcCq5USMXawipTS6Ik4xMTrWqOnPQ8qJeJagmuXGo8Hld3c+71qVSImKI5q+xGkJ2JgcQvpP6H7jart5ZlArrWH4OexG52LiMcffzxOPfVU42cPetCDcMMNNwAA9u3bBwC49dZbjf9z66231r/bt28fbrvtNuP3k8kE3/ve9+r/Y7OysoI9e/YY/8jGhO6ZpJ9fqRpNsyciIYTM2qdCJDQvIqylK8Z1q0c23dDtRfS3h0pEsp0JrQJbxp6IALwCSICmIJRnAqNBOQWM3ZpoqvUwBMoiopeduWiUjUM5hkCR1M6sXteQn8G6iJigJ6K+H0HSmalEJB2xzyXfObJpZ9aKiPw8dqJzEfHMM8/EV7/6VeNnX/va13DSSScBKENW9u3bhyuvvLL+/YEDB3Dttddi//79AID9+/fjjjvuwOc+97n6//zDP/wDiqLAIx7xCKcDIe0EtzMvQzqz9bR9WjlQK5l9OiZCyGJYiJ15CSyyhlKhRzd1we3M7IlICICWYBXPcWO2kX+61g4/JL6DXbi33A/PMV6NGVnWBArEHmMLLU0ZAEZi4jUe2ttbwTipnVkVEYMqEVeVEjG+nVn/fIS3M3tvjmwDZhXZvnbm8lG3MwPs0dmVzkXE5z//+fjUpz6FV77ylfj3f/93vPvd78b/+B//AxdeeCGAsqr7vOc9D694xSvwvve9D1/60pfwK7/yKzjhhBPwxCc+EUCpXHzc4x6HZz3rWfj0pz+NT37yk3jOc56DX/zFX2Qyc2BCp3fKJZhghrZ3LBONnbk/x0QIWQz2YkMIO3PoBGHffehTsEpoO7OZYs27X7J9sYcJ39PLHltTqbJ/YPodXLnyIlw6fCOAgD0RBWobX+yho7DszN7BKpaycQXjJHZmdQjDPJASUXtfdqVUImr7EVqJSMEE2Qwz837P87teTBGWnblHi9YxGHT9gx/7sR/De97zHrz4xS/GJZdcgpNPPhl/9Ed/hGc84xn1//nt3/5t3HPPPfj1X/913HHHHfiJn/gJfOhDH8Lq6mr9f/7yL/8Sz3nOc/DYxz4WWZbhKU95Ct74xjeGOSpSo58PwYNVEikR7ZPc196xTNRFRE4ICSEbMGO5CzAm63PlVBPn0CnGy0LoRb1lSNImZBmwG+L7K1XCL9C4sFfeDgA4UZQtoHwnz+q4ciGQJVIiSglDOTjExOv9mhZmUXIF4yR2ZvUZHOVKieipGjXszGUITpKeiNp+BOmJaKQz87pFNiZ08rnUFlMywZ6IrnQuIgLAz/zMz+BnfuZn5v5eCIFLLrkEl1xyydz/c/TRR+Pd7363y9OTDoRWlSyDnbnPSkR1KH06JkLIYght8bC3mWoxQy8IhOqZdOlH/x1/+/lv469/48dx9K5RkG12JXxPxLDKRkK2Kvbp5Dt0zdiZE93vqv49A5TFG//U6cbOnFVetNiFHD0IBfDviWhvb0WspbEzV8cwzKvibCC1FADsHJXT9YMJ0pkNO3OAIqI+v6Hyi2yGmZ6I3osp5aOwlIgsanejs52ZbC1Cy8b18T6ZSmWmJ2J/Jk9qwkw7MyFkI+whOESCqKFuS5RIuohef+//p5vx9dvvwRdu+H6Q7bkQWjlYBFY2ErJVmQkg8SxOzLaKSHOfKVQRUZTFm1C9wHKtJ2LscItCAkLviQi/nojTQs7YmVMoR207c6jwhzwT2DkqlYiHAvQk7Lwf2nGE6IlYBJ6Xkv4TWjxkKrKbn7Oo3Q0WEXuOYWcOcHJQibhYaGcmhGyWRUx09W2GCGpxQT+sUBNcNXE5nEpRBHvyFNZ6Tjsz2c7M9kQMM8lUpFo0zyoF4hCqiBiuJ6JKJY1dyLGVgyMx8VLt2T0WUwerhOqJqP4+FwKrw8rOnCSdufn60Nj/ddU/w6zZkM1gf05CtavIsiqhuSokUonYDRYRe07wdOYlCFYJLWteJmolIgcyQsgGLMTOrBcRExXc5AIUduradXgSfxKm0A8lfAgOF57I9sXuiei7+GAPO8mK9LWdubSxBg0USKRElFbRb4iJl8hhxs6cqIioDmEYOJ05y1ArEQ8mUCLqc64QRUxjXsoqItkEoef96mOn+iEqSzM/j91gEbHnGLawABNM/UYtlRJxdkWiPye9er8o8SeEbMSMWiaInbn5OtXYuoh0ZrWdEEoKV0K3FzEKvrxmkG1M6PTO2WCVxHbmQD0Ri0IixxS5kJoS0W8fO++DxGwRMWA686pYS2RnLp9zlIdReOqWyx2VEjFEsElX9HlfCDsz05lJV0I7ENXnTlRFxDpkip/HTrCI2HOMRu5BglWar1PZO2bSmXtk/a2DVXqkriSELIaZ1dkQFll9oSiZ2rz5OrgSMcEkTBFaYTmlEpEQALO9sv2ViEtSRKyKY6HszCgmuHz023jA3z21ViLGVt/M2Jkx9u6JuBx25vJR2Zl990EPwdkxSmdntoNVfBf27PsWWkjJRswuEgWyM1c2ZqVE7FE5IQosIvacRU5aUt1U9bUnon4hZU9EQshGzFruwqrNUy1mhG7DoW/nUMqeiLrKM3D/yr5cBwlxYSZYxVsFZn6fynljKxF9x4098gB+KLsZO2/7HEaitEhHT2cuJDLRPGepRHQ/rmWxM4fuiagHqyglYmo7M+DfV3imiEgLKdmA0O0lZuzM1SPn3t1gEbHnTANPxvQJZqoG9fYNT1/kx9MlmLwTQjbHDd89iJ9+/cfwvz97Y7J9WISd2QhWSbRQJBdQRKyDVVLamYNfj5uvU71XhCwDdh3CV11n91hM3RNxKKYApP+4UTRFqB3iMIAUwSqmnXkkpl73vNNCIhN6ETGNnVlaRcRJIWc+R11QQ7oerJKiHYd9CL5qSHvBi33oyEbYY1SoAr1SIqrWDixod4NFxJ5TBFYqLEc6s/l9XxQYRWDVKCFkcXzqm9/Fv912N/7+n25Otg+h7cxSSiv8I/0YH2qCsQzBKvqEMkQPQ0OJyIUnso2xJ3+hLZepeyICpRrR+95QNuPfLnkvgDR25gy2EtGj2GYrEUVaO/NoIGZ+5oJuZ27slvHHefu98S0izp6rXpsj24DQIYLqI62UiINE/WG3Oiwi9hz9vAtx8VkGlUroFQkAuPPeMc6/7NN4zxe+7b0tV/QxsS/qSkL6ijpHUyrAQvftsueSqdQ3i2i8rl6a5QlWCdu/kjYcsp1ZtJ051TifWUVE7/FQ294Oeaj8UfQiIoL2RCzDYpbPzgz4jct6sEqWqH9l23P6hqtQiUi6oj4iqtjnu5jSKBGrYJWMwSousIjYc6aB1W36WJ9OiRi+J+K13/gurvrq7fiLa77lvS1XlqHfJCFkc6hxJ6UCzJ6fjD3tzPbNfKqwjoXYmZdAiWj2RAzbXiSZ3ZKQJWDGzhxokqlIFTIl0IxXQ0y97w11ZeMOlEXE2IcmpQyczmxubzWRndkOVgH8Pofqb3NNiZiiyGEXmX0Tom1BCy2kZCPU5340qFoFePdEVCrf8nvVE5GfxW6wiNhzQjeoX4bkztmbRf/9UAWBlKsQi1DfEEIWg7oRTjUOArNFv7G3xWM5LHz68BfKvlUHqyRUIoZuWWGkWHPhiWxj1LlV29J805ltO3OAfrMumHZmv2IbALMnIqqeiIntzCNM/JSIlrJxBeMk46HdExHwG+fV+1IWEaufJbEzm9+H7onIdGayEWp8V0XEUMnnQgWrUInoBIuIPcdQPoSwT+l25kQ3VbNqmXA27ZTjh52kHdtiQgjZPGrMSGkjnSn6earDZ5SNS5DOHKo/bB2sklKJGLpHceAei4RsVWwVWF/szAJ6ETFET8RZO3P0dGbbziwmXvswk84sxlhLokQsn3NloCkRPfZDvSZ5ptmZl6Enoqedua/hmGRx1EXEwON7Y2cuv6e1vhssIvYcvRBVSP+bBSOdOZlKJfwFSG0zpZR5Eb0eCSGLQY0VqRZTgBa1jOfEaUbZmKrvra6gDx6skrLo23wdQkFvFCWpRCTbmFqJmIexpS2DKlvOBJCE6InYFIBWUQWrRC8iSggrWMVLsWfZmdP1RCwf80ygqk14HxdQJsgOqipHijmK/ZxMZyaxUbdLtZ3ZU2hjpzPXdmbOuzvBImLPmSlMeQ7WS5HOPJNIGk6JmNTOvIBej5/71vfwuD/6OK7++n94b4sQ0qDOz5T9S2cSRAPbmVOlxOu7ES5YRdmZ0ykRzb634Ra/AKYzk+2NOhUaJaLf9pahJ2IhYRTHBiJwT8QqnTl2YUpaSsSyJ6JfAIlpZ15LsqiiFyYGAeyRup05S2hntj8fvtdQe67DGiLZCPWZGQ3C9BtVn7mcwSpesIjYc+zzwfcE0ecpy9AvCwhr0055MbMPI8QE/sp/uQ3/estduPzLt3pvixDSUKczL5Wd2VN9Y405y7BQNA3U2qFYAiVi6MAYo70Ib37JNmamJ6LvgkpglbfTPljFMd8AEimlUURckVVPxNhKxGI2WCW8EjH++6WG90w0QSg+85NGidhsL40S0fyedmYSG3XvNArUb1SdR3VPxITp51sZFhF7TNvEy7cwpW8z1QRzRl0ZJOWyfExpZ56xaQfs9ci0Z0LCom6Ek9qZAy+oLGKBJsR+hJhjqLHwcEIlov5yhlDK6NcM336YhGxl1LkQTolofp/i/LIDSHx7ItrKxtVKiRg/WMXcj1GAdOZce53KnogJ3i8trEHZj4OlMy9TT8TQdmYWEckGqI/IyjCvf+Yzr216IpaPdZGet1GdYBGxx7QNzN5KRF35kCydeQF25mqbKVchQlsT9W2yiEhIWNRYkTJYxR4zfCdOM2PQEgSrAP7XLSllfdOYUomoX1+CpzPz7pdsY5pglUDpzEvRE9GyM3v2RLQVe6vFvfXzxGRqKSz905klcqEdVzI7c/mYCWhKRH+1VGlnTldEtM+Fg4GViCnFG2RroD73K3kYO3PTekAYj1QidoNFxB7Tdn7525mXQImoXViBsLawpHbmBQTG1IUO9ssiJCjq/Ew1DgILsDMvwcS5bT9CJq2m7IkY2s5sKBE5xpNtjDq3BtUk07dBfmN3K79P0xPRtjP79USc6R1YpTPHLkxJKZGL5jmHwleJaKZYp7Iz64WJID0Rq0PKRKNETCHas88l32solYikK3ZwFuB3z6M+06rXaKNE5GexCywi9pi21R1ftYK+yWT9sqwV5xCKjmVMZw4xga8tlxwYCQlKo/JdnhYI/nbmJS0iBgwES6pEDKzkN4JVqEQk2xh1aoUo3ujbW6ka+adK+zXtzH7FttIerRcRE6UzF2YRaoSx10L3bLBKGjuzrIuImhLR47gMO3NCJaJ9/fXuiWhtj9MTshG6KncQoN+o+swJBqt4wSJij2kriIW0M6e4SANNYWyU+/ccUahtpO2JaH4fVonICSYhIVkOO7P5ve9Ed2YhI1G/x5mx0LNQq4/rKYuI+nGFDlah2pxsZ2Z7IoYZM1YGZQ+uVMo2I4BE+PVEnOkdWByqnycmUppj8BDTsOnMYpzUziwCKRHrwonQ7MwpglWsl9K7J6J1LtHOTDai0FS5So3ou/BQbq/8XgkcaWfuBouIPUa/dilLhu9Ewy4ihkjN7Io6+VXUe8h05pRijllVUYhJZvnInoiEhKVW+U7DpAf77INizTud2fw+VfL0THpjQCViSjtzaPvxMvQoJmQZUGPGIFRPxGp7q8N0SkRZhO2JWBRApvUOXCnSKBExo0QMnc68hkImUFhqduY891dL1enMWZMem8Juac9NfIuI9rlJ9RfZCL3op0KLfMYMqRXoAdqZXWERscfoA3+o1Vl9sixlmEJXV9QxhDomYDntzCGUJXqhgxASDjPUIpVib9F25n4c13RplIjNfoS4bukvU6rPICHLgDoXhgFScQHdzlwqEVO075lagSG+6cxT285cpElnxowScYLCs9hmKCwxBhC/8KsHq4QodJjBKuXPUiil7Of0XYizz00WEclGGHZmtVAU0s7MYBUnWETsMXpFXVl/fSca9gmWqk8MoCsRwyn20tqZw06c9W2yXxYhYdFvYFKpwEKPx/b2UrVBsId03+FLvxZOC5mu12PgwrOR9syFIrKNsRvvhwpWSdsT0bIzY+I1Js8EtVRFxNjqGzk1i1CZkCimE+ft2ce1ijUAaQJjBArsWbs1aE9EPVhFSkR3PtifD9+eiPb7wroN2Yi2VgE+i9zq3imzlIgsaHeDRcQeo58LKoTEt0hmn18pVmfVBVQpEaUMd8OY0hFmT5RDTArVQJmqt5nixu8dxOe+9f2k+0BISPQCTrLegYHtzPbkpI/BKkA6NaJR9AvYhgOgnZlsb+pgFeVQCXSvuzpM3ROxeV5/O7NZlBzVdmb3fXRCzj6hmK45b25q2b5XRKlEjK3OLqTE7wz+Cs+89jw8fPpP1b75FxEHoqiLHL7bdMF+Om87c+B2JaT/1CFDQjQq35A9EbMwNZLtBouIPUY/GdSNlW9hanbSmsbiATTqSiCAwrL6+1S9zYAWFVAIm3adzpx2gvmsP/8snvonV+OWOw8l3Q9CQqFPvJL1DrSGCG/b70xQy3IUR/2DVczvDyfqi6hfX3yPyd4e7cxkO1MHqwTqbaX+PqUSUUoYCrswdmbNoTStlIixg1WK2fFXTsfO22tLZwbiKyynBfBD4iYAwH3ltwH4JshKPDq7Dn9845Mw+ur/aZ4n8vtlF/3uHYcNcKP6i2xEnXyeNWpzn3NLnUIqsKi2M3MtthMsIvYYdQHNRHNjFTKdGUiT0Kx2YTjQi4hhLmrL1RMxgFJFqm2lvUjfdtdhSAncftfhpPtBSCj0CUoyO7Odphw6nXlJ7Mz+qiKrp1MiJaJ+XCHbcABUIpLtjTrFmwlmIDtzwmCVWTuznxKx7B2oKxEP1s8TEyFni4iZlxLRDlZJo0SUWjFzRUzqfXNlWgA/lv0rVuW9GN7wyfrnsdcsbXHFocB2Zqq/yEaoz4xuZ/bqN1pvr/yewSpusIjYY5omv2GSwvRtKlIoEesV4oBKxMbOnG4AsS/UQQJj6mCVtBNMdSypFZGEhEIfc1IV6e2kel/l4Exf1iUJVvFpoF3+vWVnTqRE1PcjiJ2ZPREJAaApEQPbmVMGqxQSpp1ZTL3u5aQ0bb/D6SEAMr49Vhv7ptmo3Lep+wJzuxIxwXFp+zES5TXGVzmqAmNEsWb8PCZT6z4jdDozCzdkI9RHZHdxdxBnpdpezmAVL1hE7DGN57/pIeCfWLcMSsTqZnGg9QjxnEA1dmavzQTZB0UYpcpyFBHVezZOmIxKSEj0sTDFOKjvQyjL3TKM78DsOOy7G/bYesjTjuVKaPtxEbgoSchWxS4ihuqTvToMs0DjtA+WcnCISVAlYoYpVjCOrwSrlIgSoi4iZoWHnbkwU6wzITHENPqYWGhF2hH8lYh6D0u9Z2Ts4qh6vl2jsqB+0FOJONOuhIUbsgGFlHhi9o/4g39/Ah43+QcA/q0CAD1Ypfw5rfXdYBGxx9TJXlkj1Q3VO1CRIlCgbjacBeyJKJfAzryAdGa1zdT9shpFJAdo0g+WIdRCPa1Sy/gXEc3vUxWmZhdUwhZHD08SKRG1/ZAy7KLeeCqT9vQlJCV1sEoWprdV0xMxzNjqgpSAsIJVQvZEBICdOBR/4lwFqxQig8yG1c65FxGnlu0bAFawtvWViIXEAOV2dLt39DTt6ul2rQwAAIc8lYj2a8L1L7IRhZQ4NfsWAOCU4hsAGKyyDLCI2GPqxqFC1JLd8ErE+JMxtQt5JrQbRs/jqv4+5YrYItKZ6+JdYgWgentSKyIJCcUyWEltJaLvfizDIhEwe53xnWQsSzrzzBjva9NeQAsMQrYaevFcWd18J4ONnbkaWwsZvXhj23RDpzMDwC5xOH6YQP2EGYqqiJgVfunMuXVcqxgn6ImIWhE5rJWIfmqp+v3S7N6xj0t95nZXRcR7x1OvBSumM5OuFBJ1QX0E/wK9uj4I287Me6hOsIjYY3S5bigl4kwRMYUSUbdpB+71mHL8mOlHFsLOrGzEiQfGej9YRCQ9QW+hkMr2q254VPN/3/2wx6C+pE7PBKsk6ok42+sxzOKXIrXinJAU6B/7YbB7QmVnzuufxR4P7SLiEH49EQs5W2zbgcPx05krO3MhMhSVnVn42Jmt1wkolYgpir6q6KeKiD4Le7r9XEzXatVU7PdL3b/vrOzM00J6uYqakIzye/ZEJBuhhyc155bfWAjodmYWEV1gEbHHNMW2JrHOd7BejnRm7bgC9XpseiIuk505QBGx2kaIpGcfaGcmfWOZlIiroezMS5LObI/DvpMm+zCSKRHtIq23ctT8nos0ZDuin1fNPWGYbSolIhD//qWQgDCUiP49EW07866EdmYp8trO7FNEtNOZAWBFxFci6sXMYaWW8nq/pERebQeTtWSFDnU9VnZmwC9cRd07DQOphkn/kbKx9o+q5HMfcYzU6ggAgrk1txssIvaY+iTJQioRze9TpjPnQY+rsjMnHEBmVCUBJoTquFIndxZUIpKeoZ+vqT7Xah9U839vO/OSpDOH3o/ZYJVUSkTz+9DtRVKP84SkQD8PVOBeqGAVo4gY+X5Xaum8QJnO7Gvhy4R5DDvFoehFHFGo8TeDzMsiYj71sTO3KRHHCXoiNmnatVrK035ev//Tw8ksl3rvZTXn8rmGqv0f5WFEIKT/TAvMKBG9WgVUf5pVn2f1SGt9N1hE7DG6XLfpHbj1lSrtxxVmkpnyWrbIdOZUdkugvBFWh5JyPwgJiX6zkczOXE90wygR7funZMVRu9jm3d/M/Pt0PRHDLhQti/2ckJTop8FQKRED9UQc5FldOIk9Huppv0CpbguZzgyUSsSUdmYZyM48G6ySoieiZrkU/unM06LpsYjJ4WThD+pcyjNgR2Xvv9cjoVm9Jqr1AJWIZCMKKTGwiog+ynA7nVnVEmit7waLiD1mET0R7RuzFErEJnVaOy5PBYZ+EUtlaQ6tUtG3mbJXlv5yUilD+oJ+fqazM5ePdU9Ez/G47rFYqW9StR+wx+BQi0SKZbEze1+PZ4qSHF/J9sOwMwdq3aP+PhNNsSP2YpGtHPRNZ27rHbgDh5PZmSEyyLwsImbSXYnY3hNx7C2a6L4f0OzM/kpEw848XUtmuZR1EVHUPUK97MxKiTgI03qA9B8pJfIq8bxRIvqdW0BjZ66ViPwsdoJFxB4z1W6CQvUOXAo7s9ETMawSsdy+16acmbWmBbAzq9TpBMmCCr3wTDsz6Qv6mJHczlwpEUMtEjVFxOUotnmP77YSkXZmQnqDGazSpCn7bbP8eyFEvc3Yiyq2cnCIiWeYgISweyKKQ/EnznVPxMbOLKY+PRFb0pnFWvTj0hWRAxnCcqm9/5PDyBMp95q5pMCOUXkuhCgisici2SxTo9+of7CK+sjVwSqCdmYXWETsMfpJkoVS7C1BsIp+XHmgJD79MFL151iInVkv4CWyui1DsYWQ0CzD59pOZ/a3M6vtVUXJVD0Ri7BFRPu6lUqJaB+HfxCO+T3tzGQ7YgarhJkM6m1zRnmaRRUpYQSh+CoR24ptOxPYmYUqIiIDKjvzwMfO3BasgjXveUHn/TCUiOXxLEaJ6LefXdHPBWVnPhTAzjxiEZFskkIiuJ35SNyF//vLLwa+/g9NqwDamTvBImKPKTQJ+iIUe0AiO7Nm01YKy1CrzvbXMVmEqkQfEFMVBPTDYk9E0hcKQ2Gb2M6s2Y992jE0DdQre/S0SNLeIbRib1mCVYLbtKlEJKR2xwKauinQwkOeNduMfb9r9/obLKQnYnw7c1EFq0jDzuyhRNTtzIMdANIEq0htP2olok+hQw9WmRzWLJex369mLrkjhJ3ZSmdmsArZCH2hQBXofT43hQQenX0RD7j1Q8A1lzahRSxod4JFxB6jTjAhUFfZQzeoT1EU0u3MoXoi6oNRqjEkdL8swHy/lyFplZNc0heWQYnYJIjm9c/8lCqVPXqYz/wsJnaxzXdhZ8bOnEqJGHiMX4agM0JSIzW1nupfGKpVQCZEnfgcP1jFsjN7pjNLOavY2ykOx184V+nMIgcqO3Pmq0QU1TGMdgIAVkT8YBXDzqzUUqGKvpoSMVWwSib8eyJKKRs78yBNUZRsPcpglfIzpwr0Ps6LQkqMRDXmrN2Dqp5NJWJHWETsMW0pxqEUe6oZ6ThFT0RtVSyYwlK7KKdaibDvT0M0hda3mUoFaCq2OMkl/cAMVklrj10dNpdyn3OstjMP9O2lVyL6F9vM71MpEWeOK2AgGJA2QIuQVOgf+0Egi6TaZsqeiMWMndmvJ+K0rYiIQ9GLOMLoiVjZmT2ViPVxDXcBqJSIsd8vzS4eoieiYWfW0pmjKxG1ed+OUVlEPOhoZ9Z3XdmZKf4iG6G3YlAFep/zW1cNY3xvMpXvVodFxB4jtYG/vvh4TnZtpUoaJWL5KIzUad/eUktgZ55RlQS2Myfql6XvA+3MpC/oiw1ryezMs0pEn3HDDlYB0vTZC90TcTZYJc04ZCssvXv5zhQlOb6S7Yd+zxaq0KLGjFwgYU/ERdiZzb/fJeL3RNSDVVAVEXOfIqKu2BtqdubIx1WmaVefG+nfE9EIVpkerrcd/bha7MyuC3H657e2M7OKSDZAV2WrcyuYyndyiMEqjrCI2GPqRK0spBKxfKyLiCl6Imq9ahbR61EmmoeFTiQFlsNKbNg+JxygST/Q55OpijdqzBgNwigR1alqbC/BGB+6d+BssEoaJWLo8KxFLDy5cPOd9+JX3v5pfPSrtyV5frK9MVrciDD3uvUifNYoEWMvgtpKxKFnsIrdYxGoglWipzMrO3NTRFTKPReM41J25iQ9EcOqpWzl6KooX7fYlkv1sRdC1Epf12uN/p6o+wyqv8hGSE2Vq8YKv6R6GEpEBqu4MUi9A2Rx6HbmPAszWKubtR0JlYiNwlJXIoazhSWzM1vPG0IBtAwqQP2tSaWGJCQ0+rmVOp1ZtXaYFNKviFhtb5Bl2vbS25nDB6ukHQszUX4dspcvkG58veqrt+PjX7sdK4MM//d/Pi7JPpDti9TudQd5mL5xhVY4UX0WYy+o2D0RfZWIut0Wgx3A5F7sxOH497y1EjEPokQ0jquyM6+KtSQ9EW07s2+a9kB7/1cyZZFOY2fOs1KZC7gXW/TP2pDpzGSTTI1zyz9YZcbOTCWiE1Qi9hjdzhxKiahO2pVhmrQ6oD2dOeQkc1nszCH6uSyDEpE9EUkf0Qs26ezM5WOm9e3yOc8N9XqeJkwAaMaMUCEJs8EqiXoiFqZyNNSiniLVGK8+I6l6TZLtjR6CUk8GA/b/TtcT0VSiDTHxGo+nUiIT1d+v7gEA7BQpeiIqJaIABmURUSWuumCkM4+0noiRF1X09ysPUOgoA2M0JaJSNyawaQOlyjfzDOnU5zWjQEnqpP8UEsiFOreqYBWf5HMJrd/oIa0Nht9+bjdYROwxerEtVNNQdTFZrXpwpZlglo/6qrN3cVT781RFxNBWN3ubqRNkAWCNdmbSEwyFbapzS2vtoApuPorj9olzujE+VM8ke5KSTomoiqPVa+vby9dWrydWxKbqNUm2N02fbGi2NN9tNoUTVfSP3xMRQXsiFoVWlFy5DwBgFxKkM1fPJ0UOkSklooeduZCN7bu2M69FX1TRLZIheiIaxVEAq5USMbbgXH3mhBBN77gQSkS1mMZpAdmAomjSmZtzy+9el3Zmf1hE7DELSWeuzjmV0JVCiahPnJvVg4DBKsl6Iprfh7CmmcEq6YujVCKSvrAMn2t9oShE0U+fONfKxgTjhhq36iJiX5SI1W6o4BpftfkiFp589uNQoteVbG/UeGEoEQOlM+tja/yeiGYQykD49UQ0ilJVETFNOnM5TkiRNUpEz3Rm286cpieiFv5Q+Kcz64UTABglUyKWj3kmvIstal4jNIccCzdkI4xglSJAaJG+QFOMkVdjEu3M3WARscfUqpIMwarstRJxmOamSt8HozjqORnTV2KT2ZkXEKyibyKZElEPoGBPRNITzCJi2uJNnoWxMzeKnqYPWJKFIkuxF7on4uEExwQ0N6ijukAbNp05tdqcSkSSgqYnIoL3yRaiUXnHPr8KCcPOOvRVIkotqGWltDPvSmBn1oNVhOqJCHclopG0qpSIIkU6c1OYyAIpEc1gFaVETGRnzgSEUD1HHbelWnJmAtWmWLghGzItGvtxqUSUXg4gKaXZbxSHAbCg3RUWEXtMrdgLqERUg72yM6ewp6pxI8vCBcYsQ09E+xhCFCb0i3PqCSZAOzPpD8vQ61MPFBgO/O3MTVESdb/ZJErEwD0RC6t4l6p3n+pTrOyRoRJkFal6IqqPHJWIJAVtC8u+k8G2BZoUwSqmndmvJ6JhZ656Iu5IYWeuV6vyIEpE43UapktnLvej+twE6IloFEcBjESiYJXazlzeGwDuhT+1cJYFsEaT7YPUVNkC5XnhF1ok6x6LADCUa+XPWdDuBIuIPUZXleR5WNvvasJ0Zr1vl7phHAdsUJ9qDLEHryBKRN3OnGqCuQTFFkJCo9/ApO5Fl2UCwyyEErGZOKfqAwY0Y3A4JWL5qNpwJFMiBg5WWZZ0ZvW5YbAKSYGuGgzV/1tfoBklsjMbCaLw74loFKUqJeJOHIaMPG4IXYlYFREHwZSIys6ctidiVgQIVrF7Iopqm5EnKer5cq3w51qkVx813RptL4YRYlOeC839xRAT73tdfYFmJEslIgva3WARsce0Fdv8LR7lY5POHH/SILVV57o46nlzpw8cqQYR+zoaYvK+DAU8/QaBdmbSF5apQJ+H6omoNVCvF2gSjBvquIaBgrPUce2siojpglXKx1Bpr4tQr/vsR6riLNne1P0LM63I4d0Tsa0w6bXJ7vtQaPZjlHbm0D0RMyExLA577Wd3yn2QIoMYrAAAhj7BKlOJTKjBNZ0SURrpzBMA0utzaCsRV6oiYnQ7s6bK9U5nli3b4mWDbMBUYqY/qF+wirm9laqImMqJuFVhEbHH6AN/aNvvjqFKZ05gZ27ridjDdOYQN0DL0bet+XpMOzPpCfpNdApFNqAvqKBOqveyM1eHZBYlU9qZy33wtiZKs4iYIlhFX0xplIhh0pmry2CylPA6WIVKRJIAfWE5U3bLQO6UPNMs0gkUYCHTmaWUEHWK8W5IlMc1kvd67Wf3HamOSeuJOMTYXZEmtXFHKRHFOHorDls5OMTUK6hR7wMHNMEq8Y+rfBQBlIjqmpdnor5u0UJKNsJWDvoqEaW9vYJKRBdYROwxbQN/qGbTtZ05SdP98jETQktnDqNU0bcfG3UMoQqjgJ3OnHaCCaQrthASGv1zna4XnWZnDhCsohclaxVgipYVqr2VsjN7TjLU67RzNACQRjGnf15GgQq0TdpzeT1Olc5cB6tMClrTSHSae0IE67OmxqCyMBkmwK8rM3ZmURYRXc+xsiilDmyAYrADADCaxi0iirqImCNTPRExcb73lm1FRKxFL/oWhaaIRHlMPtcuuyi5UhURUxSzgcrO7DnnUrcTg8y/IEm2D2VSuVVE9AyZMgr+qicip6idYBGxxxQtKpVpoBTjOp05RRHRaP4fSIm4BMEqM033e2JnXoYACkJCY6p8U6X9lo+51rfLZ1/aipJpeiKaduZQqiKlRFybFAkSLpuvQ/dEVO1FUtuZpeRCEYlPYz0Wzb1uIDuzUZhMkvZrFqUA9/tdw84sMhTDquBWxC4iNj0RUdmZR2LiPh4WmhVaszNHX9yTphJ7iInXdWZihT+MhH+fRRfUMWS6tT9AsEqWSOFLth6FlGZSvfAtIlqqYdqZnWARscfodozcc+BXzNqZ0wWrCBHOpq0PHMnszIGTOwEYVopUE0z99Uyl2CIkNMugsK2Vg1mzUORVRNQUB8320rWsCBesYhYRgfjvmT4OrgQa4+3U6WR2Zu3Y2BeRxEYv+GWhlIjGfWaYYEKXfbDtzOV+uB2bkc6cZZCVEnElsp1ZKRGlyJANdSViCDuz3hMx7vuVSfP5Rr5KRDudGYnSmev7jHDBKroSkXZmshHToqUnomf/b6OIWByqnoefxS6wiNhjmhurpqeL/41V+ajszGlsYeWj3qsmqBIx0RxIHdcogC2x3uYSFPD0t4ZKRNIXlqE4XisHA/Uw1BNJUyoR7QCSUNctZWcG4vfv0z8vw0BFP7XNWomYys6sPS/7IpLY6OOWKvj591EtH00Lp9cmOzOrRCzPLdf73bIoWf2tyCHzUgWYycjnrCq2ZTmyqifiCO5KRKm/McN0PRHblIjB0rQBjFAFq8S2M2tzLtXH0PWwaiWiFqxCOzPZCLu1w8g7ndm2M1OJ6AKLiD1GFcP0ldRQyocV1RMxodUtEwi2QjxdAiViYSkRwwersCciIaFYpnMrz0Rt/Q1lZw65mNGV4Hbmohlb1TUj9gJYm53Zf/GrfKx7Iib7HDZfH06UfE22L/qCeRZI3aSrvPNEtku7+b9S4rie51PdHpvlUCk0mUcysgtCD1YZVunMPqq9YlaJuIq1+Koiu4goPNSVmA3WaZSIzpt0wlD6erra1LYGmXau8pJBNmA2tMg3nVkaoUWDKYNVXGARscfUdmYBTYnoWWxbAjvzItKZzWCVtGoONcEc+yZ3Wq8JeyISEgYppamwTXTjEVo52Cgbw6Q9u6JezkEeqNimXTNWq/E1drGrLVglVHE0lD3aeT8MOzOViCQuTYgggqkG2+zMsRdU7InzoA7WcN9epvVERFYqs0UR95wVavKuBauMMHZXpOnFu6FuZ477fgnrnr3siei+vTJMQrdwVj0Ro/fmbK6fvnZmdQ5lmUB1GaT6i2zIVJpJ5cGDVWhndoJFxB5jDPxZGFWJunAkDVbRrSuBAmNMJaLXprz3IdQE077RSDbBXIIUW0JCYp+b40R94PTiWBg7s65sTNdnT72+aiz0nWToxVGloj8UudilJ6oOA4Vn2X10UytiAeAQlYgkMu2te3wXzMvHXC+cRE/7halEE1MA0isZtwlWySFVERGRi4iaEjHPy33IUXgEq5T7L0UGDFYBVMEqsYuIlhLRtyfiPCVibPuv7njIPFtjTTUlYqrzimw9ZuzMnipfaQW1DAramV1gEbHHNKuzC+iJOEgYrKJd0MIpEZuvU61E1KqSobKmhZk4K5ah6T7tzKQP2BOD1MWbPBNBg1VCFSVdUTdyys4cSmmeZ8ulRAzWXmSQznoO2MEqVCKSuOgtbnyLHLPb9C+cuFImkprPmaNwnugaPRGzHEKU95pZZCWi3hNRZNU+COlecJOqiJgDwzIsZiimkNOx9652wS4i+qYzz4Q/IE06c1vPUdf3Su/jnOq8IluPaSExsOzMPrfdhTS3p4qIqcQ2WxWnIuLFF18MIYTx74EPfGD9+0OHDuHCCy/EMcccg927d+MpT3kKbr31VmMbN9xwA8477zzs3LkTxx13HF70ohdhMonbl6PvNOnMzY1VKFvYjpEqIsroq2J6f45BoHRm/e9lopUI9dqu1BNMv0mufRhriSaY+n7Qzkz6gH1qprrx0CfPIRJ6dZV33WMxQdKUGjNUIdM/JKGxUKVSIi6iJ2JjZ64WnhKlgunvD3siktgY7pRa3eS7Tf0+M8z9c1ekZbkDyr6IzkXEwrYzl+OGXfxaNI0SUZT7gVJx6Tp8SakfUxOeVUwjzymtdGavPo8or1uDliJi/GAVzYLsaWc2VI1UIpJNYvcw9C3Q26FFtRKRRcRODDb+L+08+MEPxkc+8pFmQ4NmU89//vPx93//9/jrv/5rHHHEEXjOc56DJz/5yfjkJz8JAJhOpzjvvPOwb98+XH311bj55pvxK7/yKxgOh3jlK1/pcThEp9BWfIIpES07M1Cqy1arm5EY6Be05uYuZLCK16acUc8brOm+bWdeAqtbClUTIaGxz60UbR30/cg0+7HPYkGj2Gv6EY4n6ZSIoXoi1sclRK3ai13s0q3ioXqs1QtPg3SqUcCyMwcqzt51aIxdo0G9AErIPNT5bfZEDOO60Xsiplgwz9BSmPKwkuZtPRFl3LGwKSLm5T+UxdKJYzFJ9SKUIq8LowASKBGt90pMvfolTy07+1CmUSKq60yuqwcdd0Ht+yDTzyv/fST9pigkBkIvqLsvpgDlAo0RWqV6IrKg3QlnO/NgMMC+ffvqf//pP/0nAMCdd96Jt73tbXj961+PxzzmMTjjjDNw2WWX4eqrr8anPvUpAMDll1+Or3zlK3jXu96Fhz3sYTj33HPx8pe/HJdeeinW1tbCHBkxV2eD3ViZygcgvrqsrSei72RMVx+mtjOPAlnTZuzMiY5rar22XOkhWx27B2sqBZjetyuInVlbeBoFUkT77McoDzN5n2oFvFqJOI6rvplqyqZhoL5t6s9HgXosumLYmQMUZ2+76xD+r9+/Ehe++/Pe2yL9R78n9E2PrbfZopiKH2iBmSLiAFPnokupRGzszKrgliG2C0xPiFb7UPgHq9hKxNiBMS09EX0uXUUhMRS6+ipNsIrueKguyd5KxCzheUW2IIV9bo29PjelnbnZZl6nMztvclviXET8t3/7N5xwwgm4//3vj2c84xm44YYbAACf+9znMB6PcdZZZ9X/94EPfCDue9/74pprrgEAXHPNNTjttNOwd+/e+v+cc845OHDgAL785S+3Pt/hw4dx4MAB4x9Zn0JTqYRSIqq/Xx02RcTYKhyprYqFPi59+7GxwwRCqUYVqdRS9uuZwh5JSEhmeyKmXXjIMxHWzqxdM1L0MbXtzN6qbDVn1ZWIkcdDU9lUKQcDL+qlSgnXrzUhlIjfvP0e3Due4ss38T6PbIzev1DZLUMtmOt25iQ9EWE+p48CxwjqEDmEKrhFvifLKsWeEJmhRHR+fWWT9qy2BwAoYtuZzbFv4KEaBWaLoEqJmCpYJcv8BSm1y0BPZ6awgGyEXaAXnv1G5ygR+VnshlMR8RGPeATe8Y534EMf+hD++I//GN/85jfxkz/5k7jrrrtwyy23YDQa4cgjjzT+Zu/evbjlllsAALfccotRQFS/V79r41WvehWOOOKI+t+JJ57osuvbCj2RMg9k+1Xn1yBrembFnmSqCXxpXQk1yUxvZ66tacMwCqDZdOa0aikFLc1kq7Ms6cyFNhY2SkS/1VmgXKAZJgzrqINVqn3wVhVp/YHVAljsABDdUq3eK1vR2hX1OayvGYmW0fXTIYQSUV3P2UOXbIamQL8YO3OqAAg7kRQolYiu42EhtR6LmgpwIKZRJ89Czu5DjsJ9nFfpzFnT57H8caJejxW+lktbfdUEq7hv0gV1mcqrDATAvY/hpJ6XUolINo9sCS3yVSLqY2umlIj8LHbCqSfiueeeW3/9kIc8BI94xCNw0kkn4X//7/+NHTt2BNs5nRe/+MV4wQteUH9/4MABFhI3QE/UqictwVZnyx5c4+k0es8s3boSOnW6/DrNIKKetlEUhVUipkrutF/P8aQAVpLsCiFBmPlMpyrQ672KAjQp19U3anspCjl1ETFQLzJdSaGUiIci90RsUzb5fm6WMZ05hE28KSLyhp5sjH5fqoqIQGXfdeypqQdAKAtn7AnmtJjtiTjwUODYwSpKiagKeBni9B8V0OzHdbCKR6sbQ4koUCBDhgIytp3Zeq9GnkpESFNJWfdETGVnzpoivbMaVvVEzLUiItVfZAPsBPkyWMV9e3Zo1WBKJaILznZmnSOPPBI//MM/jH//93/Hvn37sLa2hjvuuMP4P7feeiv27dsHANi3b99MWrP6Xv0fm5WVFezZs8f4R9bHvLEKq9jLsqYP01rk1T7dwhdKYalfxFKtRKh9GC4oWCWFLRGYHZRpZyZbHfvcXAY7c4gbcr3YpqzEsYuIUsp6USecnbkptjZ25jTpzCEsYYq6BcYgjD3aFSOdOYAqV/WKpBKRbIZCL3KIphDmcy9n9IFTLWYij/Ol5a7Nzuy2PaMoKXIgb4qIURfP1XOJ3Ehndn6/9KAWALLapoxsZxYthQ6v+cSMnbnMDEhmZw7QLsDYVnUdpPiLbEhbEdEn+bywlYgMVnEhSBHx7rvvxte//nUcf/zxOOOMMzAcDnHllVfWv//qV7+KG264Afv37wcA7N+/H1/60pdw22231f/niiuuwJ49e3DqqaeG2CUCs5F76D4xeg+u+L2lNAtfqMmYNnAk64k4oyoJVxgtt5depQJQXUK2PvZNfKrAoNYE0SBKRNGkM0c+X/XdVwsqvq+tft1arYNVIrfh0CZPoYqj6s9VT8RkwSp6T8QAr6v6zKU6HrK10F03mTar8epHp20zT2S7tC13QGVn9kpnrlczIGorsXtYiwtZpUQUmv3YpydiXbyr3nypiomRBQ6YSWf269tmF04GiZSI+rUrZDpzqjYBZOsxG1rkPg4C1dgqWoqI/Cx2wsnO/MIXvhBPeMITcNJJJ+Gmm27CS1/6UuR5jqc//ek44ogjcMEFF+AFL3gBjj76aOzZswe/+Zu/if379+ORj3wkAODss8/Gqaeeil/+5V/Ga17zGtxyyy246KKLcOGFF2JlhT7HUOg3Vo1iL8ykRZ8IxZ5kGjaTUMel90RMNG9RxctRICWifRypeiLah5GqfxwhoWi70RgXBVa0fkwx9yMPpG5TQ0Su9byNrQbTi6CDQOO7PglKpUQ0lE3quDxfW3XdSm1n1t+zEK+rer+44EQ2Q7OwbNmZAyyolH0Wq59F74k4m87s02dPSiATjWpP2Zl9+iy6IAwlop7O7Lo9W4lYXYcTpzMPPdOZZ4JaEgWrqLcrDxCGot+zZInaBJCtR+hzq+wPq6UzT2hndsGpiPjtb38bT3/60/Hd734Xxx57LH7iJ34Cn/rUp3DssccCAP7wD/8QWZbhKU95Cg4fPoxzzjkHb3nLW+q/z/Mc73//+/HsZz8b+/fvx65du/DMZz4Tl1xySZijIgCakyHLmqb7wXpLCdHYmaOnM6Peh1C9HvWLWGo780ooC59tZ47cu1IxY2emuoRscaZawV+Nf+OpxIrTFdUdPQilsTO7b2+qKRGHgXqzdkUfLtQ1xtdqZyjoE123pto+hFbQr9R25r4oEatzqiggpayb+RPSRrO4DaOI6HMPpY8ZodoBuexDbWfOhkAxxsDDxmdY+EQGVEXEzCcZ2YG6IBAqWEXfHtAkNEe2M2eWEtG7J6I1ng8qO3NstZR+X+DbNkW/DiqFLws3ZEOsc3ko/OzMdmgV7cxuOE15/uqv/mrd36+uruLSSy/FpZdeOvf/nHTSSfjABz7g8vRkk+gDfwjFnj7Qh5wIdcU8Lv+JrpTSsNAlszNX45ma5E4L6TWBmrEzJ1Mi0s5M+oUaC1e1ImIK62WrWiZQsIoqIsbuparvf6hCphmSkMiaqObumlXc3xmwHEpE/WlDKhGlLL9Wi4WEtGH0/9bul3yKE4adOcDY6roP9UR3sAKsjb3tzJmWzizyKp0ZRdx05mofhMjqgl8uJKaO1xqhB6ug6YlYxO6JiLB922z1VSo7s35fUDsePINVcs3OnCrMkmwdhBUyNMLY286ctRURqXHpRJCeiGQ5CZ1irA/0xsUkweqs2ocQx2X/bapFMXVcqogIhHu/gIQTTCoRSc9QxZ/RIIOas6YILlJPGSpYRQ9qUYWb2MXRtiKi76RJHUImBHKlXo+usGxUo7VN23MfVGFS9URMNbbqBYgQSkS9uBpb/UW2Hm2tAoAw90/lNpuF3ZgURYFMVM85KFs9DYW7ndlMZ85n0pljkdV2okaJWO6f2wJEbWeutiWr41oGO7OfEtEsnOSqiBj9cxhOPdga0sIhnmyAaFH5+i4SDbQiolB2Zha0O8EiYo9p7cHkoUbTbzL0hMnYCrfQ6cz2zVOqxqp1ETFvTssQdhxFsgnmkuwHIaHQV9OHAdTQrpiWO/9VfXUImWiCs2Irh/UhbxioXUXzOiFpSAJQLX7lYa6dtZ15mMZuWe+Hkc7sP3HXz6UUxXmytdADpsp/5fc+53hbsSP6+aUXpQar5YNHOrMR1CLsYJWYSsQqWEVkdTozABTT7spBKWWtbJxNZ45dRGwJVgmpRCzSFBGnUuI38vfhIf/4bOQo3yMfSz1QCkDqexYuFJENaO+J6LdgbtiZJ/cCYLBKVyJ3cCIxqW+CMoFBgJVU/XzNA6kbXTAVluXXfjZte/tpJ2KjQbMy63Ncy6IAtA+Bk0Ky1dFVZcNcYG2a5vzSC1NKiRjKzpxskahNiRiwl2+qiYte6AilRKyvGbXtO1FPRO09C6FEnGqfuVQKerJ10MctoByXJ1J6heSZwYTV88QOtNAPIB8BAAYe6rZpASOdWfVEHIi4SkQhC0CgVA7qRUSHN0zv8yisnogiYhFR6snXFSOPgm+50Wn5OlXkVU/E6Lb6Ajh/8GEc+53vY8+Bfwfgn86cZVqxn4UbshGFXUT0C4Oy7cylElGyoN0RKhF7jNnTxX8lVR/oQyY+u+5Hlmm9pTwmGvZAlErNbPe3AvwmhbPpzOlVKgAnhWTro49Bw0EaxZ6xH6LpLxTMzlwX2zx30nEfgDDtKgCrmXvi61aooAa9d69SIqbqN6u/Z0GUiNr2qFwnG6Hf6wJhFkCMPotZmLYKXZEtSsShh2qwsFV7WrBKzOGw7omYZZadubsScaqrK6v3SdmZpYxXRCyklnxd4WtntougqZSIhZQYVOrRgTxc/swzWGUQyD1BtgeZrUQUE69709LO3GxTQJZBSPwsdoJFxB7TZmf26QNl2pkRRN3owqJ7IqZaFVPPOxyEsTPbg2GqCaYdVMNJIdnq6JYcNQ4mUSK2BYb4pDNX2xNaUTK+ErH5ejgIc41p7ekUvSAwa2cOdd1SPRFThWfp15rDIXoiatcqXi/IRkit4AdAUxu7b7NZKGpCq2LfGxpKxIFSIrqr24x05iyvFXuDyHZm1RNRaPsAuNmZiwJGn8dqw9Uv4wWrGFbxiqFn3zbbwpnX6czOm3RiWmhFRM9CppqDprwWk63HbLCKp53ZUiICwCrWqIrtCIuIPca0M/urL/R2H3lCJaIaN/R9GPusOM8EqyRSc2jHtYjiaKrJ2LLsByGh0MfWUVUQSmNnLh9DJYjWY5B2zYi99mDYmQMtVBl25kB9FrtSv1faa+vzmdELd6nTmRerRORNPVkfPaUeCNP3VLczhwitckHoRTBNieh6XHZPRNQ9EYuox2aqIZsioksPQ/2YajuzCliJaGeeV0T0+QzOFBGrAl78lPDm2HL47YPuCgjhniDbhCLsuSUlZs7XVayxoN0RFhF7TJud2avRtJHOLDQ1RdzJ87S+YQzT38oeNJLZmeuJbqNU8ZlkzqYzp1KpmN+vcVJItjj1jbBoWioksTMrBU6odGbtuOprRqKeiIaC3nNQNgJoEoUk6IXMECp+/W0ZDdKpYYFF90TkohNZH/VxUWNgkNYOMuz56oLUV+6rdOaBh7qttDNXf6vZmaOnM6tglSyzlIjdi36mnVkVEeOnM0uJGWXTSLjbmaWUdVCLrBojZoVSIsZfAFOvsbcSsWjuMep7Fk4JyAZkmA1WkXLW6bZZCimRW+0HVgWViF1hEbHHLNL2q6czx77H1yeEWYAV55l05lTBKlpBIMRN66wCcDnszCEmhfeuTfGVmw44X0AI8UFNWvNM1AnCSe3MIkx/IV3RUyvNY6czawWBUCEo07bXKXpBYHaRyCsQzFAiKjtzomuX9tEPoUQcM52ZdGAmWCXgWJiJutVeWjtzXhURxdQrGdcouCVIZ5ZS1nbmch/0dGYHJWLR2BJnglWi9kScY2d2LnJoRcnhDgDl97lnoIQL5eemfC1rS7VnOnOeZbV7gvfxZCPsc3kEz2J2y/laKhH5eewCi4g9Rp0IpqpEOp8g0rpRG6RSqmiKPbUvXg1WlySdua0g4FP4s28K+2Rnvui9/4zHv/ET+Mz13/feFiFdUb3nMiHqBOEUVtJm4QHGGO+8PSP8I02/IjPQIIxqUL01xuJX5Ldr2npc/bMzh1EiNttjEBfZCN16DPgvgEgpjfYDIezRbvuhB6uURcQhpn7pzEp9k0iJqBfHRNW7cFpNRV3sxzOFUUDriRg5WKW1iOi2Pb0PoRzuqn8+wjj6AphhZ1aWal8lYoZkbQLI1mM2WKX83uWjI6VstTPvwGHnbW5XWETsMbrtd6Ct9vlU7oHmBi1VT0T1dCKQ+sa+eUo1gDQ3rajVTV4FAavom0qlYj9tCDvzjd8/CAC4/j/u8d4WIV1R480gb4qIsYv06kYIKG/GRYAm5UYBL5HttwhcGAX0YBUktGmXj5kQTa/HQItEdTrzMgSrBO6JmCoshmwdCu1eF4B3YIP+Z2brnsj3UFURTEIA+RCAClZxL442ISSiVuzlHoXJrpSBBmqMr1KUq6lo4VJE1I5JVMej0pkRWYmojkvhk85sBD+MdtY/H3kmPjvty7TAQJhFxDBKRBYRyeZoC1YB3Mb4OlfBskiveqobtyMsIvYYoydidRMEuE8K9Z5OQLp0Zl0tE6QPmPW3qaTMRvP/EI33q+2tDnPvbflgD/Ljif9+qGM5uBYvfY8QhTqVSiViea7Gtl3qw1auFf1CqLKzLN3EWS+M5oGUCvrYmkr9YPRlrAPB/FWjADCqCtlSprkBDq1E1FterE14Q0/Wp7nXLR99ixP6PUvpeElbRCxEBmSqiOihbtMLXZqdeYDC67rRBaMXmRIB1EpEt3RmW4mobM0iYjqzLGaVTXWhw+ENMxWWo1pdOcI4ujtAaL05c6mUiG7bmtTzN/9iP9k+6J9BoCzQA25jcn0vVrcLKIv0q2LN+D3ZGBYRe0xtZ9YGa8D9Rii0ZcQV3VYdRIlovR6pViH0SWaQxvvV9lJb3exjCKEsWasKkQfH8VaaCVHoCxmDRHbm2R611c8DqLLLlgppF4lCBYLpf2+kTsfu5dsSnOU3vjdfDwfNrVyKxSJbiei7EEclIumCrqAGtB6Gngmy5bZS9lFVwRpZrUT0szPr6cyanVkU0SbOUqIOdxHV8xdVgcw1WCXTw2KAJp1Zxhs72nsiTut97MpUL7bmed0Tc8UjrMUV3Vavwl3cez029xiCdmaySWbszKqI6PA5rBed1LgxKtsFrCJNcNFWhkXEHqNOBN32C/grEdW2QoS1OO1HS2+pELZfRTo7c3NxDdF4X80lVdP9pVEiBii2qGO5d41FRBIf/VwdJbIzL0ItYyzQJFbs6eEuwezMQmjJrWmUo2V7EbUA574P+qLTUGtXkqJthf7+FNJ/jNe3l+q6RbYO9bhVnQaNKjuMnTlV6x5RjVFSZHXBz8fObFhkrWCVeD0Rm2KbUgzWSkTXYJW6z2P1AaheKxG1J6Kc7YkoPNRSWsFXiBwYjACUSsTYegBd0Zl7JkQ3SsTGzkzhF9kIO1hFFRFd1glmlIij3QCA1aonYqpw1a0Ii4g9xrAza0VE54a4cxLwUvVEDGVNWzY7s170DTHJXK36ZU08QnV8sF/ftSB25nKbB1lEJAmYaAsqqdKZ9YlkqCCUWgWYhVmgcUE9XR5wH5oAGj0kwWuT3fdBV69mzZjsvT1t0Qnwu2a4Yo/xvn0RdfVhiEUn0m/0Aj0Q3s6cKmRKKcAMJaJHOrNh/TUKk0U0lWUhoRURq+Kh6mUoHezMcjZYpbYzR+2JOGtnrgsdDi/tTGBMpUQcYRJdEau/jrmvEtHoT1z+jEUbshG5UmXnVTFduCsRZ3oirlRFROEXGrQdYRGxxzRKhabwB7gP2FKbiAG6JStNOrM5cXbfnv16pLMzl4+6siREcVQpEYE0KhX7KUMUW2o7M4uIJAH6GDSolYgJ7cyGws5Hld1sL1URsVFDhtuHemzVCm7xrYnNIlwIpbmu2BxoF/gURTf7GurbF1FvDUAlItkI287sO27YY2sqVbYKBpFWT0TXU2LG+lsV2zIUUYNVhFX0a5SI3Q/MtGgrJWJ1zxuxiCilpois3quRh+VyKmVd5BDZoE7nHmGc7HMIAJl0V1fqf5dnGdOZyaYRqiA/2AHAryfidK4SsSyQpwoh3YqwiNhjmh5MZe8JNc9wViKq9hx2T8REdrdQljv7b1ONH83FtXltvRrvq56Iw+XplwWE+bw0dmYGq5D46Iq9dHbm5utQE12jj26i8V1XvIdqmaEXfdXEJXavPV1BH8TOrK7HmTAt0gl6CNqH4atE1N/vVL18ydbBDlapixPOtt/m65R2ZtR25hzINTuzq8Ky0O3MWd1DcBDRzlwGkKgxXikRq56IDkW/GYs2ml6LyZSIQ/9Cx0xgTK7bmeN+DvV+dNnUz86sz3Myz7YDZPugglXkYBWArvJ1OLekBCDrxHFVRNwhSjszP4+bh0XEHjPX4uHabFqbOAPp05mzTHg30AZmU8ZSJTPpq+nDACpP9TqtGE33UygRF2FnphKRpKMJIGlUZdGLiNq4q6uyfYYvPdREFaVi31CpIU8EUlfqf2+GJHhtsvs+tCgsC+nfXkQVj2t14xKM8b5KRH3xLHbqOdl6yMBKRH1iatiZUykRIWp129CjJ+JUtgerZJHTmbPazlwFq3j0RJwaFu1ZO3OsFj6GrdoqdDilM+vFUZE3SkQR386M1mAVt001IWdZECcZ6T9SSuTqM1gV6H1UvrLQQlWA2s68s7Iz016/eVhE7DGFNtEF/Bvv6wpAIJ1SRU+JbibO/oo9RapVCF05GiL5ukln1uzMS9AvK0SxRRVD72U6M0mAbskZprIzWxPdanj3W1BpGVtTKc1zrZDpe1OnbzNPpNjTwx+UBR4It6inWmAkUZsH7omoL55RiUg2Qrf2A/49DG0loq+y0RlDiajszO6qwUJCK0xltXJvgCJqsEo20xOxenQIQpnpHYimiJhHtmnXr+2wKiJ69G0rColBfVyDpEpEPVglCxWsoofBsWhD1qGQaJLKhzsBaEVEF5WvnaRepTPvEExn7gqLiD2mLvpVN1SNdNxve7XyIVnj/WY/QvTUWBo7s5a2GabxPurt1fboFP2yrM9bkJ6IVCKShOiWHFVEjF2g1yfOQrMz+yyCtPWbjX2D3ywSmQtfXgtFdcENyYNVdJUn4F4kk9aiXog+i67Ynzn2RCQx0Rc/9Efn3oF6T8RMaP2/YxcR23si+tmZ1YvVKBFzD4t0532QjQpIFfukmoo6FBGN4l2lRGyOK15xVOp25oGplnJSIhrF0axWIq5gnCBYpTmRsqIKn/ANVsmzdApfsqXQi36qJ2IZrCKd6hkzSeq1nVkFq3jt7raCRcQeo9vCAP/VWVv5EEIt57Qf2uS5LoxKdzWi/XqksjOrG95Ma/7v1XhfKwikSpAFmtdzNFDFFr/XV0pJOzNJSiHTn1t1T7xA7Sr0v9XDOqK3q6j3wSy2hQjP0pWI0W3aWqHDSFN2vGPVF52ARt0Yol2E676sVv13/dOZWUQkm2eeS8bXzlxvL1EAhFR9wERW90Qs7cxu2zNDSJpglRxFtPteQwWkeiGqdOaie4/rZVIi5rYSsQpGcdmFSdEEq+hKxBVM0vZE9ExnbpSITZ9+KhHJekwLiQFMOzNQ9Yd16omIZntAU0SsglX4edw8LCL2GFs56Dtg26u9zSQzTYN6XS2j/7wrs0rENAOI1CaFTYHWoyei3mMxgLLRlcZWXU1yPSeFpSqp/JrBKiQFuqpMKRHXEi2m1ErzAEU/Q+WtbS9WXyl9H7KsOSbAz37cprBMZtPWlOaA+6KK/hkEgGGq8AdtX3aOykLHYV8lolFE5A09WR9p3ZvWquxA97pZvQDv1zqnK6LuiagpEcXUvSVRUSAT1d+KJlglF4lsv6p4qIqJLnZmY3tZ9dAUEWONh4ZV3E6QdQx/qC2cRk/EcfwWD1oRUXgGq6hr8SBr7jGoRCTrIbVzS2hFxKFjQX3Gzlz1RFylnbkzLCL2mHmrs85WCEv5kCcqTBm9A0UzyfS9qNXfp1Iiau9XbZEMYNPOs3ThD0Dzeq4O8yD7oE8qqUQkKZhqN8JNoEWaYJXcnjgHUi+HUgF2RWqFTGMfPF5eveCaTIlYNOO7dljOY7yezgw0SsQkfW+r13dHNcaH7YlIJSJZH3VuibroV/7cOUHWWqDRx6GoE0ypFcdy/2AVvRik25ldFT0u6HZmpRyUyobsUESUskWJmDeBMdNIBbd2JaKfndlInc6rIiImUeco0iq4qCKia0FdXe+yTGvBwpoNWYepbFciDh1bO8y1M8NPZbsdYRGxx9irqcHSma0eTKl6IpZKldmfd9/e+t/Hoi1BNESwiq6WStN0v3xcCWRn1u1697KISBKgn6ujROfWXAtfEDuzCKYC7EpbuIu+b07b1KzfqZq5q+MSQkCIxgbvH3RmpjOn6XtbPueulXIS753OzJ6IpAPNmFE+egerWPe6WaBxqDO1nTlMwc8ILhGZ2TswVjpzoSvsqmBEVI9OxTaYij00SsQBptFEDlIvTCglopgCkE5j/NQOVhlowSoRJymGlRSNnRlwmys1YhT/tgNke2AU/arkcwAYObZ2KO3Ms8EqKyqdmZ/HTcMiYo+ZF6wSatKSwhYmpZw/yfRcdVakWoXQlUUhrOJ6oaMJf0hgZy7C2pn1vz84nka1FxECmD326gJ97N6BRfv47rMburotlfpGPZfe8xaAl6LECDVJtPilf2aA5vrpWiSbaotpALSWFfFTp9VLuaOyMx8a+yoRtSIib+jJBtj3pr73uvPs0UDcpvtSD1bJm2AV51soLSCjVCIq26974nPnXZCAUEpEYSoRpXTriZjN6YmYRe31qCksh02hY+j42pq270xTIo6jCh0MuzgaJSLgdn6pOUieZU3LFN7Dk3XQk8pF3vQHHWLils5cWOdWpW7cgcMAWETsAouIPcZWDjY2LrftzdinEqwi6deaTFOVAB69Hm07c6IBxGy8H9DObAS19MHO3Pz9tJDeRUlCumLYfpUCLHKghd1eQhWTvOzMmroxxAKN3z5YhcwACsss8y8wuCKtop/qi+jbhiO3lIixF4r03d81UnZm33Tm5u9jn1dk6zEThOJ5bzpvwRyIew8l9GCVLICdeUaJqIqIMmI682ywiuqJ6JrOnFs9FnWFZbyeiHImnRkoCx0uC90zgTG1EtGtcOKKlGYIhV5EdPkc6otp6rSiEICsRyFRhwwJLWRoJMZOn0EjST0b1OfrKu3MnWERscfYq6m+Nq6pdWOlHmMqEfV9zy0loutN0Gywitu++aJPdNXkOYSdWVcVrU0SWN2sYBVfu51dhKSlmcSmTlJPameu9sFSy3jZfrUxPkS/WRf069YiglVSpU7rvXzVvgAB2otYPRFjfw7113HnSNmZw6UzpwiKIVsLvVUA4B+sMp1TlATiKhHropqhRJw63+tKXYko8iZYBe5hLV0xFHZWT0TpEqxSSORWj0V1XAMRsSdiAWTKVm0oEd2Uo4XeBy4bNEpEEd/O3NYTUf2uK0bIWaIFPbK10Av0Isu1/rCOSkQpkQs1tub1+boCBqt0hUXEHqNuoETo1VmlpKhtYTH7ZTX7LjLLZuJ43i+dnVmb6PpMoPQiQxPUEl/VoV7OlUF5Y+fbKN+eJN/DIiKJjBrzBtkS2JmtBFGfGyB1aukpxr7b7IrdhkONhSGCVfSFp/h25vJxpojofD02tzdMVRzVrpd1OrOnelA/BirNyUbYykFRFxHdtietMUi/z4x6D6XSmfWeiMLdeiyK+cEqsSylpe03nBKxLZ0Zmp051vtlKBHzUb0vrsrBaQEzxXqQJlhlaifZTg8bv+uKmtMMMMXgrhsBxE89J1uLopAYCE056BkyZHyms1xTIpafbSoRNw+LiD1mNk05TLNpu6dTTPuUfj+QCwHt3i5cOnMyO3OL/dhjAqWnM6fsiaj2Y3WoeiL67YOtprx3rXsfHUJ80FXZqezM+vkNBEpn1o5LaHajuEVEVPtQPdYLKmH6w6YKVplnuXQdk/XrBaDZmRMVs4FwSkR9oYjpzGQjZoNVysdQBfosa+414warKIllZqhv3JWIup25KSJmKKIVcYy0X7snooud2bb9AlZgTKzjgnlcWt82p0LHjJ25LJysRA5WkUVZuFaIYmz8rivqtXjQFy7B0X/6cPyo+Fr1c7/9JP3FWHjI/M+tmUT3qidio0T03+ftAouIPUbNuRr7cfm9941Vwp6I+oBhT3R9rSvNczjvnjN6c3ohRN0vy0uJ2NK3LYWqo7Ezh++JCAAHqUQkkdEVe8MA/UtdmGn+H6BJuZ6cCCDIONR5HyyFZQglohFalfsXW12YsR97LurNbi+N2lz/vC1CiZgibZpsLWaUg6HOLW2Vulmkcd7NzgipBatkmp3Z9ZTQd16Iuug2iJnOLLUAEmVrUinNDlUpI1hF9USsVIB5xHRm06adNYUO4WG5NAon6ZSIerAKJmvG77qiFs123fUNAMD9s5vLbbGKSOYwnbH263bm7tsr7IK/KiJK2pm7wiJijynmKB98ewc2k9b4E0z9oqXuP3xtYbM9EeMPIPou6FZCL2uipipKqURUN/grwzA9u1hEJKnRVd5DpUSM3YvO7lEbwvY7r29fkmCV8ns1efdSImrvV4pevkCLuslzP+yCbwpnAGDeTyyiJ2Ls84psPezWPb7hSfYYBIRRRHemLqplQF4W6F3TfvXtSZEZRcRMFNEUlmbRT9mZq+KfU7AKWpSIqtdjPCXiTMCLXujwVSIKPVglfk/EwTw7s2NxFADyoizYrGBs/JwQmxm1cV2gdwuZminQD5rkc/V7sjlYROwxdp8YXxvXjH0qRTqzdi2bOS7PG8Z538dA3/dcSyUdBwoTGCZMZ1bHpoJVfCe5tpqSwSokNuozrCsR15LZmcOECQDrqBtT2JnVceX+x1Wr8rUFmthKxHn241AJskPP7bmiP9+OYOnMLCKSzTMTMuU5btnjIBBGEd19R6qiX5abSkTnZo9amABg9ESMmc5cKxEtOzNk+HTmeEVEmKnTeZOm7JTOLCXytj5wjspGV6SlRBSTNS/3l1ogyqpiJBNxyUbMtgoox8KRY2uHorDSmbW2DgCViF1gEbHHhG/kriwjMLYXszClX2js3ozu1hXrORLMWQybdtYkbfokyxl927I0hQ6gucFfHZY3eL6WatveRiUiiY1eEEpnZzbVMmpc9lGUzBQmA1iku2IXx/IAykE9WGWQ4JgAzaZt9bB0V9CXj/b1PXrAj6YC2zFcQDoz7cxkA+apl/3tzM3Pcs9FeBeEbEtn9rCz6kEtgJbOnKp3YGY+Otx8G8rG2h7dHFes67JRbJtJkO2+vVJ9pSycphIx5hBvWEkBYLrmNZcsrCKiUiKycEPmUQah6Hbmpieiy3hc6NvTesOqn8W+N9zKsIjYY+Scol+oPjEplIj6yS2sG8ZgwSpJ7MxaEVGESWc2+7alsfABzeurioi+yhK7EHqQwSokMno/umR2ZqsnnhqXpXRPOtQLQkCivrfWBD5IawetgJcFGFtd0HveAuHacKjtNAtPcT+Hat6fC1G3rPDvidj8PdOZyUbYysEmqd5te3ZCPNAooqcJ7Mx6T8ShmDofV53OXPu+G8VerPveQkqIuieipUR0sjPPKhvT2JnnB6u4pTPb9uimJ2JsZ4Cdzuzj/qqViEVVRBSVhZTDPJnDbH9QP2u/ub1MUyJKCBTJwlW3Iiwi9pi5dmbPGytbpRK16b624iysG8ZQwSopViEMO7MWhOKj8jT7tikrccpglaonoucE0y7W3OupeiGkK7qyTZ1bsQMg9H3QHwEfG585xtcFt4jHFrrYpt8Q5kIESbF2Yeb98rZcmsXWEAtPLky1gsvqIJASUfu8UYlINqLpiRimtYNtj9a3GfMWSuj241yzHjseVx1cMlNsmybqHWgqB13szNNCs/2q7alejyiiOaXMYJXF9m2L6gzQi5kAIAuMsqLex67UC3pT9kQkm2NGlaupfF0+NmW4k25nzuvfDSIuPPQBFhF7zDy7k/tkrHxUN2q+PZ189sG4ufNcdbb3P8W1TL/PMZSIPnZmTTmqVCprCSZk6uWti4ienxcGq5DUqDYDg7xpFRBbiajGKbvgB7gvhNTqNjsZOYGdOVSxTX8tjNCqyAO9XfTzbQdiB+ukSNIGzOTrUEpEBquQLth25ixwgT7ENt12RCuOGenMbvsgpFaU0h4HiBesMqPYAxq7lIMS0bQzmzbtstejz95unrLop1bAGvu5q3JwavRt09RXwq0PnCtTvYBTsUNM6t+5bA/Q7cxVIi6LiGQOhUQT7qPZmV37gxZSCwvS7MxAuaDCgvbmYRGxx0hrkuHdJ6ae3JXf1+nMUVUqszaTPgSrFDMTXf8Jod7XZ1gXJRP0RKyDVRo7s6vdEmizM7OISOKiF3BGg7R2ZluxB7hbg2wVYIok43m9d33bcJTbbIqIUsZVI9rXLt8C7YydOUWRw9oPpUQ87KlE1I8hdo9HsvWYDVYpv/dtFaDfZ6Y4v4RuZzZ67DkWEYv2YJU8ooVPSgkh1BtWpTNDPXa/cK0XrJKJeEpEKaEFoZh2ZifFnq5sFI0ScSW2ElFaSkQAIzGtf9eVumXK1LYzc5wn7Zhq4+ZccE2ql1Ii089VrYg4gHu7iO0Ii4g9xp6MZZ43VsUSTFpaG16rRcwAk8y272OgD4SZCPPattqZU/RErPZjtVKpSOl3XLZt9F72RCSRacZCaK0CEtljraAO/XddMGy/dZ+9+H3A5garOL6+dhiX7+vkiu0M8LWKz7QXSdWbU1NthVIijrXPm2/7C9J/ZlS+C7Az14vVMYNVWpr/+ygRAasnomjszLFuDWdSjIFGQeikRLQUe9pj3J6IVtHPs4hYFFqgia6+qiycPgvxnfZDSgyEpUTMJtXvum+vfD9kk85cFRGpRCTzMIJQsoGxoOKUzqyPQVo6M1CNGfwsbhoWEXuMHYTiq+iY26sq4glnN9DWv/Y9Lvs5YqIGQlH1egwaJiCaHosprGFqP5QSsdwPnyIilYgkLa3J59EDLUzbb6ZdzZ1sRtZCBuDfKsIFu22G73XG7jerim327xbNvB6Gztct63UaJlIi6ouLqwHSmYtCGtfgWEoisnWxz4VF2JlD3JM57Ej5aKQzu1l0pZQQ9Q20qdgbiCJaINNMsU17FC49EY3tmT0WBxHTmY0glADpzDPbsxNkox0XmuCaihVPO7PqgwgAq3VPRI+dJL1GSrRa+8sCffftGQV6kTXjBtT4yg/jZmERscfMJNZ52n7txvAp05nzthXiAI33gTR25tBN94HmoqwrEVMUEdXrqVQqgKk06cpMsAqLiCQyuoVT2ZljtwqYWdTRxkQXlYKREJ+Z24xZyGl6Ipbfh+rlC5TXihABNC7MS9N2neTOay+SLOAnE3XfWx8lov16xD4esvWYp152XniwtgckahdQaGECKp3Z0cJnhAlYwSoAIB0KeC4UrUU/956I0g4g0R6ziEpEab++nn3bZoqjmvVc/T4GZf84831Z9SgiFlYRURUkWbgh85ja57jeb9S3VUCWl+odTenNYJXNwyJij2nsTqgewxTbZtOZ408wRcsKcah05jQ9EctH2+rmVUTU3q8QQS3O+1E9pZpgAn4WNVvxRSUiiY06LweZHqwSuXgzZzzWf9cFfRi3FzNiisHsxa/QwSqGYjNqb6nysQ5C8Qwms6/HwwTW8/L5moJLCCWifT/BYBWyEc25VT4241ZAO3OCImLW0hMxExJy2r2Fi6Fss23EgNM2XSiLbbYiUqUzdz/XjWCVmdTpRHbmAD0Ri0JiUPdtG8BWIsYa5tuCVerCn8NxTeYoEVm4IfMwCtmatd/VzmwqGwfG48BxkWa7wiJij6kb2AYKVrELeGryPI0ZrGJNnPSvQ6Uzp5izqONSE9y66OdjZ9ZW04eDNIUOwJxkDmtbtYedeVL+reqxeNCziT8hXdFVZaoYFNt2aacYC11h59HwHJgt4MU8tpnrlm8RUQ9WEc11C4h87bLer1DOgNn3KradGfXzh9iHWSUii4hkfewQwczzntDuJw743z877kn5oCnRAEAU4zn/f50tSdlSvGu2WTioAF1oLWb62Jlt26+2vTyiqqjsszZrP3cNwplRS2lJ2ur3MWgLVlkR7pbqqZQYGUpEpjOT9SnshYJa5Tt2K9CvE8aUi3gLD32ARcQeo27uQ91YzVO+xE3uLB/Nnojm/nXephXWEqthsU792gYq+AJW+EOWrieiej1zQ7Xlb2c+Ykd5k8ZgFRIbvSBUL6bELt6sp8p2OL1MO3P5mMLC11gTy++9FfTa9oQQRp+z2CmXaj8A/4WiGWVjIrW5Xsysr1s+i1/W/qdQz5OthT0W+t4/2UVJIM39bl1U0yx85Q66FduyOcU2AEAR5z7KKGZWzy/rYJWw6cx5xJ6Ixn4EUiIax6XUlaqAF2lcNEIoKlY9lIjTQtaJzABqVSLtzGQehWyK52awitsigRmsYqqXaWfuBouIPUZaygffPjG2ksLXjuWC3VcKCGdnVn0D09iZzeMKEqyihz/U6cwJglW0YxsGCHhZs4qItDOT2OjjkCq4xVaA2Ys6gN8Yr9/Eh7ISu6D3cgW0Y/JUIqrt6YXEmBOXptChXtvMax9sVX4zxqf7HIYIW7P75VKJSDYidDsYO0kd8LdIO1GPGVndExEAxNRNiTiv2AbEtjOrF7gcs0QdbOBiZ8ZscdSwM8cZP+RMOrPq2+aWpm3YiA07c1wloqH0rFBFwK5Ds5SyJVilVCKybkPmMbMA4lugb+2j2pxfKWoAWxUWEXvM1FqdVZNd3z4xts0srhLRVHOUX4fpLaWKiCmEDzONwQOmM5vBKilUluVjJgRGAWzVa1U/xSN3lBcSBquQ2KgxT++JKGWaopReRPQZ4/U/CRnw1BVbBeRbmLLHVqCxNMe8dqlxsFHyw2sf7AW1+nocPeCnpYgY4LqlYLAK2Qj7vrBeJPAeM5qfpemJqBJEGyUaACclYlGUVj1AK9rp24xkZzaLmWo/qmKiwz60bk80RcR4SsT5CbJO6czG9lqCVaIFxsz2RBw5Bquo90IvIo7YE5FsgDR6IuZAvgKgClZxTKqvP9P1gkpZ9KcSsRssIvaYmdXZYIqO8vsUVjd1T5i3rBD7JvEpZWUaO3P5qI4lSLCKNrkLoQB0RVfEhrQz76ESkSSirXACRLbHqnmTPhZ6jPFG70BbER3xuNR+1NbEQOnMbcXWNMXR8ns1FvoWOlQ68zCBM0B/vkyEKTrb9uUU6nmytajDmCz1ckg7c4g+1V0RVcFIZhkgBIpqyianDnZmKSHsnojaNiFj2ZkxY2euH117Ioo5SsSI/c3M1GmtiCjcwh+KQpoWTi34QT1fDKb6flSoYJWuc6VpSxGxtjNT/UXmUEho57huZ3ZTIk4LtIwZTXARi4ibh0XEHqP3otMfnSctc3sipmu6r++Ha/FP1bNS2pn18BFAK9B67Iu+zZTpzPokczgo98NOWO6C3RPxIHsiksi0nVv6z6Psg1VEAppJtKvFAzDVNyn6PdqLX6HSmfOWYmvMsd5uWZF5jsmz6vVqgSZ6sMpsQb2Q7tfj2WAVmWRhj2wd7FYBwezMLa0iot4fSqUcrHoHVko74VDw022pQlMgFmrbDoVJF8xim6mIFIHTmbOo6cyWErEqSgwxcQ46M46req0GkZWIU6kVaStWMKl/14VaiViFqZTbqoJVWLghc5jarRj0Ar3jvW6mF+gBI7iIBe3NwyJij9GLN0CIdObyUdSFrhQTzBYLX62+8dvmMICKwpXmuMrvfVWj5d+i2mbTEzGFElGfPKtCrU8xU9nb6mAVpjOTyLQl0gKRWzu09UQMUEQ0FXvxFx9sxZ5v24z1+uimsDPbC0XOvXytQkejRIw7xrcFq5T74XpcRbW95mex+zySrcX8MKYw2wOQpPdtXVSri4hKsdf9wGSb7dfYZqyeiLMBL6IuIna/lzOOywqMiWlNlHqxTWRNgqyzWkq2FiWVDTPecWHGzrziaGdWYTBtdmYWbsg8CsPOPLBaBXT/3JT26HlhTFPn68Z2hEXEHmMX/bzTme2glhQ9EYuWm7vqa1+b9qBWIrrvnyuL6Imo3wiPkhYRy8c8E0H2ww5WGU8lm++TqCj1dZk4rhVOIhbbbMWe/rVTD6baRjxr4UuSYpxZxTbnXr7rFFsT2pnVe+Wezjznehw7nVkrZuuFWtfPjFok2jHMtZ9xfCfzmVEvey+Yt9mZ/YKQXLADSFTBTzr0DjSVbS1FxEjBKkUhkQs1OTF7GLoUR2cUe0BdEMgipzMbr68e/uBoZ14vWCWmndkOVnEt/KnQLBYRSReKmWAVFVo0cZqvly0VzLG1aRdAJWIXWETsMfbkyfvGyk6DrG3E8W6s6pvFkOnMdbBKyp6I7XZmn8HMsFzm8Qu+s/vR9J30sjNPVBGxSRZkX0QSE1WnyYWtRIzf2qG9iOiyOls+5oG254qt2Kv3wXN8N5NWVYhWfPv5TMsKz0AwdQ+comcbYN5n6AV111NBvR47RnoRkTf1ZD4zBXrfc6ttbE3gVGmUiGYRURRuduaZFGNoduZI165CLxSq41HBKk52ZrQkrerpzJHmJvZ+aH3bnHoU28rRREpEYz8qhkLtQ8dtqbE9a4qIQ0wq27nffpL+Ukho/UFzU+XrFCKoq3wHxmMu2BOxCywi9pi62XTgG6u64X2uT57jrYoB4SbOgGZnzuPbs+19sINVfFQlxuRuGezMQkuJnvj3RNy5MqhfLyY0k5joCypChEml9dkHhWqH4GqfsreXojA135roV0TMtbudOhk5oXI09wxCmS1Kxk+cBsyib4iCet03a0AlItkctutG3Zo6J7q39kSsthm1iGgliCrlnms6s63Yg1+fRRcMFaW6j/ewM5cFAVvZ2PQPjDXGmynRZt82l10o7MCYOixGQkRUSxnJuBUrjonKamzfIczP2ghjFm7IXKa2Kle3Mzu17mkCiuw+qkxn7gaLiD3GLripiVOoBvUh+h91RVr7AIRTIiqVXBo7c/kYSjWq/21pI05jdQN0C7rWE9HjRVaqlJVBhp2VWoXhKiQmdp+9JK0dLNsv4JnObC0SAU2hK43tN0xrB9v2q3+dIljFHuOd7cy2M6Ae4yP3RNTtzNpr7CpsUvs/yEXtDkhx3SJbh3ntYHxbIOhtc1Kol4Uak5WduVLLuNiZS7utWsnQi4jVNiPZmY0AF1U8VIpEByXieFrMKiw1O3Os90saFsmmh+EAhdPn0FAAZgPjPcsjKvemhXZcFa4WZNXuZYcYGz9fwZjhWWQucqZAX6l8hXtPxHljRk47cydYROwx9iTTd+JU292siTMQz8bXOtENNMlUSo6UduZa5RmgKGEmyPavJ+Iw14uIVCKSeMz0o0ti+52d6NZqc4cxTFpFLsC/0OWC3bIilBLRKLZ6qgBdsPv5+hY6lkWJqC/sGf1BPdOZS3t0uusW2To0Kt/q0XPMaFw8syrvqEpESy1TqwYdeyKuH6wS5x7KVCKaKiAXJeJ4WswqLA07cyybtlXo0PfByRlgKUezpn1PTJv2tJhVIo7gFqyi5omr2WwRMWZxnmwtColWJeIIE6f5uhlaZNqZqUTsBouIPSa0ndluvG9MGGL3RAxpZ67GkjrdMsHFrFaVBFpJL/8W9baUSiVFbyl9slv3RPSwM6u/LYuI5cDPIiKJiV7oANLafttU2W43VuWjGSYQX4loF0eDKREDKTZdadqBhFGvztijE1jqAW18z8xCrevCYt2jOMvqazKLiGQ9ZtTLngvmbf1mU5xfmTTVMnVPRCc78/rBKtHszEZPxKooqoqI6H6eT6YtqiKhCnjTiMEqVlhDdWyZo7KpsNVSVhExpp3ZViIOKzty132YZ2deEWss3JC5TPU0ZSup3OXWoCxKWmOhpkRkQXvzsIjYY+wboXri5Gn7VSuyphIx0oW6tQ9YmBvGJn3PZw/dmFoT3dxDUWRvU7cRJ+n3qAUA1D0RPYqZ41qJKOoET9qZSUzscSiFsq0u+rUWx7pvb70wgZjF0XkFAdd9UK9F3nJcKd4vu/DsH3QGY3sp7cz6o+t1VI3veSaCXC9I/7H7dYcLVml+VtuZo95DWRNdD9Xg1C5KVUj1tYO60QVDiaiKhx5KxDXdztxWEIjYE7G2iwvdzjx1szPrCkCriBhTLWUUcCpc7cyqLcVqi52ZFlIyDyklsrb+oJCOPRHl3DAm1/N1u8IiYo+ZCULxVJXY/QhTBAq03dxl9aqz4zZneiImKLSpew9bpeJxA6RP7tT2xgkqpLpiKoSyRP3tSLMzM1iFxKQp+pff+ybtutA6FnrsR1sASXNc8cYNW2E38OzL2GZnHgRYpOnKvARZ1zHePq66J2LkG+B5/eh8FyuHuV5EpBKRzKcOQplZMHfcXpudWajfxVciqiKbKvi5pDPPD1aJW0Q0nqcu+rn3RJxMZYuduVIBChltPJR2YaI6JtfkYSNNW+TGe5ZjGu3aVUg0AS8q0EIqO3O3bamxvbWIyCGezMEsqA+0IuLUyXUjpR5aZKUzO6obtyssIvYUKWXTw7C6EWommG7bbPoRzqoAo6cztySS+gbGqH59KRbEbFVJ2GCVNEUOhW6DD7Gar1Qpo0GGHeyJSBIwG1qlxsGIxbYWVXbmMdFtUyLmntcMF+z9yDyViG22b98WGD770fQwDKOgz+vtVT0RI6v2bIVlXcBx3A+jJyLtzGQTBA9W0dwTihSqbFEp24Rl04VDsa2QEplQq9UtduZIRcTCsDNbwSro/tpOiqI5rsxUbA4wjVb0NezMWtHP1R5pqqUG1bFVY71jWIsLhZ6MO9wJQFMiuvZEtIqIq1ijhZTMZebc0pXGDueB0WOxXnjQgpD4Wdw0LCL2FP28CtYnxposAFpxKpploHxsm+iGSmdOcTGrVUAB+1sZwSp5mgkmYAby+E6cAbsnYlVEHLOISOJRjxnVpGWQwOrW1rfLpzi23tgaU4nYHFf5vXexrXXhKYGd2Xp9fQvPdvhDo0SMW3Cb18/XOVhl2pxbamEvtrqSbC1C9/9uW1BJsRBb23uFSmd2L/gZyrYWO7OMFKxiKhErV1PuYWeetCkRmyJDvJ6I7UrE3DWduWizXGqp01GDVVTj+LKIOFTBKl3Tmat9XoHdE3FMCymZS6F/BrOB0fM0nJ25GjMEg1W6wCJiT9EnXNmMEtHTztwyGYs1cbEt1UCjjPROZ64mLGnszOZEN2QRUS/exZ5gAqZixldVBOg9EZtglXvZE5FEpClMld/HVmQDGxT9HBPrAFN9E6I3a1fqgkBmXreceyJa1nPAvzDpQm1nttTmoVKnU6nNp4GvXeoaNcg1JaJHEBfpPzOhRZ79v9ddrI4ZrALTzgyPYBUznbkpItbbdLBIu6B6IhbImiJitQ+Zi525KOb2N8ui9kTUbL+aEtE1WMUoIs6opWLambXi83BH+fxyXO9jF5SLaAVMZyabpyz6zfYHdS3QF20LKtXjMOK51QdYROwpRhHRnrR4Kjq0+6roE5fGUt38LNRkLKmdecaOU+1bCDuzloqc1s4c5vNS90QcCNqZSRLsxN8kPRHXC5lyUiLOLtAkSZ22rjO+CdG2Ug7w70fowowF3nNMnrEzJwohCa2i1xe/6p6IVAaQdbD7w6r7J287s95vNsACaFfqHoFKiegRrGL27NPtzOWEPFYRUVmxC236WSsR0f24jHRmYRZbBxHTmY0U4zKqHoB7+MO0kBgIrQ8c0KgbRRGth2AhtX50I8vO7KpEbOmJSPUXmUdpP9ZVvtW5JQqnLIRColE2zqiXGazSBe8i4qtf/WoIIfC85z2v/tmhQ4dw4YUX4phjjsHu3bvxlKc8BbfeeqvxdzfccAPOO+887Ny5E8cddxxe9KIXYTKhmigU+gXGLkw5N6hvmWSqHnfxLAPqeUPamcvHtH0Dy8dmIuZvj9SVKurYUqRc6nbmEKmo6hiGeYadQwarkPjYhZMkRanQduYW2299vkY8rnqMt1/bgMEqIXrOdmVmocizKNE4A8rvU12/7IK6b7/Jxs7ctOGgEpGsh60cFN5KxNkxI4V6eUaJ6JFiPC20noN6T0S1bQcVoAtyWs7zpLGo465EXJuup0SU0VpxGBZJvW+bcExntouSAPQE2VhqqWmh9aOr7MwDuCkRlcp8BWvGz1ewlkS8QbYGRkJ4NgjQE7FNidj0RGRBe/N4FRE/85nP4K1vfSse8pCHGD9//vOfj7/7u7/DX//1X+NjH/sYbrrpJjz5yU+ufz+dTnHeeedhbW0NV199Nd75znfiHe94B17ykpf47A7RWIyduXxs7YkYsckvMG/i7LjNJbAzN5aw8ntfdSVg3liHKEq6MtUmmSE+L2uGnZlKRBIfPfwBWJ505mZBpfv22oqSKVKM5wWQhOxvltJ+XgeQeCos9cAqfXuxW1bMs1W7XkfVezLIM4wS9XkkW4t5rQJcT+82O3OIBdCu1ErEEHbmNnus9nUWqSeieq8KaPugiqPofp5P9CKiMIuIcXsiwixM6MEqDsNXsY6dOYsZrCJbeiLW6cxuSsSRbWcWVCKS+RgqXyO0yC04yVQ2tqQzs6K9aZyLiHfffTee8Yxn4E//9E9x1FFH1T+/88478ba3vQ2vf/3r8ZjHPAZnnHEGLrvsMlx99dX41Kc+BQC4/PLL8ZWvfAXvete78LCHPQznnnsuXv7yl+PSSy/F2travKckHVisnTndZKxthbhWWHoel5qwpBg/7H6T6j0L0xMRSXsiNv2K/K1uUkqjJ+JooJSwnGSSeNhW4mVJZ/ZR2NWLRGJ2eymLo8ECwVoDYyIel2WR9L122kFnwzzutbjZD6XKNffH/biqnoiZqAOL1hIo6MnWQX3U7IK6s525bYEmwVgoaiWiqUQTjunMM4o96ErEWMEqbUrEqjjmcFzjaYtiz7PI4MKsElELVnGxM69T6BhED4wx05mVErHrYU3sImI2BMCeiGR9ptMCQ93ar/dEdLrX1Sz6benMLGhvGuci4oUXXojzzjsPZ511lvHzz33ucxiPx8bPH/jAB+K+970vrrnmGgDANddcg9NOOw179+6t/88555yDAwcO4Mtf/rLrLhGNdjuzp/JBmpMFQEs0jjR5nhYtN3eBlCpKiZiyb2CjvgloZ9Z6IsZOZ5ZSGsUJ7+TOotneKM8aOz0nmSQitmovhdVtWs+bmsFQzctcxo32FOP46bh2SEKoYJW2wJgUdmYR6DNj97BMNRbaC3u+C2CqXUWeCQzVIpGrzYBsC2y1sXc6c9sCTYK+0rllZ67TmWX31k+GhU+zM6vJc7RglapQKDUloiqSuigRx61KRC2dOdJ4KI2inxms4tpeZKboq6fSxupDr+9HFawyVMEqHa9d6r0YyUostHoEgLKIyMINmYfUFxe05POBq8q3rVVArisRffZ2ezFw+aO/+qu/wuc//3l85jOfmfndLbfcgtFohCOPPNL4+d69e3HLLbfU/0cvIKrfq9+1cfjwYRw+fLj+/sCBAy67vm1otTPXSkS3bUrZNsmMW5xqU8v49mBSf6cKbUnszIV9E1z93GNfdHXjQCsGSCkNNeki0d+TTC8iOk8wmyvGcCCSBD8QUlgqsNjjINAehOJTHGsKo83P6jExoe03WLBKwusWsE6vR8d90BXeQDq1ua30VNcaX2fAMM8wrHv5sohI5tPYj8tHX/WyrWzUtxlVMaXOcVU8rJOUu58P00Iiq3sitqQzx7IzV+nMEtril0dPxEkxP2l1IApMI40dhfH66sEqi0lnjqew1FKnq2AV13RmdW2qlYirRwAH/6MsIvIWnsyj0MamLK8XQVxt/dNiHZWvYw/T7UpnJeKNN96I3/qt38Jf/uVfYnV1dRH71MqrXvUqHHHEEfW/E088Mdpzb0XMImL5GKoHU2vPrFirYpaaA2iOS3pOWkZ1T0SfPXTDbk6vblil9FeOZqIptgGxrYnN11kWoIg4af5umGdJepsRMrXO1xDK4a6s1xPRZT/slgrlthP0DrTU5sGCVVoKAkmCVQIFoUyt4ugggVIKaAtWKX/uWhzV+40OEyVOk62FlOY57quGDT22upLVljvbzty94GfamXUlYmw7c/k8haaGzCoVUDAlolYknRZxjsvosyYyQ4nouqhX24it4qhroIQLhiJS2ZkrJWHXuUk917KViGKNdmYyFznVemhqdmbXgKFCyqYw3qJeZn/OzdO5iPi5z30Ot912G370R38Ug8EAg8EAH/vYx/DGN74Rg8EAe/fuxdraGu644w7j72699Vbs27cPALBv376ZtGb1vfo/Ni9+8Ytx55131v9uvPHGrru+rZhqKgXbFuY+aSkfTUVH7HRmNcFofpZ5rhDXdmZPBYUPdiPvgXaT597DsnzMs8bODKSxJqr98O0rpEJVhFDpnXHt9IRIKWcKOCmK2W32Yx9VthovRMsiUVwl4hzbr28bjrbU6YjDxkw6syo8+9qZrc/geCqdF9RcsD+HvlZxZV3Wx3cqEcl62KpcX9XgemFMcYuIdjqzKra5pTNndrENaAqTkYptSl0phW5ndg93mUwlBqJdiQg0ysdFM9Nz0jPttdXO7NkLzoVC7x9X2ZkHKlilq53Z7olIOzPZBFKf32n9Rl0L9LKtP6xnYXK70rmI+NjHPhZf+tKXcN1119X/Hv7wh+MZz3hG/fVwOMSVV15Z/81Xv/pV3HDDDdi/fz8AYP/+/fjSl76E2267rf4/V1xxBfbs2YNTTz219XlXVlawZ88e4x+Zj7SKUoD/jVWz2tv8bBnSmXPPCeFy2ZnL7/WFYt/VdN3O7LM9n30AymMLZWce5hmE0GzaVKqQSOgf3drCmUAFZttjAb8CTmOPbn7mqwJ0wV5Q8VVD1nbmVgV9vOJU3cOyLiKqn4dRWA61MT7mfGxqvb65pyp3ol2PlTuA4ztZD3vMCBeskk69XCaSquKolc7sUBgzgz+0G0yhQk0i9URUwSqanTnLlcJOdn7PJhNtv0W6IuJMgqxW6HCZm0yNvm2zlsuY7q+ZYJXKzuyqRBy29ESk+ovMRe/XaigR3ezMhmq4Pre0ICR+FjdN556I97nPffAjP/Ijxs927dqFY445pv75BRdcgBe84AU4+uijsWfPHvzmb/4m9u/fj0c+8pEAgLPPPhunnnoqfvmXfxmvec1rcMstt+Ciiy7ChRdeiJWVlQCHRdr7ZVW/C2CPbbYZuYjYUhxVhTff/jfDBP2/mn1ot0fqv+uKPsnUVTgxJ2TzeiK6FgRUEVFNLmMXsQkxPtMJlYj1gkqLEtFlN9p6B6Y4v2wrYb0PruNgSy9fX1W+C7bl0rfYNlM40aq/42mBXJtIL5JZRWT5c+8exVmGQV6O92tUIpJ1mBus4nlPaNzrRg6n03viCUth52L7LQqJTKgD05WI1TTQoR+hE9V7UhhKxKqQKYqqeCZa/7SNYqoXGMx0ZgDANE5x1CxMNMEqPj0RBzM9EVPZmdUkybQzuyoR24qI91D9ReYgjXNcCy0S0k3lqxfG61YRjRKR7bE2j1Owykb84R/+IbIsw1Oe8hQcPnwY55xzDt7ylrfUv8/zHO9///vx7Gc/G/v378euXbvwzGc+E5dccskidmdbok4sPT9DeCoRbQsfgOjBFq0WvlDpzNUNSIprmX1cuhLRW4GTmT0RYzbet1PCfRUCjRLRsvBx0CeRsC36gKbyjqlsa13UqX7nYmdep99s1CLiHKv41DWAZD0lYsRhw+6JmIdSWFbbGyZSm9v3Gr4tK8bVOaT3RKQSkaxH47wpH2vVoONw3NoTMXKwSiHREhiiVIMOtt9CrmtnziMHq0BXImZNUMK0kBh2WP8oimmzKau/Wfn7SEXEomiKtJoS0bWIaGzPDoyJHaxi2ZnzQgWrdNtW2apC1kVIVURcFWu4i0VEMgdZqaQLZOX9rr4I4qTKxjqtAuKdW30gSBHxqquuMr5fXV3FpZdeiksvvXTu35x00kn4wAc+EOLpSQvr2pkdb+5btxldibjOzZ2n8kEVplL0Q5hRlWivsXcvMCGQZQKZKJ8naU9Ez6Lz2kS9V5USkT0RSWT0z+7AKnSlUCLq/WH97MzVNlrG9xR2ZmEr9gIufiUp+lr2Y++gsxllo75QFLGIaLkeck8VmCoWD3KBYaGCVTi+k/nYfVR970vXS3SPea+r7MxZNcjXKc1OKcbF7MTZ+DpOEVGFwuhKxMbO3L3gNp1OmplsS09ElyKDC1J/T/QEWeGmGpR2Ii1gFDpiDYlGSnSlRMyVnbnre1VIDDFtUqwNO3OY/SU9pGjGjAywzu/uiwRFsU6rAAardKJzT0SyNbAtRvrX3vZYXYmoLB6RJmNynYmu73EN63TmBEXEWi1Tfq+/b949s1QaqFJ1JJhgAmXhd+D5Xuk9EQGwJyKJjm3RB7R+qklsv81YITwWVAprvAD8x1YX7IUib3usWqBJHKxSX7sCFZ5n0pmNlhUx1eZzlKO+PRE1Bf2Yi0RkHewxwz9sr3xMqcqWmlpmxs7soBocT5uipNETUfXui1VsU+eyft1SCkuHCbxRbKt7R2ba7+MoEY1ipci8lYii7bj0BNmIwSq1EnGk90R06F9ZSKxgrfmBHqxC9ReZR3UuSJjWY8Dt/C7Dgua1CojXb7QPsIjYU5qboOZnWa18cNumrTgA/Ps6ue6DfnPnk0gKaHbmuojos4du2H27hBD1DXGIYBVAs/BFLLjpqYnlMQXqiTgo36sUdkuyvdFvnJvCSYoCffkYSm3eVpSM3a4CmFW8e/cOtBZoAP/FDBemVqHDt9+k/ToptbnPNl2oA2NUKw7P6/GktjNnGFbj/HjC8Z3MR93Thg9WaX4We0GlkBJCBatYzf9d7MxTw86sFxHdLdIuSKVExBwlYof5SVHIdsWeEHX6c6xgFcj2vm2u/QunhbU9wOyzGFERaysRBaRTiu20kBhBO666iLjGMAsyn+pcKKyAKQAQDoFQ67WKGIh4yed9gEXEnmJbp4DFpjPHmmQ2hbHmZ+GUiPHVRIr2IJwwPSztG+uYqg71VHaKretrvDYxeyI2ShUO+iQOtroWSBRAso7lzrWR+7ztJbH9hgrqWCdYJaaC2S7S+i6AtDoDqoJrzPHQvnbVY7zv9TgTGNb3F1QikvnMhhaVP3dWIrbcP9cLoJHGDL14I4RpZ84crMeT6Tw7czl5FpHszKiKiFIrZGa1wk52es/GukUbMIoLqogYzc5cWPvhaz2eWom0gNETMZpwowByYRYRAWCESXcl4lRiBaUVGvkKMFgFAKyIcZI2UmSLUFgtEDQloosqSuqFccvOTCViN1hE7ClStk0Iy0fnHkwtk5bofWLaiqOeCku162oCltLO3HZcLjetUsqZPovKAhxVpWKnJgq/SeGaZWfO2RORREYvttk9uFL0G9UXdRoVmM/20o3v5n6offBNMW5ZoIkckgDMV0v5FkeNwBg1HkYsjs6EgnkWW8bV3+V5E6wyZrsKsg6288bfnVJtpyVEMGqghTDtzEL49ERsD1aptx2p2CaKWTWkUiJ2tTNPplpyMGD04qiLlNGUiJYi0tPOPJ2ub2eOqYgdWMEqADDExCGducCKqOzMg9WmiIgxlYhkPvXCw2zPU2c7s5ifzszbjc3DImJPaQpIzc98+8SoE6st5TKeErF8NFQlgVKnayViCjuzZQkDtIRBj5AEYLZXVcwm9XaftUax5bY9NZlseiLGVxSR7U1dRGxL+41qI21b1CkffcaMkGFcLswLmVpESELKHpa+SvNmobD5WZ5AuRc8WEXviVgXEblIROYz79wKaWfOIo/x0rAzV5Pm3COdedpuZxaaAicGSrFX6JZq4VZw049JVzYCqIsCsezMwlYiqmAVSKfrsZy22Jn1nogRhRv152awAhWFPXIIQyl7Io6bbQ2bIiKViGQuU9vOLJr+iA5jYbudOX6rgD7AImJPUReYtsbQridIbRnR26nUffbi3OQ3Ft3mZ6EUlkNtNhZ7EJm2TAh9blqNVGSlREyoKlL70ByT2+el7oloBatQfk5iYQcWAX6qYVfWa4HgMn61j60peiLaSsRq/5yLUuVjm4I+iXI0M/fB186sX+OHCcKz7JTwged9RhOsktULezGDYsjWo1lcrh69g1VaVNme/Zy770MTrJJlys5cFZFE94nupJDr25kd1I1O1JN+vYioCm7dimNr06JVXQnEVyJKu4ehVpRwS2cutydFpkls1fvfvR+hK1M9hCIbVIVEYEV0tzNPjSKiqUTkEE/mIaV2Lqifqa+n3ZWIRuL4TDoz7cxdYBGxp7RNMLNACoGUjfdl23EFumEcaJ33Y1ua247LR91kJMiqSWuulIjpVEX1MTnugh2s0qSDc9AncWgbWweexXG3/SgfQ6my7SAmYDlSp33tzO2BYHGtiYBWzAxmZy4f244rZjF7rp3ZtYhYvVAD2pnJJrF7ItYhgo4fm7qXc9tYGDUVV9mZB8Zj5mBnncwpuPn0WXRCtigHM0c7s9YTUWRmERF1sMrYY2c7oCyXEFWSoJ+duaiLiHrBV/VEjKeW0lPCkQ3KXoaolIidP4OWErEuSI7rc5gQG1H3RNRSmevFj+7jltET0WoVMMCUwSodGGz8X8hWpElubH7W2MLctqmuWe2N9+OtigFh05lblYiRx5D1lKM+BQF9O8MEqr3ZkAS/YosdrJLCvke2N2024iyBsq1tUUd97TLBKNqOK7L6BphNnfYNVlnPzhxzLLT7FC/Cpj1IMB7OszO73ohPtOMSiN+Cg2w9ZvqoeoyD+va0odX7PtNlH4RtP67u4QYoKoXY5hkXWv9ATUYvPCzSTrQEq7gW3MYTiUy0KxFrhVE0hWVj084B58Jovbmp1bNN26ZzWIsDpWpr2jx/PgRQ9kTsrkQssCLalIhrVH+RuYiWMaNRGnc/EXSV90w6M5WInWARsae0Fdu8+8QsgRJxvUKm66RF/d0woRJx2qIC8pnoGkrEmfCHmGop25rodyM+rydizCABsr3Re7YpkqQzr9MTz2XhwS7eAWnaBdhtM0IpEdP3emwfC13H48YePauWSmNnDqSwVIt6Wabab7GISNalCVZRSsTye3d3SvmYMmTKVIBVBSldidjxlJgWRWtPxHrbkYqIqnegqbCr7MxCYq3D62ukM9tKxCyynXmqlIhmUWIopk6hj7WdWU+iNcIfEtiZRV6rB0eYdJ4njQuJFahglVGjRGRPRLIO0u6JCEAqVaJTT8SW1g56v1F+FjcNi4g9pc2a5h+s0jIZizzJbAqZzc98G143RQFdiRh3EGlVy3hMdPWbFntyl6RvWyD1jZpMDpWdOYvfA4xsb9oL/vGLbW19u3zSmZuWCs3PMk8VoAszduZgir3mZ7FDEvTnUoWOOknZsT7W1sMyRdF3VjkaZozPM5GkdyXZeswbM6SsAkp0SWGn7TU/i11ELKREppSD1f4r1aCLRXYybVft1RbpSIo9WT+P9uJ6BKvkbYVRbZuQ3XumOVH3bRPm88Mt3KX+GzHbvzKLamfWglWyAZCPACg7c7dtTafzeyIyzILMpVYianbm6nwXDud3qUS0zi/VKkBM+VnsAHsi9hS7OT0QLrGu3T4V78YKmKcqcdvmMtiZ21a+fSa6eqG4DlbJE9qZA08w62CVPH4xgGxvlqE37Lz98Elnbj+uBMXReb0DfXv5JrxuAU2LkXymOOqnRGzrzRlTuTejRAzUXmSQi3p8V20sCGnDbt+j36P6BNO13mdGGjIKidkehnVPvO5KtHnBKrWdOVpPRFUQmO31V4aQbH5T42nRFFpnlIhVwSGSEhG2wlITJUgXlWehWYgVoumJGE2JWEgMhbYvSonoEKwyk85cbWsgCqCIVOwlW4+ixc7scX5LKhGDwSJiT7GbuAP+KZdtKkDfHnddaU/arPbP07qi25ljF6XaVSXuNm0zWMWcjMecYDY392GKiGtKiZinOyayvWlrup+ix17bfmQeC0W10rztuGIGkMwEq3gWpQKnWLsS/LhaiqMpPodz+9569kQs05mpNCcbYxf99HPCrac0jO0B8e91i5aiX6b12etcwJkWrao9EdvO3BasItz6B46NY7KLiNX3sdr32H3b9P1xKXSo1NnWYmu8vm2Ffm+dDeqeiCNMOu/DvJ6IACCmh733lfQT0aJEVOeFixJxWljqWgDIys/1AG7tB7YrLCL2lEU0hm6bZKZTIjY/CxWsok/GYyeFtdrPPezHbdsbJlDt2a+t7wRzPDFVoyl60ZHtjephl1KRDWyklum+H7ZSDkjTAsFW0fsWxtpaRfi29nDaj5lej37HZS/QAM24GDWdeU6wimuf2olmZ1bXLC4SkfWw73f1McxlQrj+grnbPnbeB8POXFn3DDtzt+2NCwlRb68pTGVVUSgXcSyyjU1XLyJWPRG72pnnqCvLbariaByFW10chakaBdyUiPXfZLN25oFjWIsLRpFGZH7pzLYSsdoWAIjJIe99JT2lTirXg1XcFwkmhdXnE9CUiAxW6QKLiD2lrYjkH6xSbUefZOZ+E4auyPVUJd7BKintzGELAnXxrq0gkEB9I6yCgOskd61qXl0XEalUIZFpHVvrAn38VNxQLSsaRVnzMx81tCt2SEIoJWKb/Txur0cY++Ft014nWCdNsEr5feZ7XHV7EVGP82MGZ5F1mHduAa5KxPD3z933YdbOLAzbb0cVmN4/UE9njm3jq55jvp25gxJxUsxavuttDqqnK+KIAmzLpaFE7HZfIKXU7MyzwSq5iFdElLrNOBuYwSqd1bB6sMoqkGWYirKILccsIpJ2GvXy7JjhoqCeFFp/WDUWZvFbBfQBFhF7it1XSv/aPbFu/mQs1qRl3dRpz+MapExnbrWfe/REbC0IqIJbTDtzuxLR9fVVk8nRgEpEkoZpy2JKEiViYIVd60JGQoWlem7fc3y6ju07rv18QXbmts9hROWeXaQdeBZbJtrnWl2zqEQk6yGtz6B+Tvj0RBQt98+x7p8MJaJSozkGkABWkrEerJI3FtkY973KmmjYpGoloux07RoX7YXRcpP6a+W+v5vGLnRoxT8x7aaGHE8bpZTI2u3MseYoRijMTLCKpxIRwFSpESe0M5N2lBpWTypX55lLaNHUSHUfGI85GKzSBRYRe0rbTZCvHaOtMBU9nVlZ7tomzr52ZiFqVU/sQaQtQdRnktla8M3jWxPtgksu/IoSqsG+3RNxUsjoFnSyPWnrRec7BjntR0t7CZ90ZrvIBaTq9WgqLDPPMaOt2KrWi5bBzhzyuOoxPmHLCt/3S12fBlmG0SB+UZRsPRolYvmonxMu93JtPRHVuRXr1DKa/yt1m6ZE61pEmhp25nYlYpT66Dqqorxjr8f1lYjVQjOmcQq/dk9EvfjXUS01KbTjSm1nNoqIeVNEFAF6IgIoslH1SyoRyRxaglXUeeHSE7FMdbfTmRms4gKLiD2lUXM0P/NX7JnbARL0RGzpVaNu9FzuE6SUzQ1jJpptRR5D1gs18AlWSa6WsuzM9Y244z406cyV9NwzhZGQrtTpsal7Iq4TnuUTrJI8MMaawDeT93B25lwl1UdswzFzXNUkV8qAhY4Uadpz1ObuwSrlB3uQ60pEju1kPvaiuX5/6KTKblnUjb1QVEg0ljurMOXSt2sybe8fmOVxbXyNEnE2WKWrnXliKIra7cxdw1pcEXbqtBCQKD8zXXsilj3b2uzMiZWIIgcGZdFviEnnfVhXiTimEpG004wZuhKxOr9l94n/tK2Xal2gZ0/ELrCI2FPalGi+N0Hr2d2iJda1FtvKR58UPqBSIiboAaY/X6hQA70wqmjszOksl74qFVVEHA5mex+xLyKJwTIUpYD2McOnH11bUSpPoLC0Fx7qMcNRjdaq8o4crCKt64z+6Lofrb0eEwSR2LZqXzuzXqRveiJSiUjakVJqIUPlo/B0lazv5IlVRJwNVjHszB1PiXlJxqJKJY0WKGAr9oCmv1nHQuZ4KpGLdiWiT/9IJ4rZ1GlVUBQdLZcT7bhEqxIxXqFDqFALiFLdWduZJ50/g9NCYgRLiZiXj4JKRDKH+jNoqJer88yhJ6LZBkHZmavxIlLAVF9gEbGnqMG9tXfgInowxeqJuF6vR4/egYBSIs7+PAatVsJaFel+E9wa/pC0X1Y55LgrEcu/G1WTSz0Mh6tHJAatNlJP9ZULre0lhPsYv14iacw+qvNCElxP7/WCVeKFJGjXGXVcWg9ep5YV630OEyhi64Uiz3sCNcYznZlsBn3IbV3gDrSgEvvcKgq0qGWaYpuLnbnNIpsNdDtzDMWeKoxqBQE9nbnDqT6ezrH9Qi+ORioiSsseCdQX585KRO24hJgtIpbqSvdd7US170Vbim1XJeJ0VokoKyWiYE9EMg+18NBSUO9aoAesnohWq4iBw+d6O8MiYk9pVCrNz3zVF+sl1kVLCguczqz/Ta7ZmWOPIUrA1GZndrlZaCv4DpPYmWHshyp4OPdEVErEqnhoKBFpeSMRaOuJmMb2O18ZHmrhoWk/4Lyb3ffDKo75B6vMHpdvoavzPmjvh7pnNZSIHmqptpYl4xS9OS2Fpe9i5TDP6nGeYzuZR1uBXv/ar6d08zNfF4XLPsz0+/NIEB0XLcpG6Iq9SJPn9ezMQnZyNRkW7Tk9EXMUcd6zdZWI3S6g46IJVjGKo6J5/6O5pWoVmFJsDZt96Gqpb+mJqIqI2ZRFRNJOZrcKAJrEepd05rbWDkawivu+bjdYROwpbSupqoDjbGduUaqkS2dufuaTtKn/TS6El/rPh3riHEgF1Eyc9e2ltzPXSkTXdOaJVUTUPggx1VJk+6LGoOQ9Eaunak9n7r69aZt6PXIiKdBiZ/YsIrYvfpm/WzStdma9n6tH31v9/RrUtvr4duZwPRGb7aki9hqViGQO+rAgWgrqLkPXegvmMceMzFbLCE2J6BBqkds9FtEEqwxi2fjWCVYBgGK6+aKAkTi9Tjpzkp6IQN3DrWv4w2SewtKjJ6Yrwg61qAvZk85j/LSlJ6KsHkWxFmBvSS+R8wvqAi5KRF2VbaYzx+oN2xdYROwpoVUqwHL0AmubOPv07dIHiyxrJq6xB5HQN61t/dLqQkfECZm9op97KhHrnoh5Y51LZUEn25O20KLYvWH1/Wgd4wMr2wqJaOnn9rXL1yre/jrFDSAx2ma0FRE9+t4uS3iW+tz4qnLrYJVM1G0r2O+WzGOeEtHHebNeO6CYPRHnq2WKzu0dxtN2O7NhkY0SrFK/uNoPm4tOMd18wW39dOZGWRRj/BAtvR6lY9+28bSlZ5v29UBELHTUVlKr2OJQdDaDVVaNx4w9EckcMmmpYYF6DOuq8gWs4CJL5R2zQN8HWETsKU0ASfMz3yb57WEdkS0e60ycXa6p+kVQD1aJNWFWtKlKgtiZW6yJMSdk9n74Tt7rnoiD5oM94ESTRKStF2EdWpQiWMVQIpaPXvbYljHIdZsuNCEJwnicFtJpXFbjZ9vCU0xrokJ9bnRFv8t+tNm0U4Rn2UVa32KLKqgO8qxRVhaSzc5JK2ZPxOZr4TEWTq3FTyBNEVHY9uNaBeaSztwerOKzTSfk/H0AgKJDwW1StCdO69vPRRFH5NDWE7G2XHa7iS9Tp9t6LDYF32jjYW1nVqtEWhCPkxKxUhzWRcRSiZhTiUjmofoe6jfdqojYUeULAJPpFLlQN5p2OnN5rvJ+Y3OwiNhT2uzMqkDlqippLLfpFDht6cyZx4qzYWfWeiLGHj9alYjC/bVt7W/mkfbsiq0q8i1kr1l2ZiDNcZHtyzL0hgXa20v4pDOvt5ABxG9ZoU5x3TbusgvtxdHqd9EKAs3XaiwUQjQBLx5q87ZFvZifw7l2ZsdbAvU5G2h2ZqC0LhJiM1eJ6LEg3Np7O3JSfSEx2/xftzM7BatYRUnAKEzFWDsX6/REBADZQYm4Nl1PieiW+OyKaLNpq69d0pnXsTPHTGdu0rRbbJ8dh+RJIbWeiCvVY1lMHLAnIplDVo8Zs6rcrgV6wGqZYClsVfGeopTNwSJiT1lPsQe4TcamLerG+OnM81eIXSaEeo9FIYRXM24f2iySmcdkrFbftPTLStK3zUokdS4iTmeLiCkSZMn2ZT0bcVQFWFt7CZ90ZnWutijbyt/HVe2pYmZmFDLd+8OaQTiVwi3WMbXYmfV9cvnctIU/DBKkGdtFWp/FL6B5j3PNzgw0KnRCdPRxSbTcF/qkMxsLKrn79lyQrXZmZbmTncfjuXZmLawjTu/AqtjWoioCgKJDwW1usQ2wil2LHw/r4mjbcXVNZ56nsNTDHyJ9DuueiNZncIBJdzvztGjszCqVuSoi5gWLiKSd5jM4a2fO0F2JaBT1W9KZgfi5CFsVFhF7SptKwScNUko5YzMDUlg82vahfPTpfaNeG/VyRQ9WWccq7nID1GpnTtm3zUokdbczmz0RgTTqG7J90ZVSihSfwdZgFZ+Jc4s9Vp8PxWtZUT231RNR/12n7bX2eiwfY1oTFW3BZE4LYC0LTymViOqz13wG3bZXn1+5MN77mL18ydahTeWrfx0qnTmFEnGenTkX3ZVo02JOknHsdOZqH4RRyNR7InYpIhazak1rm1mkdOa6OKpPq6t96Gxn3tB6HqfgCwBQdtG6d5yyM3dXw5o9Easi4rBSIkramUk7Yh1rv0tPRKkXEe1+s0JCxDy/tjgsIvaUptjW/EyfEHYd/PXzyZy0xFW3tdlMhMfN3dQqtqobz9iLEG03rT6ppOv1N4up6JixM3sqtlQRcWQoEdkTkcSjrdiW1s7cokT0UJoLo8ilTe4ij/FNGFMgJWKgAoMLtuJd4dqbUUrZuvCUYiy0i9m+hVF17R1kGfJM1J9HJjSTNqRRoJ8dk50WHtrGjMgLD61FP9Xnz6GAMzfJWA9riXFsxQZ25g5KxLVpS99IhZbOHKXVTa1EbCl0dOzbNp5rZ26UiLFu42ds2ppiyy+duSweiqHqiUglImlHtJxbQrgrEQ21sxWsAjChuQssIvaUje3M3Qd/RZZw8tw20fWy8FlqjhTFAP35WnsiuhQE2pSIWpP6WNj944zPoMN+tAWrDD0t0oR0oc1GnEQBtl6acqCFB9/wDxfs19ccMxy213ItHEROZ1aXW/21BdzbO5hhEs021VgYU7Vnv76NRdslNbH5m7KAKKg0J+tiKhH1r/3tzHNbO0T4LEopMRDh0pmnxZyCm1ZsizJxbg1W0V9bRyXiXDtznP6BTbFNS2dWwSodx8JJUSCr3/vZPnDRCr4ARGErEZvX1Smd2eqJmFVKxCGDVcgcMmlZ6gEgd++JiOlY27jZExGo2gXwfmNTsIjYU2wFmP1114vqRnasmKuzwBwVkE9z+mpz6iWKb2derzDhoL5p65eVoHegmsuKloKAS1GiLVilUVhSqUIWz3pKxJjnVrtaxkO93LI9IUTylhV64c3l9W1NsU5kZ87sIqKjIlK/1rWpzVOkM9cLRfUxuW8LaAqiKZLPydbB7IkYZoG7WGexGohzfhmTWNvO7JTOPM/OnKbYJjJz+jmtpqOyi525kMhFyzFp38cLVpm1XCrLdm3H3CSTqax7s7WlPecR7ZZND8tqP3JfJaKZzpwPdwAAVrDGhSLSTnVuCb2grs7vjv1GAWuhwlqgASIHF21xWETsKW12Zh9Fh36jZkyeI6vAWieEASYttp059vhh9wHT98nluGTL6zSoVSoJlIgt1kSXQu24JViFShUSk7aFjEECNWxrf1ifpPoWeywQX51d25nVvFmztPqkTod6nVxokq/Nn7sWn01nQPPzOjwryRhv2ZmdxvfZ+wyO72Q92lrBAJoqO9SYkfvdu3SlKDSbXp3OXD7mkJ2VMmM9ybgl8TcXhZPSuyuyTYkIoED5fTc785xj0r6PVnBrS2dW+9QxnXk8nWc919KZYwkdqs+hbWd2+byM9WCVWolYPg4wrUUChOhkbXbmXBXoXfpVtASr5MP6R9FU2T2ARcSesl5yp/77zTKvebVrTydX1ktGdbmxm2e3lZEHkDZros9kbNpSlEwS/mAVXIxG+U525qon4iCt+oZsXyYtRcQUvejaipl1MJSXndn8eYpAAWBO0c+p7221jZaib7w+j7P7AOj9A7ttb96i3iDBWGiHZ/kkTuvvr1IgquJNTJUv2Tq0Bf6V35ePLmNG2/nqE0zownrN/zNHO3PWFkIi4tqZRa0qMot+ygZcTDff42xuAAkQvSdibatsCYwRKDrNKYx+mG12ZhHPztwUcEzb5xATp3CfkephVxUR8+Go3J6YsO8taUW0tkCozm857T5f1wvjalzXxsTSqu+8u9sKFhF7SpsSTVeYuAz+9XYCWW5dqNOUjQl89TuPYpu6QRQeN54+tDb/V5MxhxugVrVUNSkbJ1BLKZuRj6UeaJQquhJxmKDXI9m+tPUOTNkTMWsZ433SmW0lYuzC1HpjoY9NW1+gyZZg8Qtw7x84d1GvvmZEtNXPLMRVP3d4bdXrIMTswhMXiUgbc1sFBOgPq28yelK9PiZYdmYXJdp4buKv3mcxUbENQKHszB3siWYAiZ3OrBURI8xPmgTZ9n6TXV7acVuoDmC8V9GUUrYKzGMfpsUUQ1FtL6+UiIOqiEglIpmDCiYSmlpQePSHbVVDC5Hm/NrisIjYU9SN0zz7VOd0Zu0sbe8FFmkytk6vRyeVypLYmduKvj6Wu9ZCR225jNgTcY7VrdyP7selVioH7IlIElGrfBOOg8BsijHgGTI1bzKeqGVFWz/XUMEqPgUGF+ZZLl3V5vMW9VKETNULVnXfW/dFHbVgNmhZ/GJPRNJGs1Bp/twnWKU9PCtysIphZzbTmTOHYI1pIZEJJdtsK3RFChOoeyK2KxG79EQ0LNrzlIgishKxpYdh176Mkw2s5zF7tqkCTls6c9fPS1ZogRZVQUgVhoaY8B6etJIpVbY2bgmPdgWyUjtLe+HB47O9XWERsafU/a0CNXKfG6wSedKyXrBKiERS9ZjKzhxqomtbzPTtjWP2y5pjdQNcEkml1hMxrQqMbF/UmDFoGYNSFm/0/XDZjbYWCPr2YycZt7fNCBOsErvoW1u0rSqiq9p8/qJeOlu9OhYv6/k613eO76SNtkAowO9z02Zn1k/dGCoVY2GhViK6qwY3UrfFC1ap7MzCnH4qJWK3dGa5YTpzjikOR1C4iZYEWVGHkHQrdBjBKq3pzDGLiJat2lGtJaVErhcRKzszsrKIOMCURUTSiup7KLJGieiloFYLNPq5pW9TMFhls7CI2FPalG0AnFMp1cVCCDsBL65KoJkQNj/zWnG2Ji0+Dfx9CK1uaps4Dz0UIq6snzrdfSVdvS2jFiUi7W4kBmqsaz1XI90ESylbC1Nett+WsbX8PpUSUXt9VQ/DQKpsnz66LsxTedZjYecexe2LesME/QPVx6JRIpbfu1xD1Rg+1Fa/BuyJSNahWXQwfx6iV7a+TSGEV5/FrhhKxNpKWvUJFd3VN4a6rcVym0HGue+dY2dWSsQuISTrKhE1O3OM4pQo5r+2XQsd42L996prUdKHuieiVcgeYtJJ4DDR+yECdfEQeWlnHmFCOzNpJavtzLPJ57mYdjq3pJSNRX+OenlAO/OmYRGxp7T1dAE0u1tnO7P594rYKrD1rWkO27OTQJOlM4ed6K73OiUPf3CcOOv7rduZB+yJSCJiW/QB3ZYaZx/058lbFHt+E+dwih4XWlXUHj0MbaWcvr2UhVH9+3CLem7KRh/sMd7PzlxexPMWpTntzKSNjc8t922KmfvdiPcaRoKoaWfuWpQqinLRqdUiKxqLbIw6vVK2ZZaVsKj2o4sS0VBXzigRy+1nKKIUp5qeiFqhQ7czd1Qirhusgm6FEy8KSxFZ2Y+79qKbFhJDKBvpsLnA540SkcEqpA01Zggxq/Ltamcux0G18jRfvUw78+ZgEbGnzLOmuSpV6hu1rH2CGbtBvWkzcVci2sW72AqVej9ky8Q5dLCKUnREvFC3WRPrwnPH49JXk9vszLRCkBjYieP617HUUvr4rRdcXJXm+t/MKyLGOrZWO7OXwtLchv51/GAV8+eu7UDmLeoNU9iZrXuD3OMzqPa7rSciF4lIG3MXzL3CmMrH2ftnOG+zK9IIVqn2wzFMQJ1X64d1dA9rcWKeEhEOSsRJgVwVBGZURY2VOMa9YdZ2XLoSscMujKcFBqJte26FEx9m05mronNHy+ekkBiJys5cqQ/Lr6ueiIJKRNKO+gy2BasMXM6ttlYB2vdUIm4eFhF7yrxG7q5FsrmToNjpzC0qoCAT5zpYpXqeyBOW1p6IAVRFploq4QSz5bi6pws2/1+3u7FnFonJek33Y6v1AGss9FhQaesDBriHf7jSamf2OMeXIVhl3mvrqohsS+bWtx9zjLcX4kIEnQ1axvcxx3fSwrx+o6Hvn/TvY9qZC2RaEbEJQemyD1O7iDinMBVjPMxqVZE5/ayDVbr0RNRtv3PSmbNYduYWi6TImn3o8jmcFrLdpm0oUf32d7Nkc9KZuyaET6eanTnXe9upYJVp1H7tZOtQFxH1fqPV57BraJF+bok5Cw8xe45udVhE7CnzeiLmjhaPuTdVqRQdLdY0oPuk0LYmivrG02s3O9OmbvLpb7Zuj8WIir224nPumBKt9jsT5nENEkycyfalLnTkLedWrHFQu2kKFTKljmsm5TSynbRtASxEETGUet2FjaziXT83be0vAF3ZGLMnoqmiVwVAl+KNmuy3KehjHhPZOsg551bd39qjP+xcdWOEcUMdV6FP0xztzOPq3BG1aq+lb5+QmMYotlUqoMxSAck6WGXz+zCezgmLAQwVYJxglfWViJ3SmYv17cxdVYB+WMXRTNmZu1k+J0XRFBFVqApQqxKZzkzmkUEpEZtzQTj2GzXPLTudOX76+VaHRcSeosZiu6eLu53Z/HtFKgVOWzIq4NCgXtnCaitWGjtzu7rJow9YS9F3GDlJG2gvZrsWstfqZGZz2PLpwUVIV9qUbepclTKOuk1vBTAIVBybzll4itn3Vg+Maev15xUYI9oKU3GvW3ZRoi64ubYXmbO9WIoO/f2qlYjV8OzTv3LAnohkk9T3poFcN8A6SfUxXQ+1ElHbB9eJ89S2M88WEQErzGVRqAJtbtmZ1T7Jze/DeFogb7P9AoZiLsZ4KFpUnkIr+nUZ48fTAnltuWwPaol17crnKhG7F0ZVT0Rh2Jmb94l2ZtJGneiuB6vkbv0Lp8Wc5HPt+xwFRSmbhEXEnjLXzuxo8ZjbLyt3L3S50Gbj0r92toXVVqzy57HtzE0PnuZnPqoi9TetPRZj2pnXTZ3uqkQs99suIg4T9Hok25dG2db8TO9LGOP80s+ddiVi923KlmJbuc2qSB9FfaM/b2A7c0tQS7xglep5rQuya8GtTZGvbz/2cenPXd9jsCciicC8EBSfIuJcJ0/MBZXK1ivnpP12ud1R14tWO7OIW0SsrYkzduZyP+S0g515Osf2C9SF0ljBKjMpxto+dU2+nsxTWBo9MSONh9JSRGpqLSmbc2UjynTmtp6IjRKRwSqkDXWOZ5lmgxeuKt+mBYJYb+GBBe1NwSJiT9nIfuwarJJSpQK0N5TXv+56XbVtxMnszG2KPQ/rTFvRVxXfYio61rMmdi10qBthXaWib48rRyQGbWOr/nWMsXCqnVeiZT98Et1nJ+PlY4zzS99vY8zwKPqtN7YWHSZBPsyzM7taf+ddj2MvqOjvhypo1ipPh9dVXZvYE5FslnkL5iHSmeepG2OM8crWO9fO3MVKWp1XrQU3TY1TTCMoEdUEfo4SUcou6czFOunMzWsVpSfiOirPrspB87h0O7NbT0wfMqUMzc10ZqXm2ux+TKcSQ1G9t3oRse6JSDszaacJVpmXVL75bZUFemV3mdMTUVCJuFlYROwpjVrGfIvrEBLHSct69o4Yk7G2/lY+dua5wSqx7cxtzf8XNHGOlbJq7EeLNbHrfqxN2pWIsQvZZHvTNrbq51mM86ttHAT8QqbmqeVyR8utC/pTtNqZPYqjbWOQ/vtFUivD7UKHo1W3sXCmXVDRr5N2sIrLYlXbQhF7IpL1aEtzBzydHGqOmTRYpVIi6tM0Y+LczcIHlIo4AFahSysidgg1cUUFq2TCLiJW33dJZ55qwSqWslEvCEQNVjFUno0assuly1AitoTgDDq+/z7kdRCOaWdWduvNngqTosCKUiIOWuzMgnZm0k7eEqyibnZzyM525lzMszM3KluqYjcHi4g9pa23EOBuNdoonVn/P4ukrXdg5qECslecU/VEbJvA+wSrtBXvhpGt50C7usn1NVYTzOGcIgdXjkgM2uzMscfBusfVHLudTyLpbAuM6jlTKhF9+sO2WH8zj4UnF+YV/QaOxdF57UWSpoQH6Cvc1vOYPRHJesxb4PbpD7tR0T9KIFM1cS5alG0D0c3Cp4pog7b+gXpPxBhKRLm+ErGLRWUyXae/mabajBGsktnFNsBZiTiZFnPszPF7IjbF0YHxqFSFmx3np1pPxHl2ZioRSRu1nblFidg1ZMgMVplvZ+b9xuZgEbGnTIr2SaZzsIoVQKIwFTjxFB3zlIiuDeptFUX8IuLsjbDrBBOY14swhZ1ZPXeLErHjfqjm2IM5PRGpRCQxaE2Ij61sk7O2T8BPKTOvz17MwpShbAuUOm0HfwBmkSqGwG3DQofjop41FNaLhrEmY1Oj6GsWEV0+L+OW4jgXich6NKpB8+e5lyq7/f554DEOdUVWA5NssTMDQNGld2AhG7uttR1dwSen4+472pGmH1l7T8SuSsS6iKgXpoAEdma1UjTbb9ItnblF2agVOWIXEWsVmPa6Aps/v8ZT2aQz51o6c21nphKRtNOkM8+eW1nHc2uqtwqYo14ui4j8LG4GFhF7SpuFS/8+lJ1Zn8TGVCLqkyf9Pq+7oqPaht0TMfL40TYprAu+DkW/1kTSlHbmtnTmju/VuE5nbi9kcxWTxKBNLSWEiNovSxVUbKWM8CgithXbgMh9wLSnyFrUyz7FUUPlrW07xnhYXz/nFCW6Fsjm9TyO36N4Vjka4r3SF4pcXyOyPdio1Y6bKhut28xifhaL+UpE/feboQwg0QdXvTApMEG53Sh25jokwVQOSqj3q0sRUS9MDc1fav0Dk9mZM7cE2bl2Zq1wEms4zOzejJnVE9FJiai9V3nTE3GN6i9iUWjKwUz/3BgBP64F+vnpzLQzbw4WEXtKncY2z+7W8fxoUpHNn8dWIrbZuIQQ9Sp0Z5u2NRlT9anoSsT1eiL6BKvotrAEij11XPq9uOskc146M3sikpiM6yKipQKMOMEsWoot+j647ELbGKRvM7YS0RgzfBSWLQU8U73eeZPd92FO0VelendX0JePds829XkYR5qMtdqZPa5b/3/2/jxMkuM+D4TfzLr67p4bx2BwgwAIkiDBAxDF+5JMXUvK5nopirJpy9JC8lpayzI/a2l90mfTpi3Llk1R2jVNSbYoyrJFSeRSvG8RBAnwEO6DOAbAYO6Z7umzKo/vj4hfZGRVRmREVkZWT0+8z4OnMd3V1ZVVmZERb7yHyEQsum/5Sb1HAdK0+NqqqvIFsnN3RN04RvO4LdK0QIkYykpEc+txJKtvgBEFjrASO7Yzp2mKgN7b4cVEBSVilFMiDpGIkgqwkXbmMvuxjf08SRAGQ+Sd9P/tBu3MIyqw4UxEw9cRJQm6AWUiSkpEqajFCwE8hhGnKhKRnYehbVO9iqCXntPbmc3hScQdCrqo2kMzq6o5McJGrFA+sL85GTuz/LoqH5coVpmMnZlet0xMjEOO6XLbGs1E1BSr2B5XpkQcJk683c2jOcSKlvAmyWxVXEU9ZFv++802ko7aY3OvYYx8M2WbdgNjPf0J1edlO3apinWa3lCRyRYiNMc5X4qyPr0S0UMHlWpwnPMwLXBQ5J6zifkhFasExXbm1IZsS9KM5Bp6HgCIuRLR5jmrIEllO3MdxSopOgEnPsNiJWKIpJFNlSAtOC6pWMUuEzFFW9OkHQaplWJzHISKTETrduZEoRrlNvReEKE/aOaYPM4fxEmWe5rbeODXli2hzsZC2oVVNbp7QtsUnkTcoaCFbmtooUvXYFXlw7AdKwwzFWAjraSqbKmKE8ZhxZ6wMze8XhGkgPR51V2s0p5AJmLR4rnqQleEgw+TNz4T0cMS9zy9jJXNavlPIptzgkpEVVyFGN+rqMCUJP1k7Mx1bDwAxeRo0/etRJBt9WSsKTPbGi7PElnJNVvP5XPQbxJ56FDkdgAk1WCNduZG288TRrgkBe3M8s9NkFPfACMKHCLwUovnrIJYIjPDkdfAj9PGzpwk6CgzETPVXiPFKoVtypkS0aqdWVaOKkpwwgas56mkAhPkaCt7XwFzQj3K2ZnlTMTsnI4ayOT0OL+QpNmY0WoXKxHtmuoTdRmTsOonwnHkoYcnEXcoaJIzrESsqlRRtUHKf6ORTMQSBU5VmzbxUq0xFuHjoIgUGOe16MofmsxELGpnrkqO0jndCYvtzH7nyMMEf/30Wfzwf/oq/smf/HWl389y21QqsAY2U1Tj4Bg5YANFXECjNm2VnbnimJGmabaRocgHnqSduWrGmqo9tunc2yKr+Dg20iKFrY+r8NChLL+wyrQgVhGTTRar8GsrVWQi2uQXRnGSz0QcsjOTEtG1nTnO5ZsNL+Dt1JBxwsb2jJgqbmcOmypWoQKSYDTDsBXYqaUGcaogJbNjTBtQIsZSflwwpETM7Mzmz9XVtDMDQDzoj/eCPXYcoiRT5QYFhHobsdV8N8pdW8XFKq3AF6uYwpOIOxTZIjP/EYuJVcVileGFGPsbfOHSgMKtqNRAfg22i2elnbnBBUuapoWkwDhtykU2YqFSaVCJWPg6xrUztydr4fM4v/HkqXUAwLPLG5V+XyhiFWNQE2SbCzuz6rjGaYm3RabYy288VH1vizL7hv/dzHHxv6lSItoWginLJHiTdkNjfJFVnO4zldSwmkxEn1HkUYRUdS1UVCLKIf2TzIcVdmaMWvjkn5ugzM6cBM0UqyjzzZCpIckWXAZxv9ou7cwpHdco0WHbIJvLeizIWASAVmpXKFEFSYpMidjKk4gdy2KVQZxkhG9bJhGz8yCOPInokUeS6JWILUs7s7yRobIztxsaM3YCPIm4Q1GmRKxqnyrgEIWtz/XEKk1Tta2a/9O6nXloApplIo7xQi2Rs/DV0GIMFGdHtiVbmOvJB6Eo9LxqLqfaRurtbh7m2OC5O1VzkooaZIHtsZkShtn4ZXuN0/XTbSs2nhqYVJGqYXjxXtX2K48xw/cMQQg08HmpGmTDiudMUXYgIKmym1IiFm1+jaNEjEevrabVlR7nF7KSofz3qzoeVJEK8r8bIRE5KZXklG1BJdXgSLGKykrcgJ25HVBRx7BykL0GUyWi2FSGPhOxHcSNFKsIJWKBcrCNxK6dOSlXIrYQOz8PkyI7M3+fwyBFYHFccZJmxSoy4St9boknET2GEEmZiDn1slScZLNRNJDGIHU7c9xYOd35Dk8i7lDQwqgowxCoQLYplC/y91yTONrJXdVsqWEl4hh2wKpQqWXGsc7EQq09qgAEmiNJ62yJzjLbinPAmlLfeJzf2OizCURVUkIQHSMEDrudNjF2qMZjmaSyvcYHkV6J2KSdeSTrsWIBifwRKwmBBhWWw+6ZqhZ4WhT3OnkyoOl82Lhok2gsInv0HPSbRB46qAh64qHtSUS1ErHq/LkKUqFEzL8GIvyStGomYjDCuCaiWKVBO3O7mESEsRKRfQadknbmpuzMYZFNe4xilSwTcZQ4ARgx6XpMlD+vzM6cfw2m850oSdEtUo2GoTj/Em9n9hhCUmSpBzKlcZBYzXXjRIp20LQzeyWiGTyJuEORtf2qLB52z6cKcpf/huuFi/z8SlVJRXKUnk8oGhtcsMh/q11AIlaZKCQFizs5w62pAVLY+GrIzFK1MzetvvE4v0FKxKoT8CKiA5hQsYpifJcfYwq6flSZiE2QoyrFe1WLrHw/GHmvBOHWRLEK+6q0R1oeV5+Phb2RsZD9exA3ozYvVrxXPweLbPodX5zloYFqzKhqZ5bPs0BJ+jehRCxoZ4ZUtBLb2ZkD0Ug6uuxLGixWEaqiMduZxaZyUND4C+TszE0UqxQrEWW1lPlz5ZSjOTtzKM6HlmUWXBUkqdSMO2RnBoA2Ist25gIlIoCEP2fii1U8hiBHIKCIRLRU5OYI+hE7c2aR9vEpZvAk4g5FafB+RUXHcLuk/Jyu7UaJZkFYlZiicaI1ZGdusldFft/ySkT2tVq74KiiR7YBN7UgKyoAqKpsGhRY3YCGc4o8znuskxKx4iRBREUoi1UaIBEVmzqy0s123FAWq1RUAVYBveQRBRC9BsvPbCAtHlXH1cR+SjYOFpO+tu8tKRGHredNq82LzkN5g886siIatdQ3GRPgcf5BtcFd1c4sn7Iqx0sTY3zK54VpMB7ZBrDxpbDtd/g5HRerMHsskW3DpJ9dOzO1p3aFNXH4+TJCwPWmea51ug7LZaz5vKTndG5nTjBqZ5bIWlslYqeoWAVAwj87r0T0GEYUp2gFRECMNtW3LKMC5LIgrRLRi1KM4EnEHQpVJmLVidVwi7GMpiZWOduvYpFpe90ri1UaZBFzlrsCG1eV91VnIwaaW5AVFQDQcdkS2QOVnblBBZjH+Y9NrkSsOl6pszmbIztIPafaJALsxrAkycqdVHEBTZRNqVqMqxaQ0EQwDOojGaogszMXqyFt31sVidiSx/gGW8Jz4/sYatitiF2bXZ+J6GEIVT5o1exlrZ25yUxEhZ1ZqAYt2nmjOJEW4moSMbZQN1YBW8ArrIS2xSo0BkKhRJTINtf5ZrJyUFmsYpWJmGS5bcPKUSI6gsR51mOcZgROQO/vSC6j2XNFcZJ9Vu0hEjEgJaInET3ySFRKRJlMtykt0m2oyGVMkV9PmsCTiDsUykUm2XUrNtZNMhMxH5Jf/BrGL1YZ/VuukVMiFizGqkxYi5SI8v83tSArsvHR+tD2fKHHd4bJm1azjaQe5zcoE7GqOiEusTM3scAsWzjbvg5513VY6Vs1j7AKVGRbWPEeo1IvAw0rRxXkaFUl4hY/d7tDxyWPjU2Q2XTaFGX5AlVIRMp6lJWIPhPRQw0aM1Tjse1UTp77jcQqNJmZnaqUiHYFJAAbCwqLOsRz8ky6qIlMxOJSA3GchuQozWE7gSITMSTbbwNkm6xElN/fqkSHrEQcOq5AKn/YdHxcsnJUHJdEarYtyNEoSSXreZ5ETElF6klEjyFEqjFDViLWERUgPWcbsd+0NIQnEXcoohK1jHXLpcbO3NRiTH7NI0rEiiHame2bP88E7Mw0uQiC/OK56jHJv5N/vqDxzEddS7S93ZKTN0NKqY5XInpYgOzMVa+BogZZ9u/mFFNlC2fATpUtE07DxFSTZJsyO7BqjmpUTLbJf6ORkoSh2AxC1dZppRJR+vwbyeYsVJpXOwcBqTCmLYX3++IsDw2KcjSB6qpBWQinUi83MtcQmYjFSsTAIr8w1/ZbkImYhqREdJtJJ2cijjajWioRRbFKMTGVb1p1e0+OJIVlrnVaym2zGeMHcYJ2Wes0YmwN3CpHk6RABRYEEtkSGc/j85mIvdzPUspEjHwmokceSZKiXdhULmWDWmcilrQzB75YxRSeRNyhGCbHCFUXTqoyAfY3JmBnVhbGVFNYCiUi5RBOoFhF1fZajURkX4ffJyI+Bk2RiAXKIjERt803UxSrNJXJ6bEzQHbmqpMEVSbiOMrhqq9BRbYBdmO8/F6MHNcY45AtsvE4//3Kje6iLGZy9y1ALn+oh5QwyUSMGpgEF2Uvj6N4JztzTzouH1fhoUM2fxqeF+R/bgqdnVnMMxvJRCQ765ASUTQp22QiyuqbgmUft5M6JxFTtZUwCMTk2+i5xHwQikxESQXYd52JKCkHW4XFKqnVecgIN5XCMlNguS6MiVNFfhx/r9uBnRJRZT1PQ0YAp75YxWMI6mKV7DqwvbaUkQqCHHcfgbBT4EnEHYos8LweJaJKfQOMR3bZQFbsqRZjVRWWWTszkZHjvFI70Hs72tzJvlZRyhTZmQFJVdRUJqKmWMWW8I1E8UNxZpsvVvEwwdjtzHHxhsok2pmHCb9cqYWV8iF77Ohx2T9fVaiUiFVVRf1om9iZVeRoxdcgsgOHSMSm1eZFpRZhGAgbqO29S9iZi4pV/CaRRwHE/KmmjeU8iZj/WavBuUaiLFYhss3WzqxYOANi97wZO3MBISC/LtNiFZoPlrQzhw3YmVn7NRWQSGMyEZmBneVyEKeZElHTOr3pWomYQqECy1SexkrEXCZiXokIUm96EtFjCDnlYFBwbVmWFmlV2ZJyuIlN2J2ASiTiBz7wATz/+c/HwsICFhYWcNttt+Ev//Ivxc83Nzdx++23Y8+ePZibm8Nb3/pWHDt2LPcchw8fxpvf/GbMzMxg//79+KVf+iVEjm9gFxJU6raqYfI0ge9qFB3OMxFJ9VDUEF1RYRkPkW1NWtwIKmviOCHeRXZm+W801TxVRGZWzVhTtzPzzCy/c+RhgPU+u89UJhEVypemNlPkvzG8SQRkC1+7NshMsTe8QdOkEpH+xvBrqLzxIO5bBe/TBGzao0U41d7bvsamTeNjk2S2KuvR9jazNRglETsNHo/H+Ye657r5TMRiYrKRpnqyKw8tdBORHWhjZ06khXNBJiLZSR23M8dJilagaEal4zQmEflmXolir91QO3PWYjxqZw6tiY5Esn2rlIixcyViIhMuBcfVtiARde3MXonooYKyWCWXiWinRFRHKngloi0qkYgHDx7Ev/pX/wp333037rrrLrz2ta/Fj/7oj+K+++4DAPzCL/wCPvaxj+FP/uRP8KUvfQlHjhzBW97yFvH7cRzjzW9+M/r9Pr72ta/h93//9/F7v/d7eM973lPPUXkog/czss3u+fqKjEVAVoK5v1EDo8QYICss7Z5ztJ2ZfT9tkERUZfqMZWdWtGnTArOxTMQCZVFV9Y3Kztykosjj/McGJyqq7jQq7cxNFqskxdd31deRqXxHx/eqRUhVkCnb8t+vXqxSnKMKNFuSkAiyrR6VZ7+ggIQg7L+NFKsU37vCiurBzM6cEQzifPaTeo8CxAVqWGCciBsUPp/8vSbGDLIrp0MkIikTbezMA7moo8jO3CCJ2C5RIppmIop7VqrIROTHGXIVoEtlUc4uripWsbwfd5TkKBF47pWIOcJFJp/5a2rbtDMnKboBZSIOfVZciRj4YhWPIeTblIvyRhPjcxBgc8IwKLhWpedvIkd1p6Bd/pBR/PAP/3Du3//iX/wLfOADH8DXv/51HDx4EB/84Afx4Q9/GK997WsBAB/60Idwww034Otf/zpuvfVWfPrTn8b999+Pz372szhw4ABuvvlm/Pqv/zp++Zd/Gb/6q7+Kbrdb9Gc9LKDana1uZ1YvxloNLVpoPVKUy1hVQTjSztxk+x69BuVCLP9zG6gWd019VgRVwYv8M1OIfLMJqys9zm9s8mKVJOU77QXjiQ4DhZ25SVt9dn2rCkNSqzGsrzgm+W80k4nIvg6TbdULSNTk6CRap4dF9OK9tbzf0OfVKyR9m7P/FhWrAOzz6qNCsUo8So62Gzwej/MPkWKuW3WeIXKcNRs0zSgRFaRfBTuz3B5cZGcO+PcaUSKqVEBUGGOqROTvj7okISMZAG4RLnBy14Hc+xsUZSLatTMP4gTtoKT8oQElYlkenU07c6xRIpLa0isRPYaRJGnWwF6UNxrEVtdWLlJB0c7c8SSiMcbORIzjGB/5yEewtraG2267DXfffTcGgwFe//rXi8dcf/31OHToEO644w4AwB133IHnPe95OHDggHjMm970JqysrAg14zC2trawsrKS+89DDZpwKxvrKubRFdqnmipWUeT8AdXJ0Wwxzv6dWYirvkp7qKyJVVuM5eccJkg6DS/IipRFVc8XVb6Zz0T0sMH6IFsoVVkMqoP8m1tgRsnodTX8Oqzamcn229aM700o9hSkVFUlYlasoibbmihJoHNmpBSKH2ZdxSry32jSzqx2PNi9hiI7c5PXlcf5hzgunutmimy751O5eIDtUayS8BKUwCoTMVEvnAFRrJJYPGcVxKlC2QZISkRDEjGiYhWFuk0i2wA4LVeR25mRy0TMGmStlIg6wi1oLhMxn2E5molo084cxVJZTHtYicj+HSaeRPTII4qlc1wmsqXrwMY5qFQ2AvlMRD/fMEJlEvGee+7B3Nwcer0efuZnfgYf/ehHceONN+Lo0aPodrtYWlrKPf7AgQM4evQoAODo0aM5ApF+Tj8rwnvf+14sLi6K/y677LKqL/2CgHJyX9U+ZaJEbCgTsUg5VDn/Zug5J2FnVjVpj2MjVBWrUDB4UwNkESlQ1fapalr1mYgeNtjoZ4uJKsRzVjJVrJZzHevA/oY6XqIKgTOINM/XoJ00Vij2xo9AUN+3GrWfD9+P+SBvr7BUk4hNqs2LilUA+X5sa2cePa5OwxEcHucXlNdWRTvzliZvNCtWsX6Z1hAk4pBykOzNqSHZBlCZQAHJxRG0uEXasRIsSTQFL6JYxdDOzMtMBCEwbPsN8kpEl+UqcZJmFslglGxjuW3mz8fszKpiFf6cgft25ihOJUVk8XGZKxETdBWEb0DH6ElEjyGkkXROyKrssdqZRaNq/odEjgfuc1R3CiqTiM95znPwne98B3feeSd+9md/Fu985ztx//331/nacnj3u9+N5eVl8d9TTz3l7G/tBCgnVudxO7NqwQJk5J+t8mEwZAubpJ15eAHvolil0zDhlgXvF5CIlduZfSaiR3XIu/dVLPBEZm+HdubCsbDC9UDvQ6c9uU0iQKfYq0oiqu3MjSpHVY3eFS3VW7Ga6Og0OB7SPFtlP7edh+syEf0mkUcR1HEwVR0PRGSPKvayccj9AjNTIg6NyWT7tVEiJppGUkAsnlPHduZIU2oQiGIVs/d2ECdZbiCgzA4khaBLUiCKVYo9qVjFSomYGJQ/NGBnVharZK/BdB4/yKkr8+3MQZt9doEnET2GkFNHKzMRLa4tnSo7l4no5xsmqJSJCADdbhfXXHMNAOCWW27BN7/5TfyH//Af8La3vQ39fh9nz57NqRGPHTuGiy66CABw0UUX4Rvf+Ebu+ai9mR4zjF6vh16vV/gzj1EkZcRUjS2XjSsRNe3M1oqOobKOSdiZy4pVqlhnigpN5L/RnJ159HVUVQANf1YEn4noYYo0TUU7M2CvrkuSVJzTI7b6ibQz1xP+L6xhRcVZDW6sKAvBKmYHqvIrgepKpSpQj/EOiI5Wc+OhKwJHtjP7TEQPHWhMGCXo8z83hcjl1Kh8m4h2EGRakF+mJUKxZ074DUrszEGrGTtzoslEpFzG0JhElAhJoKDFOGsQBtwrEQvfX7lYxfCcoTmGunU6s1xuObYzMzJTo7AMzMnROEnRDRTqypYnET2KEcvq6MJ25hg2w3GkIsalf7cROy1i2kkYOxORkCQJtra2cMstt6DT6eBzn/uc+NlDDz2Ew4cP47bbbgMA3Hbbbbjnnntw/Phx8ZjPfOYzWFhYwI033ljXS7qgUbcSURe835SNT2X7BaRFi+2EkYL320Qisu9vp2KVsezMQ+8VER9NqTqGMyfZ/1e0Myss9T4T0cMU/ThvK7IlWuRrcfg8DBtUTKkaSQFJlW2ZwQRMvoBEXQjGf2753hKJqLP9Nkn6jigsxyYRJ+cMADSxGRWVo1uCRMwWrO0GMx49zj/EYuOh+NpyERXQrJ15uFiFXxsW9644SdFSNZICCOl7rotVUk07Mx2XoU07ipNM2QYoswPJZuxUiZgkxcU1FdRSbE4itTMryVH3duYyJWIHkfFx5S3a+c8qbDOBUOCLVTyGkFNH55rPKW/UrrQo1zg+PBa2Mot03ysRjVBJifjud78bP/iDP4hDhw7h3Llz+PCHP4wvfvGL+NSnPoXFxUW8613vwi/+4i9i9+7dWFhYwM///M/jtttuw6233goAeOMb34gbb7wR73jHO/C+970PR48exa/8yq/g9ttv92rDmiAWY8ML3TGLVYrszE0pEVULFvl7VTOzSGFJzzOZTEQF4TtOsYrSZtYQiViUiVjxs8qIDp+J6FENm/38pNv2nJHVUCPtzBMgpYrGwioxCLrMW/peE2UCykKwiptEJnbmiWYiVnwNZPvVFeE0kemTxWbkv19VsSVIxIJ2Zr9J5FGEutuZ+7pMxAZzb6FoZ06F7ddchTaIUwSURVhgZw5CUiI6JhHjGGFA9pT8At5eiThkZ1ZkLIpMRIfjIWseVisRQyRWBSQtJNn7pMpEbKBYJUpSTKOAzG7ZZz3mMhHbQ3ZmfoxtXkBTtDnqcWEilknEAjWsTUM4MFSsorAzeyWiOSqRiMePH8dP/uRP4tlnn8Xi4iKe//zn41Of+hTe8IY3AAB+8zd/E2EY4q1vfSu2trbwpje9Cb/9278tfr/VauHjH/84fvZnfxa33XYbZmdn8c53vhO/9mu/Vs9ReRgsWiyfT5Bt6kWm83ZmTbEKHZct30YTRlIiBhVJ1nGgIgTGIWfLiMmmVB3E0dRiZ6bPymcielSE3MwMVCeygQm3M5P6RlMYUsnOrFUiNlkYUw9Bq7MzVyUZqkCtsHRJdDSoRKyLHOWLYvm4WoIU9eO7xyjKGsJtN2H7sZqgbzLuhpSIwUixin07c5yY2ZkRO7Yzq1RF8r9N25mHlW3DG2oS2Qa4tTPnG1/l8gdSS6VWir22Nusxs3FOWonYRmxOjiYpuoqymJC3NbcRM+t9gVrW48IEKRETBAhz15Y9QQ8Mn9PFxSotJN75YIhKJOIHP/hB7c+npqbw/ve/H+9///uVj7n88svxiU98osqf9zBAGTFlP7HSKRGbUYLVXSYAyEpE9vuhmHhWfpnWUFkTM1UkU0YGBaojFVSqTVLxNbXLkhR8ZlXJlkxxoCJv/M6Rhx4b/fwCxVatJY9xKqKriSgEVQ4YUG0MU6l85b8xScVe1ZIpUcakUew1sWGkznocLx9WVuwROg1GVqgU79UVljoloh/fPUahHDOcRAU0N8YLMm1YORjaKxHzxR8F5CiRiBY5i1WQJxGLMxED43bmBG3K2Bu2/AJD7cyp002IHDFRmB0YG9+PB8mQTVtnZ25AiVhoP5eLVUzJ0SiRSMS8EpFIxA4ibEUJpjqeRPRgoM2UBK18/h6/zjpBbOWSGcQJOkGxrV4+r11uOuwk1JaJ6LG9oLSFVV6MaexuDS0ydRa+cUO0acJIHGmzdubi91b+7OwXmezxwxPhppWINNmWP7KqWUUDUayiIEb9zpFHCTaGJt1Vx8EwGFVEN9kiS2NGYclUlXZmRWlR1eerClVT/bhKxI6GbG3muPSN3vZZvpxsm3DrdJni3ea9jZNUvGafiehhikSxoZJF99g935ZG5Rs2uGGZpsW5XUKJaGNnVpFcoG/xdmbHxSqpqiQBsp3ZRomoKB8Bcu9biNRxJqKinVkiMk3nuzZKxM2BayVikhXhFFpJzZWISRIpLdoh/3cniBuJ4fA4f5DwMSNWWI8Buw1GFj2gyhvNri0vSjGDJxF3KOj8HyWm2NeqNj6dfcp1GyQttHR25qoh2rR4JrVfk+MHkQ4qNQdgv4AStrAhElGoVBo6wKJFZtVFrirfTDS3erubRwlGlYj1qGHZ95pTTNE8u7B1uIqd2SA7sBGbds3ZgapGd/acqPScVZAd19DYVbG0RqeW6rSaOw/LilVszkF5519uxm2SxPY4/6BW+bKvtRarNGpnZn8kGF4883/b2plDTSYikTih42ILnRIxe102mYiKtl8gR+a5VhbFcrFKjmyzb2eOEjnrMRi1fRMxGSQiG9cV8grWomZc8zy6IN7K/jGciSgpET2J6CEjszMrFNmAVSFUlKTqcYNUvkHi41MM4UnEHYoyJaK9nVmdLdWUfSrRLJyrFsYMK3CqPs84UO2ky5+d9edV0HIpP2dTJST0suVFZlVLkLKduWF1pcf5i/GViBTrUETeNaeYIoKoKBOxSjuzSuULjFfwZAu1KptvFFQsBCu2MzfXYqw6b1oVCT+TBtkmJsG03hve2AsrjMnyglg+rnbD9yyP8wsqle+4c91CO3ODJVMBkYhDGw9pBTvzIFYo5TjCDiN1WmnfqQsnUZUkAAhalnZmWbE3bEscev4QidNilUj1/krFKnW0GLMnk4tV3GciZq3ech4db2cOzNuZcypUbyP1METMc1oTjRIxtSiEyrUza1S+nsw2gycRdyCSJBX5G8OKmfHtzKOnDKkGXO+KCSWixsJnb5HN237F7nWDJGJZDhgwRrbU0ES46RKSWNiZs2MJK6pvhpu0CV6p4mGKESWiJXmj2pwBmiuYYq9DE+1QYSyMDOzMTZA4ZaqiWu3M2yDrMRuP7Z5PS3Q0SI7GCiViu8I5SPesMMgTQj7z1kMHpcrXQSZi1blLJSjszKhgZ1Zm9nGEnSkATAnmctygxX6CYCSbkRqiQwslosjYG1Y1Dn2vhcSxEjEtVnqKYhULO3OSoB2U27TbiBtZcxUqEfnralmUWoRxHwCQIhj9vFpeieihgEqJKI1jNiRivuBHRWYn/jw0hCcRdyBktYbKFmavAlPbmQWJ6HhXrKikg1CVHB1uuQylMpOmoMqVkhdSdQTUs+dk/x40lYlYcGxVd/MHQs0zdEytZuz0Huc/hpWItsRYVkAyWdsvXTt1qbJ1xVlNbjyoW4yrEWNamzb/E40qLBXlWbZKRF1umxgPG5gEq+7JVQhaWT0vbzrRZ+c3iTyKkM2f8t+v3M6szRtFpeesgjQlJWJxi7FVJmKJ9bfFScQe+k4Ve0lEhEBBLqPlcQ3KlIjS++ZaWRQlKUJS7MkkoqRENC5WkZWIGnK0mUxE6T0usGmzYhXDJ0sYiZiEndEmbcpE5MUqHh6ElJc96ZWINiVTSXZO6zIRvfPBCJ5E3P7i8fgAAQAASURBVIGQJ9t1Z0sV2fhox9bl5AOQmiB1qhLL637Yzhw0GLZPKAunr/J6aIdyxM5Maqmm2pmFejT7XrabX00FNtqKyz67NG3GZuRx/mJYiWh9DsZq8q7V4NgR6cbCCq8j0tqZq1mJq0Cdici+VlUiFpGjTdrPIyU5Wr9aahKk70jJEP9nFTvz8MZX02VgHucXsvnTkOvGwbVVdTOjEjiZNpyJmBJJZZWJmKKraiRFZmfuInKq2CPF0EhJAuRiFYtMxECTiRjIJKJbZVGsKlaRlE3GmYixxm4pPX8bTWUiEuFSnIloSqgHEctEjMMCwleQiLHPovPIQWQijpCIkhIxtiARk1Q9bkjXVpSkjRasnq/wJOIOhDzZVjbWWd5PI41SpSklYmadGv2ZUJVUJEdpwkhv13awMwdBkOWbWb6eLcVEuNPwgqyoDKeqhW/Yek4Yp4DG48LC+EpEdTZsk7ZLlWJPfh02Q0Zm+51sAYmqnbm6EpEUe0XvU/5vuoQgOmqIYkjTVDm+A9l714giVnFPptdgcx8lVY0qgsMrAzyKoFQvV2xn1kUF0HnexJghilNaxQoc0+xAYChnb6jUAgBa3WkAQC8YuCURKd+sQIkYkhLR0M6cKyApJNtCAOwDayF1ely5duYCxV5oYWceJBqlFCA+/xCJc9VenhyVSUT2utqIzMf4hGUipoXHxJ8v8HZmjzyIREyHxwxZ8WuZiagcNyQlItBMrvT5Dk8i7kDolYjsq7WdOVEvxppSImrtzBVt2lk7c5B77iY3IHTWxKqLzL4iE7FJ9Q2QleHImVmZQsDufBGqohpt3x4XFtaHlIhVW+q3i+13WH0DVFPgCNtvW61EbCQTUaVErKjyNGmdbkK9XKpErNCkDQC9YYIBmdo8amAxplIihhUIWhUxSu4Hn4noUYSyRvfK7cyaMaMREpGUiIp23jA1XzgPEik/sIhwa5ESceCUmBKZiAUN0VQgY0qO5m2/BccESO3IsYjscIFcO3NBsYpVO3Oc6j8rqYRkc+BYiZgjEUeLVdoWhTGUiZgUKhEpE9EXq3jkkSSKYpUgQMyJxdQi2iFKUrRLMxFj/lh/LpbBk4g7EDkScSh7onpjXbFCBMgssxMtVhmznbk7QTuzahIMVLMmypPAYRKx02D5AyDbmQuUiGNaz8XzScS2z0X00GF40m276621Mzd4balywABJLWNFTBFBryZHJ5odWIFsA8rszM2psssiK2KLwVDerNPZmRs5LkWxShWyRc5ElNFkUYzH+QehRGwNE9nVxmOdyrfJTdiAk4TBcC4ekVQWSkSmvtE0/nJ1Yg8Dp2IAaugdIQQg2ZktilW0mYhAjsSbpBLRuljFwM7cakSJmCiUiBk5a3p9BTolYou3PSNyLkbxOM8QqzceUn6tpbGNElFSMI+MrZlNHwAGkZ9zlMGTiDsQxJ6HQUFWUcWJVaTJRCSiyvUOkmohJn9v3HbmSdiZdcdVRd0k34RHMhHD5kL3AVmpMvoabJWIkUJVJBMfNotxjwsPw5mIdY6DEyFvCu3H9mMhveZJN5KWqYpsX4M267HihloVlCkRbY5Lvs9O3M6s/Lzsib8sx7fY8j2IfUaRxyiy+W6xetl+w1xNInYaVMUGolilmES0K1bRNJICeRLRqRJRbWcW7cyG5GhesVdQQAJk1t9gQpmIAa0rzBV7UZxm7cwaO3O7gWIVRo4WFasQ6RebtzNzEjEpLMEhe7RXInrkQSrDtGDjgYhFm2KVnIJ5RInIMxH59edFKeXwJOIOhCpXCnDTzkyTrSbyOQC9ndl2npDZmdkxZBPPqq/SHnVbEymbMghGF89NWi6B7H2UJ/hVLdqZlXRYXZv9v89E9NBhfViJWNHOXEzeNaeYEq+jIB+2UjtzpM56rNqmXgVxXHyNixiOGu3MVZVKVUBkptJyWfGzqmvjqSrofqsqVrE5B7cUERzyOemHd49h0Dk4kv9dMfNUV6zSbSj/G5DszMNqNGFntilWSdANGImjJREdZyJCVZIAIGhlSkSTzQKmRNQQo0CuRdhpO3Osyg6UlIiGpyHLetSQo/T5B+6LVVg7c8Fx8XOyFZi3MwfczlysRGSfXxc+E9EjjzRSjxn0vcAi2iGvyi7ORBR2Zi9KKYUnEXcg6MQvWOdWsscCeltYZmduqJ25JjtzmqZi15kWmVXt3uMg1hACVRaZNLHotkJhzybQ59dUYKwI3pcm+FXyzeTXO1z+EARB4+Sox/mJzeF2Zls7c6JWtjWqROTXw3BRB1CNpM+Oa7sqEaup64bHdxlNjhmZElFRGlJBiVhEcgBShmADY3ydduayTETAZxR5jIIcDcoc1Yo52UUb5k1F9wAZSTiSiVjBzhzp1DeAlIno1k6aJGpCIJRLSAw+MtbOrFHsAUIJ6NrOHMuKvYJ25paFYq8061EiOlyvuaI4RhiQEmC0nblt0c5MSsS08PzLlIieRPTIIVUUq0jfs21nFkpfJYnI7cz+XCyFJxF3IEyUiNbNuJRVpSlWcX1DKyKkCONY+IDsGGgt1CiJqLEmVllkqhQd8vPZWomroigTcRySA1CVPzRr0/Y4PzHSzmxtjy0fg5qw1NOYUaQcrLIRQtkvOnJ0ou3Mgf34DujtzE0qEVW5bVXUq30+YVaSiA22hKvtzFUU9GRnLs5EtH0+jwsDKofCuMUqRfOnXqeZuS6QKRHDITVaZvu1sPDJOWDtydmZkaitiYGUR2ZynUc5RVGJnRmpU3I0X0AivRa5WMXGzqzLepTUjf3IPGuxCnJZc7lilUzhaXpcraTP/0dNInaCyNuZPXIgq3I6vJkCaTPCqp05UZP0RCKSndmvJ0vhScQdiFhHtlVU2unszD1h8XAtrWdfi46LOKoqFj4gO65M+VfxRVZAtnCup1hFTII7o4MuTbSbUiJm6tHse1UWmPLrLSLHSWnkF5keOgy3M9uqtWjBOqyGBSZU1FGgyhZN9Rbzn4FGidhkI2l5JqJto7vazpwVPE0wE3GM4qyiezGQqc2bzOYcyaOrYtOOi8kb+Vxo6r7lcf5AFQcTVry+dZmITeV/A5IScZggIwsfLOzMZUrEdtbO7DYTUaNEbMlKxPLPrB8l+mNiTwrAfdZeHMdoB/piFeN2ZpnwLWlnBuCUHI1lhVdOiZgpB42LVVKNEpE/X8dxi7bHeQhOZBdtPKRUtmKRichU2QqSfkSJ6M/FMngScQdCS0pVXBAONIUCNNly3aqlsk7J37PZlZN3GUipElZUvIyDzH6uU1iaP59OidhkbhtQrFSp0rRa9FnJaJLA8Th/MaxEtC73UahegOqFQVWgLZmqsKEyEFmEkyZH9e3Mti9B1egOVCPwqkJpuazQ6G1qZx40QHRk43v++8ICbzEJp5y54c0v+Vzwm0Qew4gUGyrZnNDu+XTXV1PRPYBsZ1YUq9iUCSQpetBlIk4BYEpEl8eWaggB2c5sqkQUij2lnTl7TrfFKjLZVlCsgtR4TTGQlYjDn730/KR83HQo3qA2bQCFxSotxMZlV+1Ec/7x73UQNXLf8jh/oCtWEd+zyETUKpil1nHAKxFN4EnEHQi95Y59tVUiikVmgQKn11DYtMo6JX+vSotxGGSL50namYtI3yoKHFKEFjd3Nku26YpVbBaYUZy9R8M5j/R9wC8yPfSgCfdcj00ebHcaVQUZQMOZiAZkplW0A6lvNHbmJsbEjMwc3x7Lnk+9+dVssYrepl0niTjTYef2cImQCwil+dD1UOWcUW1+hWEglOw+E9FjGHVa6gE5E3F00Srmug1kIgo7c3vIztwii675taAtEwCyYotg4FYMoFEiBiHlF6ZGG2B5JaKCRJQs0i5VRXnbb7ES0XQsjJMEnUBzXMJyyT4nl6SvWomYKbZMNysDbmdOC8+/TF3pWozicZ5BU8ZEJGJqMS+IE01cAN+MIBLRi1LK4UnEHYi67bGAvMhU7846VyIqFizy92wOa7iZGajeXj0OVCqVqq8nW4yp7cy2hRJVIexuBUpEq8w2DRkgP6ffOfLQgezM81Ns0mo/DqrtsU2OHUSmFJVMVWln1hVnZaR/kwrLYkurPYmojuFokhxVKUerqLLL7MyzPTbur2+Z785XhcodUIWgFYVghZtf3KLt7UUeQ1BFBVSJuAHkMqbRsbXXUP43oFEikrrNJhMxlomp3ugDJCWiUyVYolMicnI0MMv5W9uK9LZf9qQAmrEzZ39TlYlo9lwD2W6pKVbphe6ViMiRo3I7c0b6mR5XOyUlYsH555WIHiqQylBDIgYWmYhRHKMX6DMRW75YxRieRNyBEAvMmhR7gFohAkjFKo6VD6JYpYBHqpL1WLTAzEoJqr5Ke+iyHqsU4ZgVqzRzgGnBIrNVoe1VZ0uUv++ViB46bAyRiIOKduZiJWJzRAe9bJ162S7aoZwcbbbFuFjZVlWJWNg6XUERXRXqYhX27zQ1/7x0xQ8AMNNl5/bqlnu1lFIFVmGM1x1Xk+egx/kFQdDXXKyiszO7zkRM0xQhiotVhJ3ZgkSM4hRdnWqPl624bmfWFatQJqJpCclaP5KIUUUmomRndnlcqUxiFGYimrczR3GCtu6z4s/ZbUCJmCSqYpWsgMK2nbm4LIYyER2ffx7nHUhlqC9WsZjryIS/op25hQSB4wiEnQJPIu5A6JSIVYtVdO3MvaYyEQ2UiHZlHaOTRXrqJjMRVTlggLwYM39vdYsxIjoGDR1fUbFKu0IOmCi0UJCIPhPRwwS0az8/xSYP9sUqapVKs2SbeqNonLFQe1yNKPaKVdlhBVIK0CuYWxUUm1UxUGUiSv82PbasgGR0Ug1ISsR+c0rEYUUsvd8291EjBb0f3z2GUFpaVGexSkPtzMxyx8f44XZm/u8grWpn1mUi9hspVqmjnXl9K9ZnB0rfbyF1SggkkZQdWGT7DSzamZNUr7Dk7103ZM/nMkaKbNrx8OclZSKaHhcpEYOidnB+nO3A25k9hqAZM6pkIiKRrtUREjH7G64jEHYKPIm4A6EL3a+ywIyTFDQP07YzR4lxyG4VmBSrVGln7hQqEZsbPCINOVqtWIVNQHTtzE2UP6RpmmUiSsc2TuN0EdEqf98rVTx0GLYz25ISWd7sZPNGjSIrLF6GzqbdpMJSqURsVRuXdZsPVZVKVRDHiuOS/m16bGWZiLNcibjWbyITkX1Vkb52Nm1+39Iq6P0C0yMPsfGgsNRXVSIWnYddyfHgMt4hSlKRyxW28wvdIMgIHFMMkiRTIrbVdtJWkGLQ71d4xYYQSkR1YYiJajBNU6z1I0ldqW9nbgVu7czKTESJ+EgM1VL5YhW1nbkTsPN602E+Z8IJlxTDJKJ9O3OLSMTCYpVMiTiI/BzeI4NQXBeQiOJas1AiBjkSsbidGWAkYlOxX+czPIm4A5EtMDWNlBbjtLyDV5SZRcqBNHW7gI5NCmMqFKt02qMEV5NcVKIhBCoVq2gys5okBOT1o6xUaVdoiDZXIvpB36MYaZqKdmYqVrGdJAglYtG1WkFhWxViQ0XTzmyzoUNjYdEYNMXVN1HiVs0BqDfAsrZfu78/EBtFurG1SXK0OOvR5nWUkoj83F5rIBOxzlILUtQUHReR9l4Z4DEMZd5oxbmctlilk52bTgstklTkcqntzGZ/P+EiAL2deSp7fLRp/4INQdllRdZEOT9ws0RdtzlIkKSQChL0xSodxE7vXaSwTBBmYZxAzgKcGhIdzM5Mx1VEtvJj4pmITpWI/DWPlFrwz69tURgjMhGLSGxhZ47Rj91vfnmcR6BG9wK1MZ2XNtEOORJRkYkIsLHFzzfK4UnEHQhdble1vCyJRCx4TnnS73RiVVDSQahk4Ssg2+iePwklYm3FKgNSIk5YLSW9ZlklQO+xFYmosVsC1RqfPS4syGPTwjSbPFgXq2hakZu8tiLNhkqVsVCQowUEzpSkaHYa4o7yTERrQmCbFOGUZSLKjynDlqbkDJhMscqwOaAKiZjZtCef5etx/qDs2rJuZ9bYmeVrzuVcN5JJxCHCTdiZDZWIFKWgzQ+UiJ2o745E1NmZaRAJkZbeZ9Z4VENpO7NU2OFSiZiosh7lf5sqEZMUXd1nNZSJ6PKenEaKz6uCnbllYGf2SkSPEeiUiLxkypSgBzISMQ1a+ZxPYEiJ6HbjYafAk4g7EKpJFVBxgSkRM0WLsRyJ6PCGJlQPddmZC0L3J2FnTjQ27cz6a/58umKVJtU38nsoj9VVlIhCKeWLVTwqYl2yd85ztZbtTqPOzizGoAbUsIlWiWg/FtLEvVNwXL12KEiiMoXIuBBq81axYs9Wabx9Miz1aimb11GmRMyKVRpUIgbFx2VlZ+bnli6Gw2ciegwju7by14OY69pmImqur3YrFIS207bfJLO0hq1hOzNX7Bmqb0RUhSDcipRgLZF7lwy2qrxkI5BiqJBElOzMWyUWXVJZT7f4Z1Bk+wXyJKLDDWayM6sUe4A50REnUrGK1s7svlhF5NENq8AkhafpkNwRJGKRnT6zR/tMRI8ciKAvUC9TLEJgQSKKgp/Cayv7G20k3tlmAE8i7kCIjD0N2WbXYsx3RAO1Wk5MrBzeAHQWPkH+jV2sYp9BOC4EMaFRN9kQE32TgPoGbtTyS5bPReIHbCb3usw29px+kemhB1mZu+1QXPO2hF9mq98e9tjCDZUqanMN2RYEAab4WDIpJWKWb2b3fIOC3FtCq0I2a1WoMizDMBAEbV0kIln11/ux04xiQH1PFgStxcLdZyJ6VIHq2mpVmBMC5ddXlgHubiyM4gStQGFn5jbrAGbXAo2pXVA7bjHhFgeMcEsG7pSIGSFQYNOV7MxlFt013jw/FZLtV5GJKCvcnNqZy5WIgWH5QyRnIhbambmVuAklYszOmWQ4w5K/ry3ERtdXmqZo8+MPC5WI7Hu9IELf4XXlcf5BXDdFxSpE+lnZmTkxXlhaFEjjUOxVsQbwJOIOhK7tt4qVdCCUjerTRUysHCpVVCHugLzrbP58fT5AdCesRIw1hECmHDV/Pp0SUWQiNm1nDmUSkRM4Fh+Wrj0W8ItMj3JscCXidKdVuaWcyPeiMahK63hVmKnNzZ9vUKL0pVxE1ySiqp25XVGJOEjUmw9VVPlVESmOC7AnM0uViNzOHCWpc1WHksCpoAIzUdD7jCKPYag2zcMKm5Vpml0zqriArlQk6AqynXm4eTgkK6mxEpEfT0kJSRyy76cOMxFJ2aYrSQiRlJaFkJ15KqRFgV6J2A3c2pkzm7baHpkaZv0NYqmdWaNEbDeoRExGlIhZJqLJ9SW3gxcqEaXnjyP3CnqP8wg0zhVuPLDrzVSJmKYpwrQkAoGuLyRic91DDU8i7kDoyLZqVjf9pArILEguFy16Cx9/TJVilRyJmP9bTUBfklBlMaZRdDRIdMjvoTy/r3QOGioR/SLTQwUiEWe6rew6sLUza9t+m7PUZ2UCuvIse6Wvaoyf5uP7xqSUiFJJgqm6Lk1TiRzdHhmWhWVnlgQpje/KTMRuNtkmxY4rRCX2c5v7sY5E9HEVHiokig0VGgdTizFDnr+qlYhsLHS7YS439BZbZENDJSJdM9pMREgkokMlIqmKypSIZbEZZGfOlIjldmanSkQqfxhW7MmfnSnpmyT6wpgRO7PDMV7Ojyt4De0gMhrjIynnMdS0gwNAErmz03uch4gN7MyG15ZMZisjEGiTJoh9xr4BPIm4A0GLkaKFU5XJve75CLSgcT2xAhQ27Qoh+cLqJtuZKwb4jwPVwhmQST/7dmadLawZO7OkRJTtzBXyrSKNuhbwi0yPchABxpSInHS2tTNrCk2aLH/QqZfHKc+Sm+plTHU5idh3rUQsfn/l6970/Y15KylQTLg1VaySpqm2PEuUxhiein3N+E5/g5SjrhuaBamuatO22dTTxHD4uAoPFZR5oxXGDFmtprq+qLDOZYssUyIWK3ACy3ZmUmN36fmK7KQAEqFEdEjikGJIo0RsGWQiUr4xlYuoMxGlrD2XSsRYoUQMAqQI6EFGz8WUiBq1lKQCBNzmFGdt2sMttvS+mikRI4m8KbYzZ8+fxIPRn3tcsAj5dRNoGt1hGhWQlFxbgKRE9MUqJvAk4g6ETqVSJWx6oFFREJqYWGXHNfqzSu3MBbaVKtmK4yLRLDCrFKuIxVhBQH2zxSqjfxeollU0KFCNyvCLTI8yEIk41WlVbvMWRR2aJvVGogI0Y4bI2KtxjBeZiC6tU5A3VIpLEgDz45I/hyKbdthQJqL89MURI3ZKRF17LIHUiGT7cwWVTbtdgaA1yURsYvPL4/yCLm9UPMZUiSiNbyqlbzPRPQla4K95aPFMGYktw3bmzM5MmYgKEpEKVxogEYtURXTjCoK0lBij0qheqFdXCjszIrfRDpqsR1LxmRarRHGCTqCxMweUicge41KJqGzTtmxnjuNUnH9Bp8jOnB1nGnkS0UOCKGMquLYsN1RyCm/VmCFtZnhnWzk8ibgDoVO2VSkOyci2ySoRdS3GVQpjssVY9nyTsDOr2gUBWaVSry2sCZm2PLkIcsUq9mRLZmf2mYge1bDBCZWZbquyctXkWp10JuI45VlKO/M2UiIaK/akRWPRuNGuSCTbQiYHdbZq08+rTIkIALO8XMW1nVlYJVvFpK/NtSDuWx2d5dtP6j3yUBHZ8jzRdsxoh0GOhJQh7MzOMxFVSkT2b1M7s1ALl9iZU/p+1Ld8teYQJQmlduYSJSInEbtEthUVkADN2ZlFscro2CW+Z3gSRkmatTNr7MytRpSI3M6syUQ0uX9GSSIyOVtFSsQwFM3WicPzz+P8QzZm6JSI5k31ZKsPvBKxFngScQdCa/utoL4QKhVtJiInEV22M9Nx6YpVKtincpmIE7Azq3bS5ddjs3gSmVmagPomqutVGZatHCFgqCoqOQd9JqJHGYSdWcpEtJ0kRJqMvVaDailVmYD8OqwiK2I1KQlkxSpO85eg3gCTxwzTsUte3HQKSF8aH11PFOV7kj4T0Y5E1CkRZzjpu+5YiUjvnWqMt7pvDYjIHl0stH1chYcCqo2HnJ3ZkqDXXVvdRtqZJcXMkAqMbH2hofqGxsFOiQKHlIhB7E6JGOpKEnJ25pJMRLIzl6mKpHZmt8Uq9FmplYjmmYhyHmY5iej0nizy6IZeh7CJR1jZLFcOxlImYmGxCjKi0isRPWQIlWGRypeUiImpnTnRN59Lf6eN2DsfDOBJxB0IrRKxQmOdbuFMaDITUadEtOGQBgVlAk1Z3GToyNFKxSoDnRKx+WKV4cPKEwJmr6O0nbnB4/I4P7HRZ+fQlJSJWFWJWKxsa47oSAw2HkznP3IjqSouQBSrOFciclJquCShghJRJriKxtYmFEVAfowrLM+yPBe3SlSjgKxEdEsiqjbAqijo6RwsUiI2WYLjcX5BRSLKGyx1NZ8Dkp3Z4bgRa9qZgzaVWsSGpRYJgLTUziyUiA5JxEBrZ5bamUuUiDSuaW2/QKZEDCIkqcN7s1DsFSgR6VhjQ6IjTiTCt4hszUgOwO15KMiZkXKfjMg8s15O+uWz6BTnH/8M09grET0k8HOwMBNxDDtzoNp4aGcRCAM/3yiFJxF3IETGXk3FKkVk2zCyBZn7TMRCsq3KoqWoWIUykJtsZ9YQAi3RIlulWKUoE5HUN81ZLofVUjlCwPB9LstEJALHLzI9VFiX7Mx0vthOErJilaK8Wf6YJtp+NZmItnZmeWGlIumnmmpnjovHQnnjyDg7MNJvPBBR4FKhAuQbwAvHeMuNq4zoKJhUczRlZxYblsN25gqbelsDdSZikypfj/MHSZIK18iwyreK44HmTvq5rnsSMUoSJYkYSu3MJmN8FDNCMqSMRWWTMVOIhQ4zEVMqSSgpVimz6FKxSmlJAv8+KRadqc41SkRby2W+WKXI+kvvE3u+MsJ1HGR25uFilYzIPLteTvpFUiYiFEpEIoJ9sYqHDH2xSjv3mDJESSps9eqNB3Z+9oKBKF/1UMOTiDsQOiViFWXbwKCdWRSruNydVdhjAQfFKg1yUbQo1isRzZ9Pl5nVZG4bnWK6fDNzJaK++MFnInqUYVNuZ67Qes4evz2UiLpiFduxUN5QUJH0RCK6zF8CNKqiMLAujMmag0sKEhpSIgaBYoy3bKs3UUvNNmRnVpK+FcZj3eaXyPL1m0QeEuSxYFSJWPw4HUxKixrJRIxTtITKLv9aAolss23GBaAkcVL+/SBxaWeuJxORlIja7EAgl4kIuPvMiBwtLlZhn19gTHQkJXbm7H0CHCsRVZ9XmKlhz5iQiImsriz+rFL+faft4B7nH7jKsOjaEmOj4bVlVKzCx8EuBn6+YQBPIu5AxIqgaUDK/KuSl6VpZxZ2Zoc3NKGwLLAzh5YLTEAiEdtFJGJzgwetswpJ30rFKupMxLZYsLon21RKxCo2o6igBEeGz0T00CFJUjx2cg0Az0SsqMhVZcDJ32uknTmtb6NoUFL8AUh2ZsdKxEyVXaBGs1TsCfWyghAQOb7OScSsrKEIdKymY7wRiciViKuNKRFVJKL5+E7PpVMi+rgKDxn5vNH8ORgE2cZDndeWyER0OBayxa5CicjJlhYSo2iHKE4yFRigXDxTVl3o0E5KdubiTET2voZGmYi8pCMtIQSGSERXSkRh+y0oVoFoZzbPsDSxM1OxjsvzEEkJiYgYm4Ny0jdOZDu9XomYeiWihwTaeCi2M/NrwbD5fBAnkspXkYnIx8EeBm4b3XcIPIm4A2GiRLSZi5c1dwJAjy8y3SoR2VedYq9Ki7GsKqK1a7PtzPUSEzpFR5PZUln5Q/FrACwIAQ25APhMRA81Hj+5hr/1u3fgT7/1DADgsl0zle2ROnVbvkHY3XmYpqm+ZMpy4SxbNlSqPVGs4trOrLNpWyssS+zMYuOrGYt20TEB2edlOiaLTSJdJmJjSkR9sYrp5SVP1gs3v7yd2aMApXmjlhsq/W1jZ06z9uWRduZMiWZkZ07SrICk4PkEiERM3JGI2mKVgEjEtPQ+QzEN7XSgfj5AqN6mQvZ4V+uTVEOOUrGKqRJxECdoBxrLZZi3cLrNRFQoIgWJyP52mRoxkopVygjf1Lcze0igvMNAo14GzJWIZS31aE8BYJmIfr5RDk8i7kDEmtyuKjv6A5tilQaUiLoyARvyryhnrwrJOi6IHNUVxtgclyARCwPquS2sAcUekaPDFsmwColYcg76TEQPFf7ZR+/BXU+ewUy3hV958w145/ddUblgSEtySeemy/NQfuo6xsKBRHIVkZLAJJSIdZCI+o0H2vhyrUSMNcQzUEGJSAUkunZmoURsqlhlKI9OqEbt8iuBkkxEP757SIhLSMRsLDR7Pl0UDKGR6J5cQ+8Qidgaw87c6gIF80wACPjiueWyWEUoB9XFKi0k2CzZ2CE7c1jazsy+3wtcKxHpsyo6LjvLZf7z0tmZ3WcihiqSlv+bim3OrOnVg7GcRddWKGFbWSZik5n0HtsbYuOhQDmYWioR2bVlVsbUQ7+RdfL5DsX2jcf5jGyhO/ozkZdl1c5cHJ4uo6mJFVBfdmCRnTmYgJ1Z1UgKVMt6NAqob8DOnJ03RflxAaIktbczK87BKhlcHhcGzvL2wN98281403MvApCNZbZ2Zl1TfRWFbRXI166uZMp0vVSm2AOAqW4z7cxaVXZFO7PKmkhjST9KkKapGPvrhrgfK97f0JIgM7FcznEScX3SdmbDy4CI3FYYFM4zaCPKK809ZORIRN0mrCVBP/FMRLmdecgi2xJ2ZsN25jhBNyixkgIIO+6ViIFBsUqIBFsl2btr/D7USui4SgiBwG2xSirszKPHRURHUIXoKDouoWx0m/MIAGGisH628krEsnKVta2otJ05aGfW80GcKqOLPC4s6OzM9L0AZtcA25wpyVElJWIQYdUrEUvhlYg7EEmqVmBUsf1mdmYTJaLDnBgqVimaLI7RzpwvVuF/q8HFirC7FRxXFftxplRRB9Q3cXxCPVjYZGuZ26YhJNnf8EoVj2IQMTXfyybCVQuGsqgINUEv/00X0OWAAfbqZUEiajJvp/hYstmUaq+ASGpZqkez8ad4zJCV2i6zb3TqSvn7tpZLrRKRk75rDdmZVcUqxq24A/0xeSWiRxFEKZ2qtMhynDch6DM7s8tMxEStRMzZmcufq1TZRs9LSsTUPYlYWqxS8t5STIMoailrZw7cEm7ZcakVlsbFKnFZsQrZmSkT0WWxiuLzkjIRAeDMul6J+MzZDSkTUa9EbCN2HjHicR5BZ2cmEjExm+cYjYVtUiIOvBLRAJ5E3IHQWe5onWjXzlxerNKEElEUq+gUllbk6OiCld6zJtX0ia4koUqxykA9EZYLSFxbBkR+nEa1FRsO0jpCEpAaTv2g7zGEIiU1nX8DS7JPr0TMnt8lSW9q4TNW+dJ1qlk4TzemRNTYxW0LYwrGdxkyYeVWVaRWVwLS52U4dpm1M7MJ95pjO7OKVM/UlWbvq64MjD2/z0T0GIWuiAmwL9wzyUTsTjgTMWxlxRomY3wUS1ZSle0XmRJRqPscgKyJQWFhCFciBik2+X3mO0+dxQ/+h6/gq4+czD2UxrWAXmuJNbErlIiO7ssGhTHGduZYbpBVk4hE8JURruNAqMCGXwd/DS1BIuqJ52fObJQrEfnf6CByHjHicf6AypOKi1Uy9bIJolhuCddnIvbQd6Zc3knwJOIOhDZXKshIMlMSSbdwJjRh8aCJ4HDbL1AtO7DIujKJdmajMoEqmYiagHrAfe5jlmOoaVo1PgfZ41SLTFJRebubxzD6BXbdqkU8sYYYly9fl4qpcgsff5zlwlmllAOkYhXHCoE41ty7QruNArpvqchRmShoIoZDWQpla2c2sFxSO/NaQ6Tv8NygLTa/zJ5Hd88CvBLRoxg0Fqj2t60VsRZ2ZpdjRo5IGl48S6UWZsUqUiOpIo8OAMIuWzx30i1nG8yCRNQUqwDAIGLk4KfuO4oHnl3Bx//6SO6hVKwSGNuZ2fG7+swCHdFBSkTDwXAgf14mxSpOlYgK1VaYWeqBtNTO/PTpdUHkUoHPyN8STdqxJxE9BMS1VXCNB0PXQhlySkRlGRPfeEAkBFQeangScQdC1wYpf886W8qosc6lxcNAYVnBziwrVWhN3iQZlZiQiIavJ0lSbfC+vNhzvcsS6QgBQeIYTqxKrIlNZj16nF+IChRpVQuGshKS0WsrCIJKxVW2KFMi2i6cM8WwRonYaUaJODBoqre3aSuKBIKgMVURoFYi2h7XloFaaqbH7cyulYiKcTm03CTKSMSCRTh8JqJHMXTRPYD9Jmym8i0+D4GG2pnjGK2Av+bhxS61GAeGJKKhErEltZK6us5M2pkBYDBgr5fGr+WNTB0ZJyk2BjFCJKK5VV2swu3McJuJCM1xEbE4iA0tl3EqCkuKyiRosUMWzkaUiCN25uz6aCEptTMfO7uS/UNJ+BI5HpW2c3tcOKDypEL1smVUQJykaAeGSsRg4J0PBvAk4g4EETO65k6gvjw6IJtYNaHoqMPqBhQXCkzCzqy1n1uWCcjZXtQ+KkMmClzmgAHqdmZALkkwey5hqVcsnKtm3HnsfNB52C64zm0XFVnTbhmZ7Z5EbIVBYRmIrZ3ZpFil13Q7c8Frsc43K7EzA83ct3SbKUA2FjopVmlKiTjczmx5HdDmY5kS0Zkd0eO8RBlBbz1/MiDoKbrHJdGRxBIpM6JElNqZTezMSYJOYEAidqcBsCwwV3PDQBACamsiAGwN2PGT4lAmESkPUSiKgFJVER2/s+PStDMT+dEfmNnEWSai5vMasjO7VCJmxSrFdmaAZRiW2ZmPnTmX/aOkSbsbeDuzR4ZQp/Jt5a+FMkRyS3hJJmIXA29nNoAnEXcgaGKlazEGzK1GJu3MTSg6RDtzzcUqvQnbmXV2N1tyTH7/iybCvXYobJebrlVFGvLZVjmYtTMrFuItv8j0KEZRNl5VZZNOKQfINk5356FYOCvahLMxzOz5MhKxXIm46ZhENIp2sGx0N9n8cqmgL8tEpNdncs6kaWpkZxbFKg1lIg43T9teB0KJ2CnbJPKTeo8MZaVF2bzQ7PlMCPqsRNDhXDeWxiNFqQVrZy5/rvzCWUciMptpFwNnmyoZIaBuHQb0SkTaGJkKpfeojJiCazuzQrEHIOTkRxpHRn9/ILcza+zMkDIRXdvPw5F25ux1tRHjrEaJmCQpTi8bkIihXKzix3kPBso7LFIiErFIJUNlyBVWlbQz9zDw60kDeBJxB0KbiVhJiWhuZ3ZarJKqF5jBWErEAjvzBEjEIkeOraqIFsNBUKwsCoIAM92G8rKIvNE02ZpO7nX5ioBXInqoUdQ+3Baks22xin5DpWklYuFrsG5nLt8kykhEt8VZ9JJ17df1xnDwLF+Hx5WpK1XlD+bnTCS9R70iNQ9HU8UqKmWu7X2rTAFG16vPRPSQoYvuAext9f1Yr4gFMlW2y7luKltfg6HrXGoxNrcz65txAaDVkZSIrklETbEKAET8+KldXiYRaUxbkA+lpJ2ZVIuulEWkRCxSS7XalB+YGI3HcSIXqxS9T1x9lUQA2P3AFdkh7OcaJWKrRIl4cnULKVfWpkGruMFa+hsdb2f2kCCI7MJiFSqZMjtfBnGqzxsFgFa2meKViOXwJOIORLbIHP14ZRWf8WJMY48mbB8lovnzFdndwgrFM+NCp0S0tWnTYrjXDgutjkDWtEq2EFcospESbJWIZU2rIuPOLzI9hhAVKGKrks5lypcmFFNlJKJ1O3OJyhcAphpQIsrXrjaywphEtLAzO5wsRiXnDL0+kwlrTmluUKyyPoidqWLTNFWei7afVVkmYssXZ3kUoHRDpSqZrS1Wca9eTiLZzlycR9dCYjQvHJg0kgKSjc+dnVRfrCKRiBolIlmcF7v82INQQ0yxY2q7JhFTNekXSJ/XqgGJmPu8dEpEACHYe+DqXKRMxFBrZ060SsSnzmygG7CfB4pSFQASieiViB4ZAqFELCpWoUxEUyViatDOzM5Rlono5xtl8CTiDoSpEtE4eN9AqdJEYx1dz7oFplU7M7/xyhNG2SLYlBgxFgrL0Z+JFlnDwaxsMQZkVjfnJQma88a2TCDLV9RbAr3dzUNGmqbZJkiunZkTN7Z25hKLbKsBMru8qIN9tS0gMVEiusxElBf6unuXrYJeZ2cWm18ulYglailqvjYhaPvGJCK30KXugvflc3z43LH9rEh5UmZn9vYiDxmxxp0if990LDTKRGxgwzxJpGtWQyKazOFjuZFUpdgDpEKBvrNNFZ01USYC45iVu5B1+dxmVvZC6sR5OhSVogjIMhGd25n5cWnI0RCJeO06RHHJ5yUV0JBi0ZVDoMVfx8jnFQTiuMoyEZ85u1GeQwfkPitPInoQWipLPbLrrWWoRMy1MxeNQUBGIiISawcPNTyJuAOhL+rI/t92MaYL3s9yYhzuzmrI0SrtzEVKFTlHsqkBJNY0vlrbcQpyHodBdmbnofsahZNQIhouCgeRWq0pP59fZHrIiCX7p7wwHF+JWHwe0rneRKyDStlmWyaQjYM6JSI73o2Bu/wlWZWsy0Q0JWhtYjjcZiLqPy/a8DFZCNJ51QoDJXECMNKX9sNM1C9VII/dw8dmm1G8VXLfyhRlflLvkUFXIghk813bYjq9EpFHILgc47mdN0EwmnOTszOXP1eUpEIJBq0SjGx8Ztl9VaAtSQgCpGAfWAsJ+lFeubfC1YjkoFkgJaJOXclJq3ZKxSr137vSNJUyEdWFMaZ25iSJEAaaY5OIyum2OyVikqRoC/u5+nW0EWN5Y6C8xp4+sy6RiJrzT3o+l/djj/MLRBAWXVuZEtG0nTkxaGcmEtHbmU3gScQdCF3DZRAEYmJlOsE3soV13O/OZtmBGjtzhUxEeZEpL2KakjLrFplCVWSZiWgSuu+aRBxoCBdbm/agTInoMxE9CqBSS8nniw0pVqZum2pA5UvjUtE4CFTPDtSN73RcaerO+lumRLQt69AVOxG6DWT5llkupzrmRKbJJhHA7vOUi7i+5UqJqCZ96VQyb2fWK+g7PhPRowBlmYhVyWxtsUqDdua0aIkWZso2o3bm2KCRFBB2ZpeZiEQIFKmK2GsgNeQAm4M4N0clS/MqH8/m6ClUzwWM2JldHFeSAi1uKy5UWHLlYIhEvHYV0jQFYo2Vfeh7c5xEdKFEjNMULa4cHbEzS6+jFcRI04zkHcYzZzYk9ZeO8KUm7dhp9rLH+QUqTSk8B1uUiWha0CmNhSoFMx+DuvB2ZhN4EnEHgib3RdmBgL3VyKzl0r2dWRSrFGUiWqpvgOL8G3kh3dQuBH0OxeSonT2yTNEByCSi40xEzXlTN9HhMxE9iiBfwzIxJRPbNudMViRRfB6Ka8uh7bdMiWgb7RAZbBKRnRkANvtuxsWyTMSwohJRn4noXlWky4YF5LxJAyViXL5JRKBz0ZUSUR67h99j2wzDsiw6n4noUYQyZXhVJ8fESwRJiThcqgLkFFtGxSpJYkbiSItnZ3ZmoWwrXsAHvXkAwBw2sBnFubGLSMT1LbIz89doQEy1U/a7Lub0UZJo1VJE+rYRY3VTPxZvDqT2WKCY9JXUpHNtdjwuCO04SdEOuMKyrSYzF7vsGlNZmp85u4FpbLF/8PKeQsjFKl6J6MEhNh4Krq1QtDOb25lL25n5mNELvBLRBJ5E3IEoC/+3t7uNtpsOo9FilRpajIFMUSOr21phptR0OUmUoc2wJDuOabaUQSYiEQLu7cxqcsKWRCwqx5DhMxE9iiDb2+XzUD6PbHYbSV3bUpyHIjvQpRJRUzAFZE31pkMhjYO64qxOKxTXrKuMPVmxV1QKJchRyzHDpFjFqZ1ZE1cB2GUibhmQHIS5ntvYCvnaGj51bIlsev9Vm190bnplgIcMMRbWNNc1KVaZasB1k/JMxCRQZ+y1ghSJwXwn10iqs5PyxXM3cGdnLlUi9uYAAHNYx9pW/nVkSkR2LHMd/plqMxHzduaBg+OKE1PFXrmdea0f5UnEomMLAvFZzbXZY12ci5FUQqFTge2aJhKxWIn49JkNTAecYNSRiLKd2SsRPcCUuaQyDAuI7CC0VCKa5MNKmyk+HqscnkTcgTBtrDPlW2jhrMvMaiJsOjZQItoIFVRKFfq3y8ZOgtxwWdg6zV+LsR2nJKAeaLBYRZNXZGs/LlMV2WYselwYIDVsGOTHQ5lEtMk+jcSGioJE5BZSl9dWXKJssyfo+TGVqNtcE6TlhTFVlYjbw86sImmnhBrS3M5spETk5SomYf5VEEvzgmHSl/jSuuzMbWFn9otLjwxxmSrbtljFJhPRIdFBSsQ0KNoxlwtIyseMOEkM7czcSoy+u0xEXbEKAJASMdjE6bU8KSWUiPz+M9um3MDyso4WVyK6IttagS7rkeznaakqfH0rzj4rQN06zT+ruRY7HpMNKFvEcaonfTmBs2uKnaNnC5SIaZrimTMbmBJKxBn1H/TFKh5DKCXo+XnZMs1EjA1U2VKsg1cilsOTiDsQImNPtci0tHhkttTJ5sToyFHbhTOgzpeiY2liF0J+ucVKRLuFc9+gTGDGsUKFoGv1tlWOlpU/tC0JBo8LAyKXc+gclO1vps3nSZKK61VFdM103NuZaV5TXzsze1yZuk0uV3EB+hzqJgTMlIgu7cwlxSqkbLIoVjGzM7Nx3iTMvwpogl10HtL1Zb75xe/FJe3M3s7sIaOs+TybZ5g9n0nmaBNzXSSM9Cq2M2ffS5Ni9ZeMQZyiE5jYmbkS0SGJo2taBQD0FgAwO/Op1a3cj4hEpE0RsvIakYiIESBxMudlZBsRHerWaZNilZwSsdUFFG6DTInIns8NOZoIJaKuWGWJk4hFSsTTa31sDGJMw0CJ6O3MHkOI08x+HLY0dmYrJSI/t4ryRgFJiRh5UYoBPIm4A5GRbYqcmJoJHKCZnBhdsQrda02J0VgiBIYXmbSYbmIXIhdOX5gdyL7aL8bUduaM6GgmE7HovLFdFAoiW9nO7DMRPUYxUNg/mWWWP8ZQ3aQqaZExzVW+m07tzJy8UWbe8mvBtPm8pOGUkGX3uS3qKFUiGh6XbhOD0EQmYplyVLyvJkpEg00igrAzOypW0eWD0sszzqKjrEel0ry5jT2P8wdlBD1dcsYbDxaZiEmazUvqhjYTMZBJxPK/v96PMnWbrp1ZKBHdKXBEdmABIcD+OCkRN3BqLa9sEyQiJ+JmhBKxvJ0ZADqIseFgzhtJaqliJaJUrFKiCl/vRyKHUGvT5p/VbIvbmV0oEWUVWFtt017ip9TZ9T7+3acfwj/8o2+Le8MzZzcAAAem+Ovrzqr/oCARY69E9ADAzsFQo0QMRLGKaTtzlvOpHDda1M7cxyBJrIoXL0R4EnEHomxiFVpOrEwC6uVMRFcXHRFphXZm6+bO7CY1bOMTduYGbmTyHLD4uNwVq7i3M6tDz20DzzNLvUKp4jMRPQqgK7WwJbLL2oOBjER0qfKlU1xFtk1bkn2DiF9bpnZmV0rEMkLAshBsIAi3cjtzE0pEZSZi27xYRWwSbYNiFUH6Fry/Ynw3JP28EtGjCsyje+q3MwPuxg3KREyLMhFzdubya3t1KzKzM/PFcyeI0R+UKxxtkciklIog67JMxFls4PQQibgiilXYezPDrbxKRRGQIwo6iLDmYENFJtuK25QzJWJZscp6P86UUrrWaa4anQ1dKhGl/DhNS/RSj10r3z58Fr/1+UfxF989gnueWQbAmpkB4MAMf33aTESeXxlEPhPRAwAn/TQbD4EoVjHjHXJKRGUmIhsHu0GENPVzjjJ4EnEHQpexB4xTamE2sXKlFhCZiBrbr3mQe3aTGt517rTZczWRiZhTIhbatNnXugLqgSy3zcWESoZWidiyOwfL8s188L5HEQaxmshuV1TsAWpV2XQDKt+ytt8ZSyKTnk+V80ggxZyrCX4Z2Wafo7o9ilXKyNGeRbGKCclBmO1SbIUrElF9XHRt1VUI5jMRPYpQpl6u3M5sYGcGHJKI2kzEjNBJo/Jr+9xmZNjOnKkU48Gm2Qu1QJxKGXtFbb+AUCLOB6Mk4rCdeUbYmU2ViJGTzb0okRqVNZmIbcTlduatWHquciXiDFciOslElJtsNSTiAm9n/sS9z4ofPXFyDQDw1Jl1AMC+KQMSsSXb6b2d2YMrEQMqMyxQIhLxjNioDyGKTYpVSInIxhvvbtPDk4g7EOVKxKrtzOV2ZsDdgow4vSJylCzOSQqjHYmcEnFoMS7szA0oEcvUTVXbBXXtzEKJ6NjOLAgcnVKlpqbVJstwPM4fRCLvT61ENJ0kyKoqla1+pgE7s1DfKDaJpkT7utn1bZIdCExeiVj1vqXd/Oq4V52XFcbY2MRtMhFnuZ15zVURjoagp2+Zflam7cxeFeAhI9FsLMvfN54/GcQFtMJAzBldzXUp6zAttTOX//08iagjpiQSsb+lflxF5EsSSopVsIGTqkxEvvk9HRpkIoYt8X51EDmZ85YrEbMG2dWSjfv1vuFnxQm3GcdKxFagOS7++hb4aSMvux7nJOL3jrOv+wWJqLMzS+3M3s7sgTyR3SpqZ25RU31iNMbHOcK/RInISUS/ptTDk4g7EML2W9LeaWxnLrGSAvlJl6sFmW7CKC+oTeaLsrJtuFmyIzIR3S9Y5IFPF1Bva2fWLTJtlUpVIRRThSqwaoUxZQowl+SNx/mHvoZIEuomw0kCKRGDQL1obcLOXGbhE0SmoWLQRGkOZITbpNqZ6fOyjeHQ2plbTWQiligRLSzVJplthFlqZ3ZmZ1Z/XkKJWNN9y2ciehQhqruMyZCk7zqOvNHbmbPXlhiQiKtbEboBtye3NJmIYRsJXxLG/Q3zF2uIJM1IqZYBiVimRJwKS2yJBEnh5sJ9I2ciQlOEY1asEpfbLQFBdEyHlIlY/3kYJ4me0OTHNV8gBH3iFCMPHz2xCgDY2+PPY6BE9O3MHgRZvRwUENkdntXZQmJE9uUs+qWZiFyJ6OccWngScQeibiViVELgAEwJSBMrVzcA3eJZLlsxOS7dYixrZ25OiRgGGCEzgQrFKgZ25plus+3MRRZk26yiqEQtJYhRh624HucfdGNX23KzQFckQZhupJ3ZjETsx4kRQWpCtgESUe9Maa6/z9B9y9h+bhLDYdGMXBW6FmPATom4FZcrzQlCieisWEUdMSGUiIbkDWWFzfeKyQWvRPQoQmmJoJjrmj2fKYlIxXXOyA6dnRlABPb307g8u3B1U85E1Fh/gwBRwBblSVS/EjHK5ZspCDJRrLIpSESayy4PZSJOi0xEMxKxE0RONsCYElFnZ86KVdbKilW2IrRFDqEJicjeExd25hw5WvRaOKkz38nG/+svYp/fEyfXkKYpHj3OSMTdHf76OjPqPxhKxSp+Hu+BcpVvp8O+10KCdYPN0tgiE7EdJGghdlaetVPgScQdiNiw5bLOYhXAfUh9olk8y98zOS5xTAWTxY5jMlSGLucRsM/0KQuoBxosVtEs4lsWSkRdkzYhs3D6yYdHhkhD/Nk3hOuvVaAZO3OZYo/UkIAZmWlCtsnP60yJWPL+th3ct7oNxCCUKREzEtFCiWiUiWhna7fFQPN5kTMgTc02is4RiThVPLHPMhE9ieiRobSduWKxSllxkVAPO9p8EEpERWkIKQbN7MwDM3UbgChkhFvcrz8TMZHINhMl4slVRiJessTUa0QiUlHUlLAza4hRINf66yKrOIrrK1ZZ68foBAafVYtIRId25lgiXIrIUU76zfNMxCAA/skPPAcAszOfXO1jeWOAIAAW2pzs1ioRs3w7r0T0AMjOrFb5UiZiC7HR2m8Qp+iI9nPFGCTFOnQx8HbmElQiEd/73vfiJS95Cebn57F//3782I/9GB566KHcYzY3N3H77bdjz549mJubw1vf+lYcO3Ys95jDhw/jzW9+M2ZmZrB//3780i/9EiKDoGAPPbKJlcoalOUHGj2fQUA9kE2sXFk8BOFWpNiTvmemRFQfk8hEbKJYpWzhbFlAUhZQD2RkQNmu6LjI7MwaJaJlfmVZmUQ/MsvG8LgwIPL+2upyn4FhWcPAQJHdBJlN14xqfO+2QtAlZ0L4mW4SUYuwa6W5UkFvGYEQaZRyhEyJ6J70VZG0U/QaDBSeNiQiKc5dtTPHmnmG/D2TDbCVTbbIXJguntiLTSc/ofeQUKbKrlysUtAEKsN5IVPClYhF5A0yErGsnTlOUqz1Y5HvJS+QCx/PScTURbGKRAi02mVKxA2cWScSkZWICCXiiJ1Z02IM5OzM667amUV2oDrDshUkpWPxhpyJaKBEnII7JSL7vHR2Zva+X7bYwVtedCn+yZuux21X7QUArGxG+OYTp9nPd82gFXF7fFejRCSyN/DFKh4McZIihOba4vOMloHKlz2flIlYYmcG2Jjh7cx6VCIRv/SlL+H222/H17/+dXzmM5/BYDDAG9/4RqytrYnH/MIv/AI+9rGP4U/+5E/wpS99CUeOHMFb3vIW8fM4jvHmN78Z/X4fX/va1/D7v//7+L3f+z285z3vGf+oLnBkE6vin9N8yzigvsRmRug6nlgJ629RYZ30PZMJoy5AuzMBO7OKEKherDJ5JaKOfG5ZNOPKpIHKSkoLZsBd8YPH+Qdd+YNtblsZyQVk56HLc5COKVS8jiAIstdhcI3rWtRlOFciloyF1u3Mmo0iQs8xMQqYZCKat15XKlZxnIlYWJwl34/rUCJaXqseFwbKSES69G3nT6V2ZsfjhlAYFmUiAkg4MbW2obcd08LaqJ0ZEonowM4sWxOL8s0AAN05AEyJSJ/ZxYtMvXZuM2KkKCcCu0EJGUAQSkR37cymSkSTTMS2RSZiL3BcrGJwXGEa49/9rZvxs6++GtPdFi5ZZKTvZ+9noqFr9s8BA9bSbGpnNs1z9tjZMG0IbyExurajJJWiHRTXV6stiP8eBo3wAOczSrZwivHJT34y9+/f+73fw/79+3H33Xfjla98JZaXl/HBD34QH/7wh/Ha174WAPChD30IN9xwA77+9a/j1ltvxac//Wncf//9+OxnP4sDBw7g5ptvxq//+q/jl3/5l/Grv/qr6HZLbgweSmTqthIlou1iTJMFBrhXIhoXqxgcl8gBK5gsUjZYIyRimmUiFsE+oH77ZCLqlFs21kRZfaIiOqYk+/Z6P8KcIlvL48KCjiCjc9D0Ojex/TZB0MdpOZk53W1hdcts0TQwVZpTsYrrduaSQjDbplWtndnxPQsoV5vT2GWSNUmPmTLIRBTnoqMFmcgbHTNeJE5SodBZmFJkIno7s0cBygh6G8dDkqTi/CotVnE8bgRciVhY1AHe2pwCqyUkIpHzPROLLIBEkIgOlIhpSXYgAPQWADAlIoFIKQA4fm4zEwCQEtE0ExERNgYxkiRVbsBVQS4TsbBYJSM61vr6v7++FRkWq7D3ZCpwq0TUqiKJ1EnyxOgVe2dxZHkTn3/oOABOIh7ln6cvVvGwQJLKRLZG5WtIIuZIybLra7CGbjDwZW4lqCUTcXl5GQCwe/duAMDdd9+NwWCA17/+9eIx119/PQ4dOoQ77rgDAHDHHXfgec97Hg4cOCAe86Y3vQkrKyu47777Rv7G1tYWVlZWcv95FCMpWWTaWjyELazAEijDdSaiyAIrsDOHlnbmQaQmFzqOm/dkZAtnhRLRcid9azspETV5dKIkwcR6LpE8qoV4EASi+MH1cXmcP9ARSbaklIkSUdiZHWQvDb+OsGAcJGQEUvnrMLFpA1KxiiMSsSzr0boQzEBB79yWCKkwRkUickJwEKelx0Zj23S3fOo23W3m89Jl3sqPU0HOCVMrEX2xisco6hwz5HlGuRLR7biRltiZaUG9uqknEenayqy/ejtzErKfu7YzK/PIpExEwtJMV9x77nyMWWT3zvUwZUiMysUqQP2bYHnFnr5YBWA5xec2B7j7yTNIh9ZgOSWijhyVLNqAKyViktm0iyzj9L4n+XKfK/bOAgDOrrPvX7NPViLOqv8g/xssE9HP4T1M1LBE0MdGxSqDuIQYJ7TZ9dXDQMwjPYoxNomYJAn+0T/6R3j5y1+Om266CQBw9OhRdLtdLC0t5R574MABHD16VDxGJhDp5/SzYbz3ve/F4uKi+O+yyy4b96XvWJRNrGwXzwONJVAGWTxckG9pmoLut0W7eGEYCELQ5Ia6pVEiChKxgR2IMkKgZVusYpCJKLe3ulRbmigRTc5BUvJ0W2FhgzUhI078BMSDIdKoB+k6N277NSClMoLeobLNgMyctshmNM1EFCS9MyWinmyramcuiqwg9BxvfAHy/VhfCgWUE3708+lOuRLR9aaKTmFp4wygPMReO1SSNy1L1bDHhYGyRnebua48BujGDEDKUnU1bpC6S0W2cQXO+qae7FvdYtfWVGhmZ07o51Hf7HVaIEkgKRFNSET2mc312licZgv+rz56EgDwvEsXMrVmKYnIft7lJGLdDpw8OVqkRGTfo8esbUX4539xH976ga/hK4+czD10vS+1M+uyHrkSscczEV2QbrkmWw2BM6xEvHJPnii8ev8c0CcSUaNEJHUl+s4KizzOL8RxjDDgY3ehyleKCjBRIsZycZFmLBTnorczl2FsEvH222/Hvffei4985CN1vB4l3v3ud2N5eVn899RTTzn9e+cz6rR4APIiU69Ucbk7K08Ci5SIAISF1SQDKlMiFtiZJ5KJWFexCrcza9qZc+2tLltkNQROaDG5N1ZKdc2JE48LA0JFrbFcml7nZfmlgEzcuFMiipZ6zfVgk18oFMMl1xeRXa4m+KWqotBuQ2WQlJOjTdqZVeOXrBovIxHXhRKxPK7BxiZdBbriGvkzLBvjs1IVNRngMxE9ihAZbsKazHXlMaB8rut2LASpXxR2ZqFE3NCTfStciUgEWqmdmZSKcf1KxCiO0RYFJCoSkWUitoNEEGQzvZYgEf9KkIiLQMyPvTQTkf18rsX+dt2bKlGu/KHguPhnSEkN5zYj3H+EOekeO7Gae+jaVpx9VgZKqa4oVnGTidjW2pn5uTnUEE5KRAJTIhrYmbvs92aDTW9n9gAAJJGkctUQ9K0gMZpzp7JqVkvSs3GwC29nLsNYJOLP/dzP4eMf/zi+8IUv4ODBg+L7F110Efr9Ps6ePZt7/LFjx3DRRReJxwy3NdO/6TEyer0eFhYWcv95FIOyitQZTDyU2bCpzLSd2aWdWV44qvJE5vhd+pyhrBkoPib63qCBG5mpHcdULZW1C2oWzq1Q/D2X1l8dgWOjKsqUsPqJfVM2bY/zB33tdW6ryNYr5YDsHFwfxCNWpbqgi3UYfh0mqsG+ZkNFhnslYs3FKgabD00Uq5iQozRel72ODQsl4lRjSsTRzysIAuMCt6xURT2p95mIHkVISjZhs83K8ufqS+4UneMByOZXW442moXKTmln5rnWm3oSkezM3bJGUgL/eRC7UCJK45DquDqzSMHe+3luaZ6VlIjPLjNy86ZLF4GYEwIqQpLAj2m2zZWANW/w5RpfNWqpXsjO1bWtSBzHmfW8FXgjV6xSrpTqOFQiJmX2cyIW4/wxXLk3K0/ZO9fD4kzHrFiFl+rMYtPbmSeMk6tb+G9ff3Lin0Mcy2OGmqA3VSLmzlXd9dWi4iKvRCxDJRIxTVP83M/9HD760Y/i85//PK688srcz2+55RZ0Oh187nOfE9976KGHcPjwYdx2220AgNtuuw333HMPjh8/Lh7zmc98BgsLC7jxxhurvCwPjrLGun1z7AI5uVrewJamqXE7s0trmBxLoDquWa7OkDOWVOjH6gISKlbpN6hEVB2TUFcaTnyEnVmjRAyCADPC7uhOMaUrorCxGREZWZZTZGPh9LgwEGkt9XyzwDYTUUO2kQIwTd0RU2ULZwCY7piXJ5W1IhNEsYqj62tQUkBio14G7OzMLpWIZTZtIHtvje3MJpmInYwgNS1Rs4Gp46FMObqywZWIijxE+W94JaKHjLKoADo1bZSIvZLNFECyM7uKTkn1duYwJDtzSSYi31AnVR8p2JR/lpNTLopVkliaayrJ0RBRmynS5gJGPM122yMq5ecdXMxstIbtzHNtdg7UPT+MYjMlYo8f8olzW1jmY97Z9TxZu9aPJBJRQ45SzmPKft+dElHzWhR25st2z4jr7pr9XJVIJGJXQyJyK/tMsIVB5G5d4lGOX/vY/fiVP7sXf3Tn4Ym+jpxysFCJmOVommQi5khErdI3UyKaCnguVFSqML399tvx4Q9/GH/+53+O+fl5kWG4uLiI6elpLC4u4l3vehd+8Rd/Ebt378bCwgJ+/ud/HrfddhtuvfVWAMAb3/hG3HjjjXjHO96B973vfTh69Ch+5Vd+Bbfffjt6PX34r4ceZcqHvXPsBnTiXDmJGCdZFmFZO3NTSkSVAoeUDGZ25nIlYpMkomohtjjDBrrljYFRqxzZa3SZiACziJwzbG+tCl2xgekCE8g+qzKSY9pnInoMQaeizqICzK7zyEARKyvENvpxLu+uLpSN74CdtT9rqjcsVnG0O113JuJ2KVYxybCc6rRwbjMqXQyKYhWTTEQptmIzijFjYIG2QdlxMRV9eVmMiRLRZyJ6FKHORnfh4ijZrAQayFIl1Z6CRAw4qbOxpVcMnuNRAaJMoIRw63QZibi+tm76So0R5ayJ6ms96syhE62KcpVZyc4MMHXbRQtTkp3ZrFhlxpGdOZ+JWHDu8GOdarFz8JHjmYV5WIm43o+lHMLyduZMiehgzSW3Tlu0M/faLVyyNI2nz2ywZuYkAYiU1ioRMxt0O1pHmqalimCP+hEnKb78yAkAwD3PTLbANo5KlIicWAwN25lTWWGtbWfmSkSfiViKSkrED3zgA1heXsarX/1qXHzxxeK/P/7jPxaP+c3f/E380A/9EN761rfila98JS666CL86Z/+qfh5q9XCxz/+cbRaLdx22234iZ/4CfzkT/4kfu3Xfm38o7rAkbUzF3+8++bNlYiyfahT2ljnrlhFngSquCRS7ZnYmfuanEeRiRi534Ggha6KEKDJU5pmiy0dRCZiyWdFC0qXhJsgcAo+MJvJPWVmlU3uxTE5VFd6nF8YaHLb2oKYqM8e226FQvnmuoBERyLOWDQpmxZn2eQsVkFpIZhlO7OJTbsJO7NQm2teh2l+IZ1TJuT0VDtPaNeNMpLWlPQVmYgaJSJ9hl6J6CGj1nZmKxLR7bgRpETeFF/nIScR10tIRHLltA1JxOlpllm3sbFeexxHmlMiqknEuENKREY8ycUqAC9VCQILOzP73Zk2e0/rtjNHSYpWoCF9+X21y+3Mjxw7J350ZliJuCUXq5STHJ2E/b6LTbCorFiF1InJ6N++eh+zJl93YB6IsqbtsmKVlKs2Z7DlcxEnhPuPrIhm7UeOnyt5tFsksnKwMCqA2pnt7MxJ0AZ0BDUn6buIfCZiCSptTZvcXKampvD+978f73//+5WPufzyy/GJT3yiykvw0KBciUgkYnnuiczCl2XSdR2qOmQ7lmqxO2tRrJJNGEcHJpGJ2MAOBImgVJ9Vr93CTLeF9X6Msxt9oUxUoW/QzgxkShaT96oqdKSLDYn4hQdZ5MGLDi1pH+eLVTyGIVSshZZ6O2KiTDVMmO620N8w2xmtgrhkzKDXAJjFFZi2M0913C6cy1RFbctxWRTGaMjRRopVTJSIhkUNRAaaqArDMECvHWIrSpwQ2lEJ+WxqP6fNsYXpciVilKReoeIhIAh6VbEKP29M1iwUcWOnRHQzxhOJGKjszJzA2er3mWKMro84wTs/9A1MtVv4z+98sdhQb6d8MV5CIk5NMwIvTPo4sbqF/fNTYx8LIZ+JqL7Wkw4joEiJONMdJhEX2f/EZsfUhBKxRUrEIqJjyM4sKxHPSkrEKE6wFSXotEoarAFBIrb45+qi4CeOB1kzbhGhKZSIg5Ef/cIbrsMVe2bwozdfCgyWsx+0NSRiELBinc1lzAUb2IoSJ04ODz2oAR0AHjm2auSAcwWKQEgQICyaZwTsey0kRnNdyppNwo5eQcfHjB76YrPUoxj1+ls8Jo5Esh+rFi1EIprYmWUWvmyR6TJfKlesohjPyA5lkomoa5xusp25TIkIALtmuljvb+Ds+gCX79E/35bhbnoTJSS61ldTVVGapvjU/Swu4QduGi1ckuEzET2GoSv3ofMyMrzOB4mhYq/TwvLGwNm1ZaREtCDUI81YKGN6gkUdQHZMRjvOkMZ4jU2b7llRkiKKE23eZVWU5d4CUiaioRLRxM4MMOJ3K0ocZWaZ2c/LylDIcjlvkIkIcPtgybnqcWGg7NpqGZ6DgDR3MhgDnG8+iLy/4iVaq5UVCqxsDLBrli16v/roSfzVo6cAAKfW+oKgbxmSiK1OVijwzJmNeknESBItBOr3OOXZeEQiMiVi9j7cRCQikVeGdubpkI2dtWci5my/astlhysRH83ZmbP3ZJ2P7Z3AQDXKix/aDpWIyaAkw1JhZwaAmy9bws2XLbF/nFljX9vTahsZoctIxKxcpeSz9agdfyWRiBuDGM+c3cBluzU2dIcgEjFGq5j0k5WIJkWx/HpJy9TLpEQMIqcbzDsB9c+YPSYKebLUUky0rezMfCEWBvpFEODW4kFKxDCAUoUgilWM2pnVE0ZaTDcxeJD1XPfe0i7s2Y3RHb9hiGKVshKSBlR7OqWKqRLx/mdX8NTpDUx1Qrzyun3axxLJYGLh9LgwYFLuY2pXiA0LpmyakavAKBPRgvDTNdXLIMut+3ZmfcmUSYB2mqZGxyUXULnKwLVTIhqSiAbFKoCUY+lCiVhyHor71rr+vrWywZWIBpmI8t/18Ci7tmjMMJkTbis7s8hELN4sCKTFszwv/PPvHBH/f2xlk2+op2ilZnZmWjz3EOHpMxv6x1oi4UrPGKHeStjlJGKwgTBg9x3ZgfO8g6RENM1E5HZmrkSsu0wwTpJMiagpViE7s3z/lMfGdU6CdAOyGpTbmVsOi1XSshIKDYmYw4CfRzorM0dADc3BphN1pYcem4MY33jiNIDsfvywZL9vGqlQIirG5JA2U2JsDOyUiFq0SYk48PONEngScYdBJmVUFo+9EolYZvMQ6psJ786aLJznpuqZMHYbLFaJShpJAWBphhZj5fZzkYmoaWcGMsJ13SHhZmJnLhugP3UvUyG+8tp9pRY+b2f2GIbOqmubszYwKFYBMtuvq+bzOC1/HTYlQ32DrEcgOy5XJH3ZGD9rQQjksnx1dmbpvHC1aUQbcboxPntvzezMpjYvl2VTsYagByBUTGWOh3Nb5UpE+fr1uYgeBKHKVoxdSzNsMVhGZAOWJKJoZ3abiaiyM8uLZ2r63ejH+NR9R8VDjq9sYXUrQhfSeGmo2uuhj2fO1k0istcZo2TskpSIs902giDA0jR7XXvnuqxUBQAoY7GMEODHNOVQiagvVuEkYjA6bq1uZUonmi9MtTSEJIGTiGHMxlYnSkSZRNTZmeMyEpGX9OhKVQg9TiJiw2nZmUcx7n7yDPpRggMLPbz6OfsBAA8fWy35LXegjYekKCYAsFYiBrGlEhEDY6fShQpPIu4wyP591aJlD7c+DOJUTEBUGFA4vUEmgsucmFgoETUkos2us0al0mnQzmySs5aRiPrPKk4y9U1pO7OwM7vLRMzszGoVWFJGIt53DEC5lRkAZjqcGPUkogeHzqorilUMM08y65xZVIArso3IG11ODRHuNnbmMhvftJSJWHbdVkFZO/Nsj+zM5kpzQG9nbrdCMRa5UhVlaimDYhXNOZMkqXiNNnZmwI0FfVBy7yLHw4lzm9rnEUpEg0xEwCsRPTKUKRGXLFwcfcNsWGDymYhkZ+1hIDaXP/PAsdx4f3RlE+c2B1kzMyDIJyWkQoGnz9Tb0JxK1kQdwqlMiUgbRy++YhdeesVu/Myrrs6cSEKJaJaJOBW4IRHjJEWoVSKyc6UTFt9fzm70c69rmh6nI3z5MYXc0j2I09o3V3IkYqFN21SJyM+jrgGJyBuaZ7HpRF3poQflIb78mr247gAjdB+ZpBKRn99KJSInF9tBgvWt8jGelIhpaDZm9DDwBT8l8JmIOwzyelg1sZrqtLAw1cbKZoSTq1tit7YIIlPMYHfWpRLRxPYrSESrTES1QqmJViZSFenI0cVps910mTylBbcKpFAxypGoCKFELPjMTJSIj51YxUPHzqEdBnjd9QdK/x5Z/Hw7swdBl2NIyrvY8Do3zg50rIg1USLaZJ7qLN8yZPXbVpSI46wLpUpEIkYNxix57C7LsOy1Q6z3Y2eqIqNMRAN7pKwmNClWAYBphxb0Mnu/IBFLYlNEJmJPvWiWXRVeGeBBKNtctnJxDMyiYAD3mYghtx8HikxETDFL73ywIYQAf/7tZ9jvBkCSMjvzua0oTyKW2pnZz7tgmYh1QtiZVaoijnBqAQBTIs7weez8VAf//WduG3pC00xE9vNMiVhzO3Ocok2ZiJoG2U6BEhFg8/r981Oi5HC6FQMJ9ApLTvaGcbZBsxXFxvcFE6RCORoWu9oc2JnJyj4bbHryZgKgPMTvv2avIPAfnmBDc1K28SDFPWz2TUhEw0Z3kYk4wIrBBtSFDK9E3GEwUSICmaX5xDn95GpQ0sAoI9uddVCsUtLCBziwMzdwEytrJAWkiTDfsfzmE6fxnj+/d+Q46d/ddmiuRHRoZ440ak8iQBKNnf4LD50AANx29Z7SVmoAmOYTKJfH5HF+QSipC9RoNKYNbO3Mhoo9d+3M5YpIG0u1rmSq6DkBV6SUnhy1sTPnlIglxyUIgditTVv3OnoGSkT5PTchOoCM0HaSiVhi78+UiHoScYVv+s1rMhHDMBCFat7O7EEoVSIaujiALNpl1oCIcZ2JCKFEVMzjONG2gDWcXR/g9FofX3qYzZfe/PxLAADHVrawuimRiEGozFgUoEzEYFC7nZlIKaWqiCNTIm4KYUAhYrtile6klIiiWCV/rly6xEi1M2t5JeKUUCJqCF+uKCV7JlC/tZ6UiEmgUsOq25lzqGRn3tyedub7/wL44r8Gkm342ixxbnOA9/z5vfgmz0DsRwkeeHYFAPCSK3bjugPsOnz0+KoT54kJTO3MABD12TzjeydW8c8+ek+hkppIxNRwM6WHgZGK/UKGJxF3GGTVg6qABJAamktUAqYLTEAiER0oOohs0ln4qiwyu5p25iYyEU1s2mTJWeYT4d/8zMP4gzuexCfueTb3uEzRUT4JnhZ2R5d2ZrVShY5Xp0SkG9otl+8y+nszvp3ZYwiCwNGW+9jZmcsyEV3bmSODDZVsk6D82HSqbBmtMBAbLC5IxKiEHKXx3eT6lu9buvsgkN23XNmnTNq0TTIRszzEUHsfzD1v232xiopU3zdnRiLSfWthWk8G0N/xdmYPAi1uW4pzMHNxlCsRycEwY6Cwdm1nDolELFEiLgTrWN4Y4M7HTiFKUlx/0TxefvUeAFyJuBmhJ9p+S6zMQEa48WKVssx0GySiJEH//ran2bHNYUP/WRCBZpiJSO9D3dEO+UzEgtcrLJfZe7lntosDC+zzOMPn9RTTkZGI5ZmIQbQl5iObNZ+LQolYRuCUEWpCiWhuZ54LNranEvH//UXgi/8SeOAvJv1Kxsan7zuGP7jjSfzLTzwAAHjk+DkM4hQLU20c3DWNQ7tn0GuH2BwkeKrmaANTpFSEompz780j5ednq78MAPiDrz2BP7zzMD7yjadGny+2VCIiMtqAupDhScQdBpMCEkBqaC6Z4Js2dwLy7qwLlQr7qjsuIs/WjDIRTezMDRSrGBATuygcnO+I0A7xEyfXco8jG/ecRtFBmHVsuZTbUYtUrDQ5XN1UD9CUxUE7YmWwsXB6XBjQlfvQxkhkaGcelGT2EVzbmRMD9bJp5mmcpCBOxmSMN8nuq4oyVTaNWWv9qHRxq2uGHwbdt5y1Mxu8FkH2ae6d9J6b5iECwJTDMTHSxFUA5nZmEyWi/HdMr1ePnQ9TJeLKZlSqYKVoF5OYBpeuG0AiEVWLXbIzYx1n1wd4jM8Fb7h4AQd48ciRsxvYGMSZErFMfQNI7cx9rPfjWhfQwppYYmduz5CdeV2vRNziNkuuylSCKxW7nEQ0ydS1QZwkCAMNiRiOkogXL01l83pOcFNMR49IRB05SoRwvCU2oGoXb0RE4KjKfQztzH2+TjGyMzMl4gy2tl87c7QFrDG1L+78v0d+/EffOIyvcTvw+YCjK8wKf9+RFfSjBPcfYaKNGy9ZQBAEaIUBrt7HPo9JlaukZRsPQYB0ejcAYDZeQZykYr5xpEBJ3d9ixxyWZcOKzNm+VyKWwJOIOwwmtl8gUwmcLJngR4bNnQBEfomLnD2jYpWa7MxELjRZrKKzJi5KuT5pmuLoMhsInzyV3x06t2W2GAPcE27yhL1IxSpI7NVihUCapnjkOLtxUcBvGaYasGh7nF/QqezomjNVNmVttCUkouOCn8hgLDQlMm1sv4Dboo6ypnpSIqZp+TXet1DQdx0q6AGzTMQpg7bXjQokIj3WRJFqi7INSxrjj6+o5xibg1jci8uUiFmO7jZbXHpMDGXX1pJ0TpVlW9H1ZaRE5NeVs8iblD1vqFKj9bidOVjH2Y2+2FC+Ys8s9nOFGxGLGYlYHglDRM9im71XT9eZi5iYKREDfmxzwYY+42/jLPvKCVUlOHnagct2Zk0mIikRkZ0rFy9Oiyx6UiKSK4hs19rPi0iQqJ8p6WsWb2R2ZpUSkb++uMzObKFElJq5t52defV49v+Hv4Zf+A//FY/za+zR4+fw7j+9Bz/1e9/EYycm12ZsA1r796MEDx5dwf3c+XXjxdn1RGuvhydVrkJFKColIgDMMBJxV3AO6/0Ip3k8wLGhQrc4STEYsJ+1OiVjIb++ekGEZQMV+4UMTyLuMJgo2wBg7xy7gZWRiEKJaKDomJ9iF+Y5AxLPFlmxivoxlGVzbsxilUlkIuqOS24YXN7I2qKePK1QIlrZmd0SHUCx3W1vCYn9zNkNrPdjdFoBLt8za/Q3ZxwSHB7nJ7JcTp0S0ew615W0yHDezmwwxk8bXgtRjuwvH+NdZqmWtTNPd1og3rRso0iXxzoM19ZEk9zbnoESUbR3WhTaZCSiu4091XtMJOKptb5SBUb36iAA5kqy6Ojv+ExED0JZtEO7FQqHSpmihEicaYNMRJojulAipmlqbmfGGpbXB3jiFCcR987gIq5EpPnrXItf+2XqG0CQOIsttnB+5mx9NsZEWBNLxi+eizeHTVyOI8CH/1fgyLdHH7fJ7IvmJKIbO3N5JiI7V1pB9ncvWZzCrqHSnzX+unr0OJ3lUpCIm+jx+0rtm2BJiRKR2pY3zuifx6pYhbczb8diFZlEBPCyE/8Dn77vKADgmbOMsOpHCd79p/dMLEPQBrKA47tPnc0pEQnXchfYp+87auTwqxsiAkEzZgSzLL5hN85hvR/jzBob548NbV6e2xygwwurWp2ylnpSIg4Eye9RDE8i7jAIUqpEgWEaej4Q7czlig5SwJ3TWFSrIjZYwNPf34qSUhUhkaPdIhKx3WA7s8FxLc1k7czPLme7K0+eXM/Z+s4JErF8x3lGKJXc3Bjk97+IFNg7n5GIRdbER7h8/qq9c0ZEAJA1lvpMRA+Ctp3ZsljFJNsOkFWAbq4tE2XbjGHJ0Jb087KNJ/l5XVxjZcq2MAyy3NMStbtpziPgvml1YJSJaF6sYkUiOiS0ByXK0d2zXQQBO1/PKHbzV/hcYa7bLs15zJSI23+B5tEMyhrCgbyTQwca02aNlIjuNh5iSdkWltmZeTvz4ycZ2Xfl3lnsmunmNs0We/x6MVEichJnPmDzzDqViMKaqFMVAZkaLdjA6079EfDwXwKffHf+MYNNIOZrl1ISkR03kYh125lLMxH5Z9iCbGeexq5ZUiJSscqwErG8WAVIMcs/1rpJt4zAUZyD+5/Lvh69h9kDVKBila6BGEDYmbcjicgIQ2qQ/rHWX2HtLCMW5ViwOx8/jT++azSPb7vhhKTU+/bhs5ISMSMRf+CmizDbbeG7Ty/jHR+8U+TyN4WU520mUBPqwQwjEZkSMcZpfj0dW84rEc+uD7JxtWws5NdXFwOjPN0LGZ5E3GEwDf/PlGD6C8QmW2phylwJaItYFKuoHzMrKfDKdk30duZJKBEN2pnX+8LKDDDFp7xLsrrFA+oN7Myuc9vk3KqihTwpYQdxiuUChQDJ568xtDID7skbj/MPujgG+l5suFmgUzXKcGkhBczGDLoWoiTVjmM0fsz32qWt0wAw2yMSz8EYb3DvMi3PEnZmg80v1/lmcUmLMQCjXKtNUiLaZCI6VGeXKUc7rRC7+QaYarOS5gplVmb57/hMRA+C3fypxM7ct7AzO4xAiJIULZQpEbN25qfPbAhHxxV7ZxGGAfbPT4mHLhIXZZKJyEmcaTgkEUvszLKl9cpzd7PvHb4DOP5A9hhSISIQpI4S/LjbDpWI9HkVqgep+CHIzpVLlqbFeSmKVfjGWMfEziyV5My12XHVvlHEi2tSFYl44EZ2bOsngZUj6ucR7czmSsQ5bOY2OLcFVo8BAKLLX457kyswFQxw+ZG/BJA5qsgF9i8/8YATMU2dkNf+n33gGM5tRui0AlyzP1tzXb1vDv/t770Mi9MdfOvwWdz+4W81+hppzNDbmTMl4tpWJEi/c1tRjgc4uzFAR5RMlYyFrUyJaJKneyHDk4g7DJGhWqbMTkqwaWcmO/PqVnnwvS2SEtsKwBYtNLkzXmROuFjFZBK8yBdYSQo8ejyft0E2FsC2WIUrlRyRiKS+CYLiY+u1W4LsLDoHKcj3uv1mpSqArLrZZjuYHhND1sJepETk2aeGGWtl7cGEacNSk6owUyJmCzXdNU75MbvnDBaYyJSIaw6ViDoyc9awPCuyiOHICsEcFavYZCJqlE2kRJyqkInoRIloQPqWOR5ooWWS4+szET2GYbLxsEQNzRv6DfM1ERdQfi66HDMYKcU3zQ3amakgYc9sFwt8Dk7NvwCw0KZWQhMSkZE4UwkjfmolEY3tzJSJuInFLYmcuutD2f/LVuayMZ5IxJSyB2vORIxTtEiJWER2cHViK5VIxMWCYhU+X+jY2JkBzPHPt/ZzsczO3JkG9l3P/v/Z76qfx4ZE5ATy7HZsZz7HSMRzrT34i/g2AMCVy18HkK1h/uaLD+LQ7hmc24xw52OnJ/M6DSGvu6jc7Nr98yPCmhce2oX/+q6XAgC+9r2TjYhrCEKJqBszJCXi8XObOQfhsZVMdHN2vS9yUcvbmUmJyN6XsjzdCxmeRNxhMLHHAnKxRbGdlGBjC6OFQJykTsKLAZRanuYNy1V05GiXq1dctXXKMFtgtsQi88Gj+YDbw1K5yopFJuJMQ0pE3SJ+r1hgjk7uHzlOzczmSkSyOvbjxDjnzmNnQzSEFxarcCWi4S5jZLih4vzaMhgzOq1QLKzXB+qxUJCIs2YkolAiOiBIzZSIdoUx28HOXJYdCGTEoG4DpEqxCt03nGQikp1Zc1xlJOLKBlciTpUrEX0moscwTDZ2Fo2ViOxcNFEiTjvMho2SFO2gTIm4BIC1MxOu2JvZRamhGQDmOhYkIidx2vEGAiR4pqDhtDJMCAFAqCEFyK783Y9kTb+meYiAUPS10nyBSV2Ik0SyM6uViEEa46KFKbTDAFfsnR1VIvJ7mlERTtgSf2uu5UiJSKUWRRZtwiU3s69aEpEyEc3tzLPb0s7MSMQTWMJXkucDAG7Y/C4Q9XGKq/oOLEzh5dfsBQDc+fipybxOA/SjRIyHi5ILQM5DlPG8Sxcx1QmRpMWtx84gilXKScTdwTk8M7TpIeciLm8MzJvqeUv9dMjeI9/QrIYnEXcYMrJN/7g9JXZS8XyaRfgwpjstsbCt29JsokQEJLtbyd+n1zdbQLgJJWIDNzFRGFNyXLRr+dCxldz3c0pE0c5cviBzbf2NDJpsqSH8xJASMUlSkYlIwb4mkLPC1rebFcJjIog0mVl0nZvaI02LVVyWWQD22YwmSsQ9hiSiUCKWZBJWgQk5Sn/fVGle1qQNuC9WMTku0bCpy0S0sFsSXCoR6drq6JSIijGeUE2J6ElEDwarYroSEtGmuGhGameue8NStseWtzNviFKPK/YUk4jzHcpENFciBkgxjT6eOVNfsUpqUJIAAGj3EMkZaC/7GWDXFcDWMnDvn7LvWZGI7LhbvFRhEKe1uowiw2IVpDF+7+++BH/4916GvXO9ESUije+i6Tksy21jn/Fsy40SMRXtzJqx+eIXsK/Pfkf9mL69nZkVq2yzOTwnEZ+O5vFgehlOpIvM9v/UneL+tneuh1uvYm3Bdz6+fZWIp9bY622FAV5x7V7xfTkPUUYQBDi0mxXpHD5d35hQBiP1MikRcW5EOX1cyn1c3sgyEUvzYdvs2pwO2eN9LqIankTcYTBVIpbZSQlC0WEQuh8EgVDB1Z0HEaflCzEgU+GVLTJPSYP+MJosVomEmkN/XLRbROTa5Xv4gC4pEW3szK7VUoOSvCxAKlcZUqk8c3YDG4MY3VaIK/hxmqDXDkF/zjc0ewDAIFIXKNnaI2MDYhwwI+/GAQ1LZbm38jX+kW8cxk/85ztFkQXhNJ9MGisRHW4+lGXsAdn4Xvb3aZPIhJxybWc2UVj2iOzT2ZmrtDM7VkwB+ntyqRLRgkT0mYgewxDzJ818l8ga3WY5kF1fswZ2ZpcblpGkbFMWAExli/05sIXzVfsUSsS24cIZADozwpI7iw2cqzOaKDVQFQFAECCR1YhXvQa45e+w///uR9jXzbPsqwWJGCbZ51/nvDdOUsmCXHBsdLxJjOsvWsDLruKkh1SYmKapiOhop6a5bezns66UiJRHp7N+ChLRxM5sMJfnStgZbDrJG62KfpQg4XbmxzbmkCLEV5LnAQCSRz8n8gX3znXx0isZiXjvM8vbNhfx5Lls8/iFh3aJ76uUiAAmQiKSelk7Zkyz93tXMEoiyh0CZ9cHwp5cTiIOKRF9Q7MSnkTcYTDJyyLQBP+4pqF5YGDFkiEammsO3qfjCksUeyYk4iBOhIVgb0EWmChWiZPasx2HQeRoGSFA1gda6L6M36hkJeK5rawkoQwzHfaYsuKFqshKKDRWN4VKhUpVrto3a6SAJQRBkKnAPInoAT2ZTbZkcyViOckFZOTdpJWIckPz//OVx/DVR0/iyw+fyD3mlLAzj26mFD5nz6ES0YAQyIpV9H+fdo5poaaDazuzSU7xVNvczmyTiei2WMVgjC/NRLQoVmnZkf4eOx+JwfxpybKd2bRYxdWGZRRLyjZVoUC7Jxa6CwFb1OeViNl4Tko1OUdPiSDILKXBJtK0vs0VYyUigO4MJzM6s8CltwBXvoL9+8wT7KsVicg+/zAZiHt+nZtgcSx9/oVKRP69JH+e0HkZJSnObUXi/BMlLSoVKoF//jMt9nhXmYjKYhUAOHATgAA496zIDByBsDObKBElO7MmhqVJnNsc4OX/+vM4ffQwAODBVXYcX4kZiRg/+vmcKOXixWkc2j2DJAXuevLMZF50CU6sMnJt33wPN1+WXUM3KJSIAHAZJxGfmoAS0cTOvCtYxdNn1XZmuZ25VOUrilXY3y/L072Q4UnEHQbTdmbArKFZ125aBLLS1m5ntlQiysH7q1sR/tlH78Gn7zsKILPwhQGwVLDIlBdFrq1TRAiUkaMUDk546ZVs4JR3hVYt1DfThsULVTEwOG/2KZSID1ewMhMoFN2VwtLj/IIujoHU2gPDa9x0g4aIG1fnYJXXceQsmzQO7yLb2pndKhENMhHp75dsUp1ZYxsqReP7MFzbmU3cATbFKjaZiC6bwmmMt1UiJkmKX//4/fgfdz8tAsvN7Mw+E9EjDxM1LLk4ynKt1viYZqL0DYJAbNLUPc7HSSotdjXXBZWrgG0kX7E3U3pdJCkRZ9sWdmYgs5Tyhuba5ocmhACB27VxxcuZtXCGWy7XTgBpKtmZl8qfi4477ovxsM7PjIgOANpiFaT5vznVaYnXc3ZtIM4/sl2X25m5EpGrpeq+fwVcualVIvbmgL3Xsv8/+tfFjyElYtdAicjPvVaQIu03qHjT4J5nlnHi3CYWYmZP/s5ZRiJ+lSsR28f+GunaSQDZmppEHtu1XIWUiHvnenj+wSW84cYDeMetl+fyEYexbZWIM+y93o1zI/ELx4bszMbtzO2snRnwSkQdPIm4w2AyqSKo7KQybALqAUmJWLedmSJHykhE8fezG/sHvvgo/vDOw/g3n3oIQGbf3j3bK3yfZOuj6yaqyJD0pV1LAt2kTq72heqS1J8mxSrddpjtyjrY8YsMFs6kApXt9FGc4CuPMLXUdfvNS1UImQpse+xiynjq9DrufWZ50i/jgoK2nblFxSqG7cwG6lpAUgA6JhFNFZFHlzcEATW8i2xbrNJEO7Pu3iWUiCUk5hmhRCxXuPWIwHNAtKVpKmIx9JmI5UrEzSokIj8HtlwUqwglol0m4jefOI0PfvVx/H/+9B6xIDEpVhFt6tvUzpymKe564rST/EmPYhi1M/ONhDOlxSp2maOucqWjRGr7NSAR57mdWVYi7pdIRFKqGdmZAaEGW2zx1uCazmejplUCkYhXvop9neUkYrwFbJ3LSMTppfLnkkhEF/fmpEyJKNmZh7FLlKv0sc7V9VQAY265ZL+nu3dUgihWKVlPFOUibi4Dj30JSBI7O3N3Fin4tdxf0z+2ITx+cg2LWEOXW9YPb80iDID5vZfi/uRyBEhxG+4BkM2jyLK+XctV5AzHTivE//OTL8av/9hN2t+ZDIlIGw+aOTdXIs4EW1hdZS62/ZzbOLYsk4hSO3PptcV+v+NJxFJ4EnGHwSRXikATfK2dOS5fKMhYKCDx6oBQ35S8jNkhO/PxlU38l68+AQB4lg8ocn5FEeSK+zoDmIuQGJK+i9JiuNcOcXDXtFAPPcktzTaZiACc7MoSTJps9w4tMDcHMX72D7+Fr33vFFphgNdcv9/677rOeqyCzUGM3/j0Q3jtb3wRP/Kfvtpsu9kFjoEmx1AoEQ1JCRNbKpC3M7uIQ4gMox3odTx2IpuMD08AqVVwt2IsHIZoR645rgKQCAHNmEFKxLWSv29jZ+5J8RV1QxbN6e7JpETUEVA2xQ/iedvurPUDA/t5kRLxkeNMad6PE3yR2+tNysBsM0ybxqfuO4Yf/5078PN/9O1Jv5QLBnQu6DaXaQN2WWNn7keJGFcp6qUMM46yb1nbryZjjyDKVdawf76XKwm8aFEiEUNaOJtFVpAabHebXbMbdZGkJvZYwsv+AXDtG4EX/O3sNVG779qJSu3MiAeY6ZndP2yQSlmLhZ+XQokIyAR3XygRQ6FELHmfODk6E7LH169EtCURpVzET74b+IMfAR78mJ2dOQgQtRlZFQ5WbV+yEzx2Yg37g7MAgDPpHPro4OCuGVy6axpf5mrEl4f3YnG6I9aOJPK45+llZ+WV44Dux3R/NoEgEU+tO4/5IqQmY0ZvHjH/+S6wc4Zs2bIS8ey61M5cdk4TiZiye0ZZnu6FDE8i7jBEBqoHwiVLbKLxjIbUsGlnBmQ7c70XnamdeX7IzvwfP/+oWECtbkVY3YqE8rKoVIX+Bv0ZFwtLGabKUdnOfNHiFGvL4qUjT/JyFZEvZbAgAzJV0bqDfLOBwXmT2ZnZQP2L//07+Mz9x9Bth/jdn7gFN11qMEEcgssMsCrYimL8+O98Df/x849iEKdIUuCBZ1fKf9GjFogG2QJiiq45U3tkZLihQudgnKRuiCkDsg3INgm+dyKbjI9rZ86UiC7Uy+UkLS2Uy8YsUh0NK7iLQKUmLpSIMuGlK8+aMngN1YpV2PjrgkQ0UYHRGL+8MRCL3EePZ+cjrUUWpsuJBZP3aJK455mzAIDP3H8M39jGzZw7CaLsSkciGtiZ5fmC6fXlahO2tO2XIOzM67hi72zuR3O9tthwmRZKREM7My+3WGpTa3C9mYjKnEcZz/0x4O1/Aszuyb5HasT1U5XamZkSkX9mNY6HSVSiRCQSMxolsXfNZs3hdB4ReWdquZwiEtGZErHkHlpEIj79Tfb1mbslJWL+HFUhahGJuD2UiI+dWMU+TiKeSNn5dtW+Weyd6+HbyTUAgOvDwzlRysFd07hkcQpRkuJbT55t+iWX4qRQIhqOCQAO7mKfy7mtqDlSjezMus2UIMBGZwkAK1cBgOsvZmPYsZUtQXie3RigDcNri2+4tDmJ6NuZ1fAk4g6DTbHKZXxQePqMWp5s084MQGpndqREtGhnfvLUGv7oGywMl37t6PKmqLfXDaBkWXRtnTK1Jsq2PGreI/vKk6fWEcWJWCia2JkBWbXnjhDQtjNzEvfU2haWNwb4y3tZZuWHfuoleP2NByr9XdelFrb47lPLuPeZFcz12rjuALMJPX5ye0yOLgQMInUcQ1asYmhnNrDoA3k7nAsy21SJSIvh70lKxCNnN8WYnqaptZ1ZKBEdFnXoxowZg+IswLJYxaESUSaoO9pMxJZ4DYmC1K6SiehyUyUyILMXpzviOiMHgExqE0yUiNOk1nSUXTkunpGaId/3yQcbU2tcyIgNNpfJxbG8MVBeWxTp0mkFOSeKDq5cD1GcinZmrRKR7MzBOq7cM0rQ0DxxSigRTe3M7LmWWmyeXNv8MKViFbP56Qhm97Gv4ygROw6iRnKZiAWfF2+PxcbpbNeEg5SIx1Y2xb0iIxHN7MxTgVslYuExyTjAbbBnD7PPJeoDpx9j3zvxEEDZhiZKRAAxJxvDQYO2WQ0eO7mG/TgLADieLgEArto7h71zXTyaXgoAuCZ4BnulOVQQBLj5EHvsQ7wocjuBSEQbJeJ0tyVswo1Zmk0yEQH0u6xhWpCIFzESsR8lwoq8vCEpEQ3tzGEao4W4NArjQoYnEXcYbDIRs7YltRJxkKgX4UWYd2RnNlUizkok5n+/6ylESYpXXLsXV+1jBM6xlU3JzqweQMXC0nEmomidtshEvJhbVbKMirVcW6qpnZmsHXXuyhJM8uP2cBJ3EKf4yiMnkKbAZbun8fJr9lb+u9vNzkxN0y+5YhfewIlRuVF70EAD+IWMgSA6Rs/DzB5pZ2cuUwB2WlneqFsFmBmZKU/44iTFs7xkZXUrEsTZHtN25m5e6V0nTOyxc4YkJk36SOmhQ8+g1KQq5HNL287cyY5Z1bJZKROR1HuRmpysisigWCUIgiwXkTsAvseViDTRB8yKVbabynwYT0sk4l1PnsHnHzw+wVdzYcBkLCQXR5qq56UiKsDi2hL5ejXnL8dJilZgUqzCLHs37ErxtpdeNvLjd73iSrzi2r04uMCPybhYhc2VF2rORAxiA1WRDqREtCYRJSWiAztzQkQHAqDoPOSZbYj7QD+/gULiANkNJsg7QzvzVMDudXVnIgac9E3LCJeZ3cD8Jez/jz/ACEQ6huP3sxxLwCwTEUDSYedfO5q8nXkrivHU6fVMiYglAEyJuG++hyfTAxikLcwGW7h2Op93Tsq97RhfJOzMmjVwERrPRTRRIgIY9BiJuBtsvXVgYUpsjB87t4k0TbG8PrDORASALgalpVwXMjyJuMNAZFvZAhNgkmuA7UqoJuZV7cwrNduZ6XWUqW+IQFvbinD/EWYbfeONBwTx9uzyZibl1uzC0G6060xEUyXiomxn5jvMdExHlzfF+z3VCY0JXye7shwm7cy9dku0gX3uAbbgesHBpbH+7nZrZ36Ek4jXHZgXytEnTrIb8DceP43rfuUv8Z+/8vjEXt9OR6RRUtN1EhmqjYVF32BsddnQbKrKnubX97BdmyaApEKc7rSMLXyzDq8vs3ZmMyUiFatYtTM7sMnG0rmlOy4qVgHUuYhESE9Z2Zmzx6rIyarIilX014Oci7i2FeEIzyZ+99+4QTxmwYBEJIJnuxaXEBFw61VMefQbn37YbxA5hsmmebcdCmvv2Y1iWxrFIxAxaIJpV0pEuVhFp8DhBNrbblrAiw7tGvnx2192Of7ru16GLqlv2qZ2Zk4ihuw63azp+NLUTFWkRB0kogunSlnrdHcGaHMVHm/xJZBS/ptPnAHA7kVBzM9RQyViz5ESURCBJqTvgRvZ12P3AScezL5/9nD2/4ZKxJQrEdvR5JWIh0+tI0mBS9tsLSmUiNzOHKGNJ9KLAADXhUdyv3sJX5/JCvXtAiGksVAiAhMgEVMzNWzUWwKQKRF3zXSFavLo8iY2BjH6cZK1M5dZ9FsyiRhp83QvdHgScYfBJhNxcbojMgRVlmaasJtaPEhRsFq3ndlQiSjbmR88SvkIC8LaISsRdTlgnYaViDr1DZBXItKxXCQRo6uimdnQsgJ3k2Agm9zrLHxAZikn1cbNly2N9XenDQoKmsTDx9hu6rUH5nElzy0iO/Mn7nkWaQr8j7ufntjr28mIk1QUWxRtgoi2V8OiBpuoCFeh+zavY7hllPZfaAJ4ytLKDMCJkoNg086ss9hFcSIUR0Z25rY7OzMdUxDo1eatMBDqVZVdl8bpGRs7s0RO1q2KHRgWDe2bZ/epp06vCyvzntkuXnntXvzNWw7iFdfuzTXLqiCUiNtkbJfRjxIcW2Gky6//6E3otkPc/+wKHnh2+1nZ6sYkidLYcL5Lmwmqlk0aT0ybmeXH1j3GR3GCFkyUiJxA21pWPwYABrxcwFKJOB+w36ttfmhTrFIEYWc+CWycZf9vY2dOE8y02XlS55w3jdk5pSVHRZ5jPiv11c/Zj1YYiJzs2W4LoKKWMqKDk8JTvEG27k2wMDEkXADgwHPZ1+P3MwtzEUxJRG6n78STJxEpCuaKKXbfuuLyK/HSK3fjRYd2CSfbI9zSfGX6VO53L1lix3tkeXuRiP0oEZmGtkrEy6RylSYQGI4ZyTRT++7mJOLu2a5YHx9f2RLjPjVsl2citgVx2fNKRC08ibjDYKpsA5jV6OBuykUsHuhod/1iqe1NB2d2ZtHObEYiHjm7KdqYrzswL9R7R5c3s2IVzS5Mh082mlIilokHi+zMNEgeW8lIRBNbGMFlJqKJEhHIB+8D45OIoixmmzSiPXKclIhzIvz8yPIGNgcx7nmGTf4fOnZOqMI86oN87RaVodC5aV6sUt44Tph2SHiYbqgMqwufewmzvwklIm2mWIRry0rEusmD2CBHdVYUZ6nfV3nCZ6JwIxWgEyWixf2YCD+VLW2zQrFKGGYZb3WfiyalFgDwosuXAABfffSkKFW5ev8cgiDAv/mbL8B/fdfLjJwOUx39+zNJHF3eRJIyQvrqfXN47XP2AwD+/DvPTPiVucUv/cl38drf+JKTTQUTRMZODjZ/OqNQlJBllzZJTOAqOiVO5ExEXSspG8+FKk+FVZY1jTnDnGlOIs4GvJ25LjuzacaeCoWZiEvlvycRBgtddr6s19rObKCwnOG5iOunct++5fJd+J2fuEWM0Xu6AyDlnz1XhCrBlYhdTiLWnRUbpvw+apKluZ+TiMNKREJnJtvFLAMv9ulEk88Of+wku19d0mLn25te9gL8939wG6Y6LbF2eTRlVu6LB4dzv3spd/ptNzszdQK0w0CMi6Zo3s7M87vL1LA8d3QXtzMvzXRwgG9eHl3ZFCTiTItfWy2DdTJdX8FAm6d7ocOTiDsMNpmIQGZpfkqhRKQdh8v3mOVZUDPwua16mXtaOJdlB5KdmcjPS5emsTjdwQFJtUeDqG4XpiklYmSqRJTszHQsRIyeWR/gFLdo25CITpWIhjZ4OZeyFQaVGplluDwmW5xe6wvV69X75rBntov5XhtpytSIZLcH4Ns8HUDOoyuyXJIt2dTObDO2urTVm6rNh/O9XnYl2619asjOXEWJGDlonjZSIvLrW2dnplKVham2ETkl7MwOMhEHBrmB4nWI/EK9ndkmt01+fN2KKZE3WnLveg0n1L72vZO4j4951+wvWSAXwCUxPy6ePsuuqYNL0wjDAD/2Qraw/IvvHtmxi48kSfHn3z2Cx0+uCddH0zDdUFmSylWKsCFUvhbzp46bMZ7ZmUmJqLMzL7GvmyvqxwDAMieyFy41ewFcCTYLNoeubdwwzDdTYoar+c4+lan1bOzMAObabDyu9TMTLcY6EpHnIq6fHPnRG248gA/91Euwe7aL119O+ZU9QeYqwY+r50iJCG4/NyJ9SYl4TFIizl+c/dxQhQhAnH+9ZPJKxMe4EnFvepZ9QyLiae3yaHIQALBnPR9LdOkSxYX1t40zCsjyEPfMdUvX08M4tGcyduagZMwIuNJ3V7CKuV4bvXYLBxbY53NsZVOM+1MhkYgGc952dn2laf0RbTsFnkTcYYgNLUYEamh+qmBQiOJEkIuXG9iNgIzEm7QSkfAcHt5+MSkRVzZwykCB022qnZkmwSUf11QnxO7ZLlphIHaDFqc7YgFMCg/TZmbAbQkJlVCUtXrLJOL1F80LtUlVzGyj3CwqVTm4axqzvTaCIBBqxM89cCy3GL7z8VOFz+FRHXLrcpFSJStWMWxnNigLIjRhZy5T38jWvCBg5T7AmHZm6fpc16gBq0Acl2YwNLEzZ6UqZsfVFSSiSyWiidKOohiKX4fIRKxIItY9Jpp8XgAb1y9enMLmIMH//BaLbrhmXwUSscvfn22wQTQMcnKQ+uTVz9mP+ak2nl3exJ07dIPo5OqW2GQ9MyElvelYSCSi2s5sr/LNxngHxSpG7cyGSsQVntW2cInZC+AKuBnUa2cWRR1lhSEqkCX41KP8CVuCcNJCUtLNt7kSscaxMFMiao6LCND14nney6/Zi2/+s9fjl1/B1Zaze8uVe1wp1XGlRDRtiQaAvdcx1ezWMrM0A8D1P5T9vGO2fgSAkCsRu8nkFXyP8fiNuYh/bhKJuHu2izDIlIjz576Xa99enO6IMWI7qRGrNDMTaO155OyGc5ceAKmdWT9mtOYYSb8L50SZ3gHJqbfMs3CnSYloYtHn19dSh/2O6t5xocOTiDsMtkrEy3azSS9Ngr/2vZO48zE2YD67vIlBnKLbDgUJVwZXdmbTHLBhEo0aIMn6++jxVfEe6RpJGytWIVVRCTERBAE++M4X44PvfLEg3oIgENbmRyqQiGRNrHsSDEglFIZ2ZgB4wZhWZmB7KRHlUhUCkYgf++6zALLz+c7HduZCc5IgpVwQFI8btsUqkcUGTaaaqv/aqmJn3j/fEw31WbEK35G2IBHbrVBsXKzVPG5EBu3MNGYN4lSp2CNCw6RUBcjszC5U5zb3Y3pfi8i+JEkFuWhDdMiPr1vBRyR9GYETBAFezdWINBEfR4lY92K5DlB4Pjk7pjot/I2bmBJnp1qaZfeKyibsEmmaGs8LqZiuzkzERopVTDIRdSRi1AdWj7H/Xzxo9gK6bL4ynXIlYl3jhontVweyM2/wudLUoplFNggEaTBLSsQ67fcJqUY1c3hSIlKxSpqOKEhbYZApFenxOnClVCdl117dSsSAH1dgQvq2u4xIBACkrEjm2jdkP7dQIoZT7Pyb2gZKxMdPrqGHProD/lnNZyRiKwywe7aL76WXIEkDtPvLzGrPEQRBlot4drPR163DyXO8VKXIiXffnwGf//9l5/QQ9s310GuHSNJmiNEgNbi2ALTnGUm/OziH3XzeRwKpR4+vinG/JzIRDUhErlbcM8XuMT4XsRieRNxhsMlgArIa+qfOrOP4yiZ+8oPfwDs/9A2sbkV4kluZL9s1bSx7pnbmc5uDWjOzaJ1vamcmkBKRykhoIbY43dGWxQg7s2sSMTX/vF54aJdYjBHouEiJSO+/CdzamSkTsaS5U7qRjZuHCGwvEjErVckWzFSu8hAnGH/wJtbs9sDRFSz7na5aIZSDYYigYLGRKRHNxilSqpmUTLk8D83bmbPF2iVL02JStbwxwPL6QFIi2u1IZ2pAR8o2zXHJmWXrWzF+8zMP45//+b25XEuaMO6aMRsLm1AimuRoksJwZWOA2z/8Lfynzz8i7qEycWZDdMjPW7cSMbJQWb7mOfty/65CIvYc2bLrgFAiLmWL5R/lluZP3POsE6v8pCHnaE+CRJSv+bKxkMYCZTvzGErEOlVtAHMTta2KVVaArXPAH78D+M4f5R9z7lkAKVsQkxquDFzdN5Uy4qO2TWZBCIxJIhJMrMwETgjMtNlrqLVYxUiJSHZmrmj7zP8F/OvLgafvzj+OSMZZg89KKBGpnbne+1crtVAiAsD+G7P/33stsP+G7N8WJGJrit0bprCZc5M0jTNrfZxZH+AlIbdnt3ojGZx753rYQhdPpfzcHCqVuXRp++UinuBKxBES8dxR4E9/GvjyvwEe/Wzh74ZhgP3cJkxRTS4hclRLiOzePFsX7wrOCQfK8w+y8eGJU+t4gnMZPWFntlAi8hzVs76huRCeRNxhMG37JZAS8anTG/jiQycQccXD/UdW8MQp3kxlaGUGMiUiU4rUdwPI7Mz6x5FShXD9RczysWe2m1vI7S0pE6DHNlasYho6PARSWFLrZZViFReLsqyducTOPJ99DnWQiC6PyRZkZ75uf6ZEvHJvPlv0dTfsx5V7Z5GmwDef8GrEOhGVqGHpGjeZqCZJiuMrbPK138AGIhaYNVt+AZt25mwsuGRxGtPdLAz88Ol1kYloo0Rkz+umodlEtddphYL0O7K8gf/wuUfw+3c8id//2hPiMURomDQzA3Imogslorl6lci+T957FP/vXz+Lf/vph/FP/+c9iJM0N57JjcsmoMb62ltkDe3MALPrUUTIbLdlXNQmYztnIj5DmYi7svH9ZVfuwd65HlY2I3z78NkJvTJ3kCNwTq81vwEWWZCIIhOxxM68LdqZkxStgI9FOtWeXKxy30eBB/4C+OQvZ23MQGZlnr+4VM2TPS8ncbgSrP5ilYp25mF1nhWJyD7/uRa3M9eaicjPqUDz/s4SicjneI9/mRWoPPaF/OOEEtGARGyxe3k7YfOSujeJAptiFSDLRQSAfdcDCwdZoQqQfTVAi9v057DpXMQh4+kz67ls8sdOruKq4Ah+u/sf2TdueuuI8pXmU48FXOU7VCpDSsRnthGJeHyFjQ8jduY7/hMQs3MJj3xG+fuUz7+s2JCpFamBIhtAb4ErEXEOu3lZzNJMF1fwDMevPMIUot2AiHGTTET2/uyeYq9Blad7ocOTiDsMpm11hIOSOuUvvntEfP+eZ5aF7e2QYakKAMx122KcrdPSbFqs0goDMbnrtAJctW9W/N7++Wzhsqek2r75YpWKJOKQwtLGzkzlD3XbEgHZzqwfYkhJOddr4+oKOVnDoLDz7bDQJIt5zs48RMg/79JFvOxK1izmcxHrRV+0KRefg3RuDgyUiKfW+ujHCYIgO2d1yGId6p94mKrNZVUNkTZyu16VYhUg39BcJ0zamYFsjLv3mczG928//RCe5vZKykRcMlQiuixWqZKJ+OVHMkvUH9/1FP7xn3xXjGe9dmgdhj5pOzPA1Ksvu4qNc9TMbIuMRNx+7cyiyG1XprhphQFeeiXLIb37yTMTeV0u8dRpSYk4gUzERHK6lF1ftPBVWdJIbTe8Ca1DVp5Vf6yDWSYiJ9GSCPje59n/by4Dj3wqe8wKt9KbWpkBoUTsxmw8rS0TMRlTidju5onDCkpEsjPrirmskRgQHcPFKmefYl+HlGuZEnFIdVkETnK0OdlX9yZYyJWjoWmGZY5EfA4jrfdey/7dNV9DtqfZfHkWG42KAW7/w2/hb/3uHfjIN1jL8qfuehD/pfNvsIBV4NIXAz/070Z+h9R8z3YuZ984+XDu55cusXnXdiIRv/kEuxddK7sB1k8D3/wv2b8f/Uwu31FGWb5snaAc1bJyny4nEaeCAQ5MR8DJR4E0FcIUKnTrBmakJABxfS11SInoScQieBJxh0GoVAzUAQBbkJHV46uPZs1h9z6zjCdO2isRwzDAXLf+BbSNTZvsdlfvm8sRCBdJ6gddMzOQLSxdKxGTMUnEYULDSonYcW9nLrPx3XjxAv73V1+N977leZXfAxnbxc58cnULp9f6CIK8dY/szABTMly5d04srr/ucxFrhSj3UZyDNJbEBiTis8tsErh/vmdUrEKxAis1Z8MC2XGFJUSMrKq5mO+IE4n4xKk1UTC1u0SVPfK8vfKG5Cow3VCZ5X//HolEXO/H+JU/uxdpmgrbiakSkZSNk89EZMdFNqG3vOhStMIAH/32M0LVbJuHCGTKxTqVKkmSgi4b03H7B3lG4IsO7ar0N0mpubUNNohkxEmKZ3nmlWxnBrJj/dYOJBGpkRoATk/A6mWjRKSNkmMrxdlklezMjuz1UZKa2Zm7s9ni+tHPZ9//7h9n/7/MioyMm5kBkYnYISVibcUq1M5cUYkI5Mm16SXz3xsiEWvd3BOWS5N25lNAfy3LdTzxQP5xZHeeHVJdFoFIxIRde3UrEUNbO/MwiQgAe/lXm0xEroSdDbYazaEj0cx7/vw+vPcTD6D9rQ/hivAYNmYPAn/7jwqPgdR8p6avYN946JPAH/8E8KE3A//lB/GWh38Zu7GybezMR5c3cf+zKwgC4FXXSdfSnb8LDNaYgrTVBc48kRUYDWFxukESMTHYTAEQdOewBfa63vXwzwL/6Rbgrg+O5OyT9d9MicjW1otddl1NIrLjfIAnEXcYogr22Mt2j+4SVVUiAm7KVWzItnlOIt5w8ULu+xdJhFu5nZkyEd22M9vY3YowbAsbzoTUgRbjLnb7BkKlUl4Y809+4Hr88AsMmwNL4Kox0Ra06L9s10xuYbI00xU7ec+9ZAGtMMDLr96LMGDXHGVbeowPYWdWnIMyiViW30qTwIsXzSbDCyIbtv7zkOZVZTZSORORdsSfdylTb3z0289UtjPPOlLgmKr26O/f8zQjEb/v6j3otkJ88aETuOOxU5Kd2VSJyMmpKBH3mbpgs/lFSkTCP/3B64Va4N5n2E76dIX2+ikHtstYVoEZkOoA8Ldfehn+67teiv/zjdeVP7gA1M68HVTmMo6tbDLiJwxGNvVuuZwrEQ+fqTUjejtg0krEOJaViPrr60ruSHnsxFrhNb4xhp3ZhSK7ZUIiBkHW0Lwllas88unMNkt25kUbEpG9V51oHUBa3/WWGhxTGWQSsYKdOSMRa7x3mZCjcjszEbsAcPKRfIkFKRFN7MycRGxRsUqU1DrGkBIxaBl+XguXZu3FFz2PfT3AcxKHsgS1IBIRG2Kj0zXSNBWbov04we9++TH8cOvrAIDp1/0yMLe/8PdIjLK8wO9py4eBBz4GPPlV4PDXcMnRz+HNra9vGxLxSw8fBwC84OBS5sbrrwN3/g77/1f9MnD597H/H7Y03/M/gCf+KlMiNkDw0sZDUKZeDgIsg21+7F17hH3vvj8bicgSjd8mpDYnGufbvp1ZB08i7jDEFUipy6Qcn1fy3YnvnVjFYxWUiEBGZNV5oyZytEx9I/99KlUhyBN8UzvzwLGdWRACVZWIwyRiBTuzC9XewCIvq05MO1RX2uCBZ0ebmQl0Pd3ECZ39C1N47fVskkJWCo/xIezMbUUmolSQUmYFOqJQGqng0s4sNh5slIic/PzxFx/EXK+NR4+visWhrZ05y0ScTMYeKc0fOMqusdfdcAA/wAuK7nrijGRnNjsuWb29WjMxSpspRpmIUtbhVXtnsX9+SsRx3HeEkQRVlIgubMCRBYFDCIIAr7h2n1X5l4ypbVqsQgUjFy9NjXzOz71kEb12iLPrA3zvxNokXp4TxEmaWxhPWolYZvG/fPcMOq0AG4MYR5ZHF/QU6TJtZWduop255HqXibT9z2XkTTIA7vtT9j2yM9soETmJE6YRuohqO77QlBDQQc5FrGBnnnNBIhIJqMtEpNe9cYapvAjRJnD2yezfZHc2KVbhmYitJLv26rQ0twSJaDheBwHwv/0x8Lc/Auy6gn3vlp8CXvGPge//BfM/3CUl4iZOr22Z/94Y2IoSEcF0+Z4ZXB08gxvCw4wYvv6HlL/35udfjNffcACvffUbgDf8GnDr7cAPvg/48f8ifu9QcBxHljdr36Csgi88yKJSXiMXdD78l8DmWWDpEHDjjwLX8FbtRz6dPebEQ8D/fBfwP/5ulonYwJgv7MwGGw/HQ3ZM5xa4hf7w13HD7iDnROr1uSPAqP2cra0XeBmTz0QshicRdxjiCqTUQSnH52+9+CAOLPSQpszeFQbmC2eC3NBcFyj/xmQxdgMvU3n51fkbsazaK6y3l9BUO7OwJo6ZiUhYsFigzTjKygJkO3OzQ4zLY7IB2ddeeGhp5GevvG4fggB4440Xie/97ZceAgD8z289Xbst5UKF3M5chLluG3TZrZRMEMjObFoI4UKNDbAdc1MbaS4TkSsRF6Y6+N9edkh8v9sKrTYeALmd2ZUS0YwcJfvxVftmRRPfPc8sW9uZpzotYWmuuyHdtAQHyNqHAYiIg6v2jq9EnHbQzkz3LaC5jSJXLdPjQpSqLI06NrrtEC84uARgZ1maj3L1JWEiSkQLlW+7FYrNuyK1vyhWsbi+Zhwqso1JxJ7ktjn0MuAFf5v9P1maK9mZs/iVWWzUdr1RsUo6DolYWYnI25lb7H3dGMT1Nf8KclRzH53eBYCfp89+N/8zORexghIxjB2RiNz6GbYtNn0ueSHwnB/M/j29C3jd/wXsudr8OYhExGYjDcBAfp72P37m+/Bbz38CABBc9RpgZrfy9y5ZmsZ/fueL8X3X7gNe/n8AP/AvgZf9A1bCctWrATASsR8lONkQIapCP0pEZNlrrpeuo3v+J/v6vL/Jxptr38j+/eRfMes9ABxmqkysHsVuPgVuUokIAzXsRw/+Mv5J9DM49fbPALuuBJIBpp7+Gm7kjsRpbKIV8zgLo/ZzvvHQYueGJxGL4UnEHQahRLSY2B/kduZWGOAV1+wTljeABYV323aniYsFdGyhRPyXb3kevvHPXofnHcxPMg4smtuZ6ZhdKxFtJsJF2DffyxWG2diZaXFZd8sqIBWr1JBzaIPpbdDOnKYp7nqS2YnIzibj/3jdtfjWr7wBt12d7Ya9+jn7cfHiFM6sD/Cp+4429lp3MkTxg2IsDMNA5LucKSGPjiyzycfFhhsqws685YaUAsptv/NTHbzlhZfif3nhpbkM2L/z8ivEdbl7tmtdcuFCiZimqTHhNkx6Xr13Tqh6731m2bpYBchyfuqeKIqWeoPNFNnO/LIr2dhASkQKZq9EInYdkIix+XlYFzIydHsVqzxzZrRURcaLyNK8g0hEamam6JizGwOjbNk6EVtsLANZNnERiUjzBYp4MYErO3MUG2YiAnki7dBtwE0/DiAAnv4GszKLYhULEjFsAW12Ls8GW/UVqxAhULWdGRgiEZfMf4+r6aZb2bHUlekbmGQittpZhuOR7+R/dlzKRRSZiOYkYpD0xRqgznIwykQ0tjPXhV5GIp5ebYZ4o3NhrtfGvvkennv6c+wHN72l+pNyNeZVbab+IzfLpHDXE6exuhVh71wXN13Cx42Ns6xEBWDEJ8DKcJYOAXGftYgDwDN3iefZ32LjZxP2XqFeLilWAYB3v/PH8E/+6a/higO7gGtez7756GdFLuKegG3Eoj2V2yhRosPmXjMhI7JXHUQT7QR4EnGHwbadGQBeeNkSggB4zXP2YXGmIxZkAHD5bjsrMyCXCtRYrGIxYWwNNTETcpmIw/X2Q+hy4sF1sYrtRHgYnVaYIwhsVEUzDgk3UqqY5mXVhRnezhwlqfNmbRWOLG/i2MoW2mEgVCgyWmGAXUMW0lYY4G0vuQwA8OE7t7+lOU1TfOWRE07sunVhYJCxR2q1syXWjGc5iXOJpRJxZcNN+QjAyg/L8O/edjN+820354jCixen8SM8g3T4PDSBCyWiDTk6I1kOu+0Ql+6axnMvYbvNzy5v4iRfeNgcG5GIZYpUW1Bum02xCiApEYca6ysVq3TqV2fnzsOG9omIROzHSX0qojERxQnufJxtGB1UkIgv5iQibSztBBCJSHPFNG1eqRFbblQSiVhkK8+KVeznT1tRUiuBmlMili2ecyTircD8AaYGA4CHPwWs8aZ3GyUikMulq5tEHIuUGlOJ2E4jMY7UJXLIWqdLjosslM9+h30lEpSUiINNoL+af6wO3M4cRJuiCHKrxg0WsjOHpnbmusAJnk4QY/ncuUb+JBFEc702I3VPPMjOmevfXP1JOYl4EMcBpBPPRfzCQywP8VXX7c+cbw9+nJGF+27IinGCIFMjUi7i03eL59kXsGiVsjlzLUj5+WwwZrRbYeYwFCTiZ3AzFxPtAScRZ/cBJhvnPRZFNcMLpurkM3YSPIm4w5CpOcw/2psuXcRnfuGV+M233QwAOSWibakKMPliFRVyJOKsqZ3ZcbGKxSJTBbl12qadWZABg7j20PfMSjoZJSIwOTXiXU+wxeJzL1mwWvT/rRdfhjAA7nz8NH76D+4SFtrtiM8+cBzv+OA38BMf/EbjChRTkIq4o1FSL84YKhHP2ikRXUQ6AFmsAzCeAuznX3ctLt8zgx96/sXWvyuUiDVeX7mm1RIV/ZykFrpizwxaYYD5qQ6u4s3n9BaZFqsA7pWINsUqh3bPiAxLuc2dPaYKicgLSWr9vLLmc1sla1XIY+nmhDaIZGxFMX7uw9/GVx45iVYY5NsuJZAS8Xsn1iZi+60LR5c38S8/8QDufWZZ5EBesXcWC3zOcbrhY7MtpRMkYpEScVClWCWba9VN0Gd2ZkMl4vwlwCLbhMTVr2Vfv/Nh9rU9ZUZKyeDlKrPYrM/OXEuxipyJuGT+e0SExf1sg6+me3Mqjqvk3CGLMqlD6XM68SD7SnmIYceMIOVKRET9rLm+ViUikb72G41joZvd89bOLWseWB9onjY/1Qbu+zP2zWteb0dUD4Nfj9PpBnbj3MRJxL96lKlcX/0c6T51L7cykwqRQLmIj34G2FrNtYjvSjmJ2MCmkVDD2kYgXPH9jAQ+exgvWWDrsX0hJ6RNx0JeWjWdsE2nupTLOw2eRNxhqNLODADX7J8XC1+ZRLxiDBKxzouOhAcmdmYVDiz2MNdrY6bbwv6FEhKREw+u1Ww2mVkqyIUx8z3zhTMtyuIkrT37UdiZG1Yidtshuvxv1l2SYArKvnpRgZVZh0uWpvHuH7wB7TDAp+8/hjf+uy/jiZPbM4z/G4+zCcl3nzqL3/vaE5N9MQoIokNzbZEScXlDvQCO4gTHzzES0VaJeG4zqpWgt1UiqnDl3ll86Zdeg9tfc43174p25lrHd/OijllJbU2ZgQByCvpuO7Sy/roiEW3GdxrHX3ldZmVbnO7kojdsSA7CtAslYg2bX7boSZsB2yEX8R995Dv45H1H0W2F+O23vwgvPFQ83u+e7QqCmzKpzjccPrWOv/m7X8P//eXH8NN/cBe+d4IRcQd3TYtipjMNl6vYzp2u5qreR0+MkogU6WIzZkx1QiFoqVOVHUURwoCPh6Yk4qFbM3UNkVNPf4N9XbjETHkjo8tUOLPBJtb79dzDsmKVMW5cYyoREQ9qL34ME4NMRGCUvLiWEzUnH2YNiyIPcY/Z5yVIxEyJWGfUg8hEbNrOHLYQtdm6c2O1IRKR7MxTbeAkV4Ze8YrxnrQzxch9sFxE2niZFE5wh4bYmFw9ATz2Jfb/w7btK18hSDj89R9nikAAiwlb3zRpZ7beeOjNsXgHAJedugO/+Ibr8FMv4Mc9W7zZN/ocbAzscRKx7nzznQJPIu4wCIvHGGHn+xemcICTbIcq2JkXnBarVH+OXruFP/r7t+KP/v6tpYoOIqIaszOPQY7KCkurTJ+OO9WerFRpGjRJdJH1aIK7D7ObbFEeYhn+/iuvwsf/4ffj2v1zOLcV4f+959m6X14tuP/ZFfH///ZTDwl723bCwGAsXDJQIh47t4UkZedyWSETgUjEKElrndjHE8iiG8ZMz7ES0YJEvHJfdn+SN792zXSsFHLulIj6XE4Zb3nRpfidn7gFv/Sm63Pfl4nS7VKsQgSOqrTIBYIgcKKqrIK/evQk/vLeo+i0AvyXn3oJ3vTci7SPf/2NBwAAv/oX94l8y/MFj59cw4//ztfw1Gn2uo8sb+IT/L502e4ZERvQvBLRznVD+aKn1/ojr5XOJxuSPgiCjKCv8XxMYmneUqbAee5bgEteBLz072ffO/gSkecFwN7KDOSUiElaT8FgVpIwhj12bBKxL7kExp8fpmkqilXK7cxDBR1XvpK9rsE6sPyUXTMzkJGIcV9EYdSlREzTLJczbDesRAQQdxiBs7V+tpG/l7Mzb/K5LWVYjgNuab4sOI4T5yZbrEJRLYvTHWBzGfjv72Dn7iUvGi2+6c4yNR8AfOXf5X40G7H1zcqm+xzcgJOXlYhssjQ/9gX8w9ddi++/hH/f9PripVXdOFMi1u3Y2wnwJOIOw7gZe4RfeP11eP0NB3KqCFNQLp+LYpVxyDYAeN7BRRG0qoMoVnFNIibjk75kZ57ptqyUf+1WptqrkxAAMqVK08UqQEakTmLnaG0rwgPPMtl8FRIRAK6/aAFvveUggDxZt12QpinuP8Je16VL09gYxPjVv7hvwq9qFAODhvClacpEVJNHlId40eKUcYv6rNT8XOdmSixNYiZwaQGQlIiOMhHLxvhZaaF/lWT3vSlHItotfJyRiLE50dFrt/ADN10kXgvhKokorWJnzopV6ruXRRUK3OqAC0LUFmma4n2fZBbEt7/scnz/teVzpF94/XV47iULOLXWx8/817u3hZLSFB/44qM4fm4Lzzkwj3/8xusAQDTEX7ZrGrsNc2Xrhm0p3Uy3jUt5HMWjx1fx/3z5MfydD30D6/0I6wMqVrFbrLooV0ltSMTLXgL89BeAy78v+167y5REhCokolRuAdRDkgprYm3FKjYkYmZnXhBKxPHH+iQFWiAlYslnJZMXrS5Tqe25lv37xIPAGi9VMbVbtjIlIm2u1DXGy8fVuBIRECqweKOZ+S+dCwtTHWCL217l5vOqkEjEkw2VxBRhcxCL5u5FrAJ/8KPA4TuA3iLwN/5t8S+RpXmFN7y32Tpzus/swWlaf1zPMGyKVUZwyc3s6+nH2Nc1S5Ken4OdAVOux0laq5tjp8CTiDsM47b9Ev7Xlx7Cf37ni3O5L6Zw0s7MF8+mi/hxQQq6um2+w6iSYTkMUiLalKoQsjbjegm3gWjGbX6ImeOW7klkWHz3qbOIkxSXLk2LXLMquPFiNoF54Mj2IxGPrmzizPoArTDA777jFgDA5x86XiupVAdELqfmHKTcPN0CWDQzW3yeYRiI63HFxWZK2FwW3TBctDMTKRUG5WP8jGxnlopHnntpNum3aWYGgAXHduZx7scyibjdilWa3iSa2gYNzZ+67yi++/QyZrot/NxrzeIAprst/O47bsGumQ7ueWYZ/+v//XU8eHT7je1FeJxHavzca6/Bz7zq6tz5eHDXDJZmSInYcLFKhSgYykX85L1H8d6/fABfeOgEPvvAcfFcttfXtAMSMUlkErEigXPVa7L/t2lmJvByi4UWu/fVcXykKgpaFQgBwvRuYNeVjBg1JQOAnJ25zvVJlCQiv7K0MEYmBxcuZXkk+57D/n3iwepKxKh+JWKUJOgIJWLDxSoAwilG4KSbKyIP3yXkdmZs8XGZk0hjgZOIh4LjODXBPFxSIQYBMP/N3wKOfJudjz/1MeDgLcW/ROUqhCtfBQBorZ8UG7muLc2ZernCOLjIhBg4+xRjPAWJaGpnZvPJsH9O3GO8pXkUnkTcYYhqyNgbFy5KBZKGFy0dYWd2ewOrQ2FJrZC7qzStOpgEA9l5OBE7M9ktJ0Ai3l0xD3EYN3AS8fFTaxOzZatAKsRr9s3hpksXsX++hzQFHjzaTJOeKQZkJdWMGUuCRFSPVUcsm5kJLsbBOjJUx4XLdmYTi7a8WXK1RGgsTHVE3s+2USLW8HnVZWeutVglNv+86oSLfEcbxEmKf/Mplpn1915xlXG8AcAIt99++y2Y7bbwnafO4od+66v4b19/0tVLrQ1kY75s9wzarRD/+I2M+JjvtbF3rovdsxQJMRk7s42Lg0jED33tcaGm/Ounzoqfz1heXzMdNhbVeW2lsTQGVSURKRcRqGhnZu/TYot9pnVcb2EdduYwBH72a8Dt37B7HtnOXOMmc5ykaAWcRCwtVpFIRCI49t/Avj7711Imoi2JmCkR62pnZg3hk7Mzt6aZynQm3WikwCOXiUh25qk6lIiXA+Ak4gSViCuS0jKgkpTX/l/AxS9Q/9KeqwUJiqCVZXiunRAbR64/m5DOwSrjII170Qawfjprqje9vvjnH2ytOHFX7hR4EnGHITZYOLuGCyUiTRibUiJ2RbGK28VKHYvMl1yxG7/4huvwnh+60fp3XeykA5IScQK5bTTgr05gwCci7QUHx2h1A7BvvrdtyTkiEW+8ZCH39f5tppo0aWemyZBuAUx2ZtNmZkLWAumCbJvc+O5EiWhR1EF/f/dsV3x+BMpFHP5+GdwVq4yfDSsrv6oUq0w5sABParPSharSBnc/eQbfO7GGhak2/v4rrrT+/duu3oPP/p+vwhtvPIAoSfGv//LBRpQ2ZUjTFHc9cXpkw2orinGMl0pdxjcrf/Cmi/D//ZHn4jffdjOCIJhYJmKVDVgiEeVoq+9wErHbCq2dE9n8qb4xPpeJWMXGBwB7r83ampcut/99bmdeDGu0MwtCYAwlIgB0Z8TrM4ajdma5Sbu8WEUiL5YOsa+HbmVfn/hqBSUi39RMY0zzt3SzNiWilIk4ATszKRHng41GyDdaq85PyUrE+uzMh8LjOLM+QOTY2abCspyHePap3GtTIggyNeL+G7NxZO24tPnudszPGsIrnIPtHjDHsojzmaN2xSrYOieRiM2q7c8HeBJxh4EWY02RbUVwsXhOalDs2aApJWJSw2IsDAP8w9ddi++7xj6/kuzqtRer1FDwUxVzU5OzMz+7zAgnUoeOA0HObbNcRHo9ZLmmr9vtdQo17LhKRG5nvsSSRHRRMBU1PA4Wwa0Ssfy4nnfpIg4s9PAjL7hk5Gc/8oJLMD/VxquuM5wochCJuOJMiVh9qnXZ7hnxvlTKRHRAvNVBjlZBFr8xGRLxCw8dBwC89vr9Qmlsi4sXp/Hbb38Rpjohzm1FeOzkaFtw07jz8dP48d+5A3/nQ9/MhccfObuJNGXnEDkdgiDAO7/vClEWQ5mIZxovVuG5nBXszABwaDdrgL33CGuArRIVQKR+nddWytt+UwRMeVcFQQD82AeAV/1T4OrXlD9+GLxYZYErEevYZK7ctFoHSIkY1VusEscZ2WanROQE78GXsmzD1aPA4a+PPk6HVrZRNtdmx1KbEjFOhZ25NQElIhF4c9hoxAZMgoP5bgD0+Xhsk7mpAifqLsYptBE1vtFCIBJxYaoFLPOMQyKydbjlp5ii78U/BczxOdXaSaN5cx0ITaMCVCDF7/LTkp3Z8PoiEnlzBfPc3TaJNeV2hycRdxi2g1LFxeK5rsIYUzTVzjxp+zlNnNdqzrPL2pknoUSc3IB/bIXtmh5YsLO+FkGQc9tM4SdIxCEl4n3b7HVm7cy6TESyZbDJXZKk+N6J1dxCmohhezuzw4KpCZDzBKFEdNDObHJce+Z6+Pq7X4df/ZHnjvzs9TcewF//8zfiB27St+UOw50Scfz7cacV4tAeRnhUsjN3eaNxjUTHwEI5WieEbc+xQ0CFLzzISMTXXL9/rOdpt0Khmv324bPjvqyx8cgxpnb/xhOn8dkHjovvP3V6HQBw2e5pZQarUCKu9/Ho8VX8wL//Mj723SOOXzHApxhW5+B1++fRa4fotUO878efDyDL15wdg0SsNRORKxGTqipEwpWvAF7z7vJyliJwO/N8wJWINdqZJ1LU0WXjJ/przLKKujIRU3OiQyYvljiJ2JkCLnsp+/+TD/PHWSoRAcy22GuoS20eyXbmCRarzAfrOLXaAInI1wq72pLqsY5MxLkDQHsKrSDFJcEpnGzgWIqwssGO79LeJjBgGbdGMQcHngv84v3AS/5epuBbO4Fd/BpqTIlYVb0sSMSnKmQi8s8/GWD3FJvveDvzKDyJuMOQkW2T+2gX+S7FVpTUdlOjxVjYsBKxH7klEZvOehyGi0kwIBE4EzguYWdumERMkhTHVtik+yJLwqkI21GJeG5zgCdPsYXlDUNKxAefXZmYXaMIWTuz+hwk8ujM+gBpmuJ3v/wYXvcbX8Kf3PW0eMyzZ+2LVQCZRHSQiThBJSJdX/0oqW2TJbKM4dCVylQpnNnOmYgA8Krr9qHTCsQ1ZwMXdmY6D5veJHKR72iKI2c38ODRcwgD4JXX2ildi3DzZUsAgO8+fXbs5xoXZyRFyb/51IPi833qDCcRd80of5cUimfW+viDO57Ag0fP4cN3Hnb4ahnEmGGxobI408FHfvpW/On//n146RW7c6R8FSXitGiqrz8TcWwScRxwEnGOSMQaNpnJzjwRJaKwJq7Uel+OkxRtTiKWWs+LMhEB4MpXDj3OkERstYGAjb+zIVci1rReiZMUnYCspJNQIrLPaw4bOL3m3s5MSsQlbt9Hq5dlTo6DIBgqV5lMLiLNa65o8wbw2f2MwLYBkW9JhAM9dhzOMxF5GVNlIpsUv8cfAGL+3pteX905AOzesr/LyFJvZx6FJxF3GLaDEnG+1xaLproWZUnTSkTKRNzhSsRZV3bmiSoRJ2NnPrm2xXamA2CfReC+Cs+9hClVthM5R/mMFy9OicXj5XtmMdNtYStK8MSptUm+vByiuPwcJBVNP0qwOUjw7cOsGOebT5wGwIgXstNcsmQ36aLG33qzYe0tfHWDIhCA+hbPNpmILiDszJtRToU6Luq6H7/nh27Et9/zRrGxYINpqdG4rvw9Io8vpEzELz7EgtlfeGiXGDfGwQs4ifgdqdhjUpAzYR8+too/+/YzAICnz5THc5Ca+9RaH5/nSs0mLNpxxaiAFx7ahedesogwDHDtgczeLI9rppgRpHZ9Y3zK25nTSZKIPHNw1oESsbI1cRx0OYnYX8VCze3MpEQsJUe7c4KcxS4pT/WKV+QfZ9M6zdWIM2323tZFIsqt05WacceFUCJuNKLeo3zMxYCNd7WUqhBkEnFCSkRahx8MOIlISlgbtHtAj61JLm6zdYB7O/MYxSpARtY/+x32tTObqZJL/3gozsO9HUZAeiXiKDyJuMOwHTIRgyAQN+q6BhmhRGysnZn9Hdd25km3rboqVplkJuIs2ZkbHvCPLbMbzd65nnVAexEu3z2z7cg5UaoiKaJaYYDrL2I32+1kaR4YNP7OdlviWj+z3sdTfNH82En2fh/mdr65XlsQTaZwYWcmC98kN4m67VC8Z3XlItq0M7sAfbZxkta6+VAX2RYEQa6V2gaz0u+t1v55TYZE3Kwp+8sGRJC95jnjqxCBTIn44LPnalWJVgHN0y7lua///nMPI01Tyc5crkQ8txkJ0vHYytZISUvdyPJhqz/Htfszy2I1JWL986c05pmI20CJOJMyErGO42vVVaxSBVTEsrUqZSLWpUQkhWXJcQUB8L/8DvDm3xCtvQCAS28BOtL1ZaqUAkQu4gxXItbp/GqDX78TVI4yJWJzdua5YJ3//RpJxKWsofnkhBqaKev5YvCG4sUKJCIgchEPhGyeX7dzYxg0ZoydiXjsfvbVhqAHxHm4u8XGQU8ijsKTiDsMpJzrTkABJkNUwNeUmUBcXlM2PpGJGLktViH7+aTtzHXupAOTbWcm8sb1ImYYR1fI9jq+lRlghDnZF7cLOUdKxGFb5Xa0XkcGduYgCLA4TWPVAE9z+95jJ5iS5mGeFXbN/jlrmywtVupogSSQEnGSm0RAptqpq6F50orsqU4oxvw6J8bbwRkw1WmJvLfTNSkhTPJGXcBFSYwJtqIYf/Uoy1R69XPGy0MkXLo0jb1zPURJOvHxnZSIP/3Kq9Brh3jq9Aa+d2JNbKoc1NiZF6c7KBoaHz/pduOrjo2H63JKxO2RiZgpESdA3hB4sco02OdfSzuzUCJWKyQaC1LTKs0P69hkltuZjbInb/hhli8no93NWpqDEJjeZf4CuBJxV5e9hroItyjJilUQTuLzkotVGrAz87XCPDiJWKcSkav+LgpONVISUwSa0+yNiUQ8qHm0BtzSvCdg9yv3mYh8vtsek0RM+JzOmkRk58GuFjsHfbHKKDyJuMNAO1EUQD4p1J0xldmZa3m6UnTa7otV0jRtXGE5jGkHJQmA1Iw7ESUiV4BNiESso1SFsN3KVY6cZYuKQ0PKlBsvZjaH+4+s4NzmAN984nRt1smqIKKjzFK/i2e4Hj69JnYaz6wPcGatj4ePMTJRXmyaQrTUb9SoRJzwpgNhViyez29lGyEIAmE/r5NErKOduQ7sniPLaT0Lskkp6Ol+1bRy75uPn8HGIMb++R6eW8FSXoQgCHDzZWzcnLSlmTIRL16cwgsPLQEA7nz8FJ7hmyo6O3MrDLBUoNL+3gm3luY6zsHrDmRKxNkqdmYHbeGkREwmodgjcNJtOq2PRCSybTLFKvz+3V+ttZ15EEu233GUo2Rpnt5t18jNm5P3z7BrgOag4yK2JUfrBikRgw3nFuA0TcW5MJM6UCJO7wYALGENpyakRKQ5za7oKPuGSTNzETiJuCs9C8B9JqJQIla2Mw8pLk1LVQj8PFxssfPCZyKOwpOIOwwZiTjBCQiQVcDXNMhMqlilroyRIsgcy8SUiJ36g8EB2c48iUzE+naabXCUt/jWUapCuJy3stY1ORwXR5eLi2NIifjtw2fxut/4Ev7m79yBj/21+4ZOHYQatoTIprHqr59ezn3/sZOrorVUXmyaok7bFGE7xFUAwEyvbiXi5LMeF6fZMblQIk5iM0XGnlmW0VrXgizLvJ1QJmLDxSoUJ/H8g0uVintUEOUqEyYRSVGya7aLl13JCiC++NAJkUemszPT7xFeeiVbND92oiEl4hjnoJyJOFaxSp2kNlciUmnGRMCViL2EzWnqOD5SIk4mY4+TQrISsR+NvdG5OUjQCmoojLnuTQACYN9z7H6PKxH3TrPjoPnZuIjiFB2yM09QOcqUiG5JxM1BIsaSqWQt9/drAVeWLgWrE8tEFJmPW5xErGpn5iTcQnwWALDsPBNxzI2HmT25FnOrqABAKFIXeDasVyKOwpOIOwyUFdRrbxMlYk2DTOPFKi33SkRaiAGTIwXc25mbPy5hZ675mMpwlGci1qlEXCBLrOMdP1M8y4nS4ZKR5xyYRxiwm+zxc+x9oHysSSEyVCJS9MI9z+RJxO+dWBN25msrkYj1ZyLSpkavPdlNIldKxMmSiPVfa5MujCHsmc3KL+pAdlzNzjPIYdG0EpEWYbThUBe2S7nKGX5e7Jrp4GWcBPwCz4BcmCrPg6Vylav2zuK11zO792OO7cx1RCBcujQtxrJx7MxuilUmaWdm5GovZgqcWuzMnBBoTYREpEzEc2KTOU3Hz4jdHMSSYm+M4zrwXOBnvgK87b/Z/V6LbQ7t4dOxOpWI7YnambNiFdeZiOe22NgeBEAv4urpqcX6/gAnERexipMTszNzpeUG39ivUqwCAHNsbJ+NWAHhGcd25hbfeKg8ZgRB3rpdMRNxjsc6+EzEUXgScYeBFpkTVyJOkxKx7kVLU+3M7otVYmkXdGJKxJ6jYpXEjMBxgdkJKRGP1ZyJCAAL0/UTUVWxthVhhb+Oixbz9rbpbgu3XrUHnVYg1JN1EUxVMTDIRASysereIRLxoaPn8MQptoiqYmcWLZBb9ZFSRJ5MepNIZCLW1c5cg6poXNQdwQEAcTK5zRQZe7idua4F2aTs55PKRKRIAtrUqQvPP7gEgBU4uc6XUiGKEzGuL8108cJDu9BpBeKaLFMhAlm5yqufsx9X7WUqtsec25k5KTWGMjQIAlzDN4iqKBFdZCJiO7QzcwVOJ91CB1FNdmZerDJhO/NUpyVEAuPOq7aiBO26bL8XPQ+Y2W33O7xpdk+bjR1n1we1bLBEcYx2UAM5WhUSeXNmvZ9bK9UNOgfmem0EWzw2qFY7MykRJ2dnXtkYYApb6G4x8q96JiIj4aa3WMvz8sbAWWxRmqbjF6sAQyRiNTvzHM/KXNkG67DtBk8i7jBsbRM78yLfma5rQRY3rETsCCWiu5uXfGOclFJF7KTXvCgztZK6wNyEMxEvqlGJWGd+z7h4lltl5nvtwqbY3/+7L8Vdv/IGvPl5FwOoz+paFSbtzEBmxaNcMFIQfu6BY4iTFPO9dqXP1MVnl20STfbWTQ3o6zVdY/GElG0yXJCI2yYTkduZ62qHHEyIHJ3uTCYTkZSItKlTFxanO4KAIwV305DP96XpDqa7LbyAk5uAPg+R8BO3Xo6XX7MHf+flV+CqfYywefzkGtLU3fyprjImyh3ePdMteeQo6Hysk0RMYmrFnWQmYkaizGO9FjtzLYRAVZA9Ne4D0ZbkEhhvrN8cxEJhOZHPi+ftzcQrYk5wrAY1YhJL78sE7eczwRbCNMaZ9b6zCAsSG8z32sAmJxHrLFYRSsQ1nF6dTCzRysYAlwasGAzdeWBqqdoTcRKuw0nEJHW3zkpSIAz4GD+OpV62blcsVqGszFWfiTgCTyLuMGxG26NYRSgR67Iz04Sx4UzEvsNMxByJ2NBxDWPacSZiZwKLZyK4+lHi9PMbxjFOsh2oUYkoyjm2wc1LlYdI6LRCLE53hBK06dyyYZi0MwMYsep9/zVsokEqxGsP2DczA5lq6dxmVNtiertk3pISsa6MGKFc3gZ2ZiftzBPORNzrSok4oUxEim1pCmRxr1uJCNQ/V7KFvHlCGcYvuypTRF2maWYmvOq6ffjDv3crLts9g0O7Z9AKA6z3YxxbcUeMJjWdg//wddfgl970HPytF9tb/GgcrFN1H0VEIk7Qzhy2GNkAYCFYq6mdmeebTcIe25WcBFurtUWNbA7izPY7CeUoVy4GG6fFRmcduYhxJL0vkzgPpc9rFhv43//wW7jxn38Sf3LXU7X/KdHMPNUBtlh8Tb1KxCUAjBBrD8417tCJkxTntqKMRFy6jNl8q2CW2ZnDtRNCgOIqF3EQJ+LaCltjXFs1kIhTMVPVbwcxx3aDJxF3EOIkFcq5qQlnZtW9ICMlYlPZgV1uF+zHibPd9O2kRFyreTdJBO+3J9fODNR/XCqsbkViR65OJeLCtlIimhXHiHNq29iZy9qZ8wqUV12XtzxUKVUBMgI4TtLalL6bIhNxsrfuual6ScTtlIlYJ4k42CaZiKR2q61YJTZT+daNqUnZmfn4W5YNWAUuWsFtIEpVpHGQylUAMzuzjG47xCH+Oy4tzXWpfC9enMbtr7kmVw5jimkHduY44ufBJElEQOTCzWMDG4Pxx3lSIna6EyARW22gzRW1/XPCJTBu5M3WIJGUiBP4vLjKDeunRRZ3HbmIaSTdJyZB+ra7ohBjHhv4xuOnkabAnY+frv1PkRp1bqoNCDtzjcUq7R7SDot4YJbmZmMraANMkIhVS1WAzA68drL2yLJhyHmj3a792Cwg25krFqtQNqwvVhmFJxF3EGSLT2/SSsSZnaFEBLLJat3IGqdRa+OjDVzYmdM0I7ObXmQC7LMjJW5Tgz7t/s5PtXMk5riYl8gal7kwJqBjLMt8dJITVQEDw4bwXUNlCS+5cneOpKtSqgKw94HII8pUGxfbJa4iK/ypS4k4ufgDQkbm1DdmbJ9MRN7OXFexyqTszKLIoulMRLIzO1AiirnSZDIRSYkoj4O3XL5LjF0mduZhXMlzEb/nsFxlUrmcMmYcnI8RJxEnYvuVIZpJx1ciDuJEEALT4xAC44CIIamheVyHx2YkKREnYWemDMWNM2JztxYlYs7OPAESERAqsLlgQ2zeuIh8kDMRndiZAQSUi4jV2iJFTEHn+OVtTsBWzUMEgDlOIvbPYR9vBHeloN8cJNnGQ3scO/P4mYidiClU1/uxcDh5MFRa4X/5y1/GD//wD+OSSy5BEAT4sz/7s9zP0zTFe97zHlx88cWYnp7G61//ejzyyCO5x5w+fRpvf/vbsbCwgKWlJbzrXe/C6qrbIOadji3JujlpJSJNjM/XTMSuRDy4KleJDDPbXCKz49S4ky6RXWVWUlcgS3NTJOIxB3mIQJarBzRfFDOMZ0VxjH5R6cLiVQVCDVtmZ5YWz0HAFs20CAaqlaqw5wqyfM6a7OgiE3HC4ztlw9Vls4+3QXag20zEbdLOXNMCZlJFOFPtrJ05SVL89hcfxV1P1K9OGYbIRJyqn9hZmrASkRo2lyQl4myvjR+7+VJcvDiFFx3aZf2cTZSrRGITdvIkogslYjDJTEQgp0Qc9/jWtyJMg4093ZnZkkc7gmhoXpXuy+PbmbdDJiLWT2ckYi2ZiNL7EkzonswJnPe++Qr8+7fdDAA4XlP7tIzMziwrEeslETFD5SqrjSsR6b5yeYvfJ6s2MwPsfWmx+8ShLtsgOuvovrUxiKXSorqKVarZmduD7D426az37YZKo8Pa2hpe8IIX4P3vf3/hz9/3vvfht37rt/A7v/M7uPPOOzE7O4s3velN2NzMBoC3v/3tuO+++/CZz3wGH//4x/HlL38ZP/3TP13tKDwAZErEbitszParwuI0G2jq2l0nHq+p45KJh0HkVok4yQXm/7+9846PozrX/zOzVatV77Lk3o0bBowpNr0Egkm4CSS5gTQgPYQQbsgloaSQ5HIT0hs/AoSEEG4CgUAIYMCAMbZxAfduy0W9S9t35/fHmTMzklV2Z2Y1Z+X3+/n4I3m12p3VtHOe87zvk5WVdGPqtAPpzMDYi4ij9Qs0i9etuyqd7ouYrhMxP0uJ35nCz9tRg1UMk+eqAj98bhemVhhFRPOlLbrjwZ7jUEtndthprjsR7TkmEwK4irIiIqbphs02xnRmO9pzJBwKwuFOxEg8ibUH2vGjF3bj7me3Z/19tXTmrDgR+VjJ6XLmgZ/tfz+8EGvvuNBUmS8PVznQOr6diHmGSg67Ukp5PzpHUoyN+AxORIuVKpH+Xi3t15ufuShtC4aEZrtCzyJxQzqzgz0RYeiJaEuwiipkx+E23z/PKqqIeGqVWyvVbs2iE7HAb3QiFtn7JgYnYnv/2DoR+Ximzo5yZknS+iJO9DF33rHOsKXtGw7bBPqSKcDsK4FTbwDcvsx+Vz0G5VivVp3k9DxMNEzdpS6//HJcfvnlQ/5MURQ88MADuPPOO7Fy5UoAwKOPPoqqqio8/fTTuO6667Bz50688MIL2LBhA0477TQAwM9//nO8733vw/3334/a2lqTH+fkRpQJJqBPyHoirAzTqlA21uXMbpcMWWIJUdFkEoD9kweRRMRQjIU/2FFWbXRuOjXAt7tn22jw1d8qm52IABNsIvGo4zevxjSFUh7WM1b9KIcjnqYTsdgwea4vZS7LqeVswlHod6OyIMOBhwE2WQnb70R0upxZu77b7UR0XkS0SxgFoKWa5jm8v3hPxERKQU84McB9awanRN88Q0/Eg2qprB3le6OhpzOPv56IvJy52EQ68XDwRZiDY1DO7HKwBQJ33QOstNX4fzMoisKCVTyA7FQZKUdzIoYsLzJH+5kLKq644PFk1mPTNri7LNqDAj+bY9qRzuySnOyJaHAi2hisoiTZwkIKTiaE8/LzHlTWsDFYRyiGeDI1ap/rTOBzhKAvi07EPN2J2DbmPRHZ56uA6kQsnGDtBQuqgJ6jWFTCxNANhzrwOUyz9ppDMCC0yIqIKMvAdX8y97vaNYP1UY32RYXoTy8StqtNBw8eRFNTEy666CLtsaKiIixduhRr164FAKxduxbFxcWagAgAF110EWRZxrp164Z83Wg0ip6engH/iIHwxEKnJ5jAwAbkdkzK9GAVyy+VNvxGxXur2Y0IpW58JT2lDCyHt0IiaSxndkbQzufpsWN0wU/XpWcGu5IErcKDVUYrZ+ZOROfTmdXE3wyCVerUJNKZ1WwAO7um0JKwbve+09KZHQ5W4WWd9vVEdN5VVGRzCw4ACKsl/Xyxxil8bpfmzrbDCcH7AjmVzhyOJ3Gsi12POvpjWe0XG4knEVPvjdksZ85WWdhoDBWsYhV+H8xm/y8RrhnGxQE7nPfxpAJZUZ2IjpczcyeidREx1tcJAOiT8h10tunlzIU23ZejCb1vm7M9ETtQZWNPxJTaEzHphLuSYxBwSgNeuGUJimJfOBhHcyL63IZ0ZhuDVQCDE7F/zHsi8vFMgaJ+tkDZCM9Og2A1AGBukN1/NxzsyMr9NzzAieiQK5v3xoz0aNcMClcZiO0zkaamJgBAVVXVgMerqqq0nzU1NaGysnLAz91uN0pLS7XnDOa+++5DUVGR9q++3oIld5wSSahORIcnmAArw8znEfA2DI7H2okI6H0R4zaJa4NJjXGfx6EwrpzbVX7KHWCS5Nxn4+LNWLnhsulELLC5dNQM4VhSK7cbPZ1Z/ds7Xc6cptDh97i0a2a9GiJw6bwqfO3imfj2lXMtbYNdkxWO7jYfZ05EdV+J4ETsDsdtKfkF9GtqnsMiIqCXNNsRruJUKSkXESPxFI50sMTElJLdUBJ+3ZUlfXHKThwPVunnTkT7nG/FajubUCyJaCI79wEeWuRkT0SXLGntRuxYNAsbEkllK2ECdqA6EQsRslzOnOjvAgD0Sw65EIFhypmtOxFt6dtmFu5EDHeippCdcy29Ucul9UqCi4gOltRzIS/SA1mWUK6Gg7X02us856JQsTsOKOpxbnOwitGJ6ERPRBeSyE+pff248GyWAqbt1Li6UOBzozeawM5G+01dUadbBQAD3LBB1RxhV1XReMF5tSlN7rjjDnR3d2v/jhw54vQmCUdUICciYOj1Y6MTcSwnmR43dyJmR0Tk7gavg/2yXLKkCSh2BWFoDjAHQxLyx0mwCqALNk46EblImu91jerGMZbIOwk/b9Nxw3IXTl0pm+T43C586cIZOGWCtd44hTZNVjjcLez0QtF47omYTCm2CeBcWAgIcE8u1cJVrE9i9GAVZ3oiAgP77dmVOj0UXCgv8Huy0pM5G704M0EPVrFPtCrwuzXDWbY+Fx/vOt2+R180s36/C8d0EdHxdGaf7kRMpBRtvGqGRKgLABCSzYWU2cIQ6cxWx4eReEp3SzkRQMIFISWFCncUssSuzW0W3eZJNVglKUQ5M3PQVahtZVp67HXy8bFZiUvt7Se5ALtL7lURsUhSeyLueRF49y/2vscw9ETiKIKhrYS/2NoLqk5Eub8Zp01mn2vdQfvDzcKxOGRJFcOdciJqZe0KKnzsOCEn4kBsv+pVV7MDrLm5ecDjzc3N2s+qq6vR0tIy4OeJRAIdHR3acwbj8/lQWFg44B8xEO5E9AvQExHQB8d2rLDrjdzH3oloV5nvYLTSRMcHwfaWn+phAs4JAnal76ULby5cU5y9cmYneiI290TQ1hfVSpmri/yjlvdyt048aW3iYRUudKSTED6xjA0aZ1fbW8aStXJmx52I6ueKJmwJFIg7FNRhJM/j0o4Vu4QP7kS02ivNDsry2STM1nLmsXYiGsTz/Ybk32yWiHVroSrZ2Ye6E9GpcmZ1Em1jObMsS7YvNAxGlH6jmghsw/4Lx5NwSexzSU5NnDkGJyJgbXyYinQDACKyQ8nMgKGcuVev7rAarJIw9m1zYH+5fYCH/U3d0U7Nrdfcbe16mEqoPRFFcCKqIiLvTd1q87Wetzwq5iKiv9D+kntDOXNHTxh48hPAUzcD3cfsfZ8h6A7HUSyp90pfEWB1cUJ1IqK3GWdMYaXR6w60W3vNIYjGDPvZqXGhJ09zQZZ72PbYFZI4XrB9z0yZMgXV1dVYtWqV9lhPTw/WrVuHZcuWAQCWLVuGrq4ubNy4UXvOK6+8glQqhaVLl9q9SScNUa1flvOuB8DeFXYnSn89bvZe2XIihgURBPgE1+5yZiddRcExLGfu7I9pTpgp5fYPkgttShLMlHAsiUsfeB2X//QN7GliA7nR+iECA91CTvZF1MTsNAYgD1y7CI9+6gwsqCu2dRv0yYo9E2lR+t7yY1JRgD5bHDjsNXg/TSeQJMlWQQDQ3bgBBz8Xp0x1InbY6EQc6/Jzt0vWhF7j4l42S8S0UBV/dspLi9TSX6ediHaKiED2xdGIJtCLISLaUXETjiWdL+HjqCWdBZIqIlooaVbCqojotrnXXCZwV1GsTxsfWu6JKELfNq0vYpfWaqbJYkJzLM6O5ZSTQvZgEbEwO05E7iwrlNS/md2hKsCAcub+tgYgrjoD23bb/16D6A7HUQxVRMwrtv6CqhMRfU1YOpUde+sPddiWTs+JRwzHsFNhTJKkXQfLPOw+OVZ99nMFUyJiX18ftmzZgi1btgBgYSpbtmxBQ0MDJEnCLbfcgu9+97t45plnsHXrVlx//fWora3F1VdfDQCYM2cOLrvsMtx4441Yv3491qxZgy9+8Yu47rrrKJnZAhFByjs4xTY2quc9mMay/022g1VEEQS46GNHOQ6QfqBFNgl6x66ceZ/qiJlQnJcVx5EeYjG2k8yW3gi6QnG09kbxs1f2ARi9HyLA+qHyib5dx5QZYhmUM9cW52H5zArbt8FuJ2JUkL63fo8LXnUb7DguRekdaHdpKZ94Oy10APb2RHTyGj/U/bI9i05EfnxnS0Tk46SeSDyrATFDoSiKJvLZWc4MGAJjsiQiatcMh8dPJTb2tAzHEwZRymkRkTkRi1UR0Up7EiXCeqbFXA6WM3uNTkR+X7baE9HQt82p/aUKVAgbE5rDll4yrrrAFEeDVfR+dABQUcA+m909EfnYLKg6brMpIla6w6iDIfehfb/97zWIHqMT0Wo/RGCAE3H+hCIEvC50heLY09Jr/bUNJKJsm1OQAZe9C1wZoR6HpS523FFPxIGYGgG+8847WLx4MRYvXgwAuPXWW7F48WJ8+9vfBgDcfvvt+NKXvoSbbroJp59+Ovr6+vDCCy/A79cnoH/6058we/ZsXHjhhXjf+96Hc845B7/73e9s+EgnL3yCKYoT0c6VaCeciHxC2dqbnQnKeC1nTjfQIptoK81jISK2sJvd9MrsDJCdSmc2vl+HKjykmz5tt7vVDAlNRHTuONQCSGwSpURZeACMfRGtH5f9Wu9AZ0v4uBvLDkEgnkxpC1BOfy5A74loR+mvU05EYGjRqC2rTsTsljPzcYaijP0EJRRLaostJfn2TtQKs5w6zQX6PIdbBWi9v+0oZ46lnC2PNeJTy5klJkhZuZfLUeZEjHucdCLan84cEcmJGOqwzYkYj7HrqeKoE1FPZwYMPRFtno/xa26+oroD7Q5VATQRsczVj0mSodVb+z7732sQPeE4SjQnog0iInci9rfCIylYMknti3jA3r6IySjbH3HZ51yiO6AdhyWaiEhORCOmrhDnnXfeiOmFkiTh3nvvxb333jvsc0pLS/HnP//ZzNsTwyDSBBPQy3TsGFwlHZi0zKwswOaGLuxu6sEVC2psf31xypl5EIZNPRFT6ZeRZgveE3EsypmzLSJqwSrRsZ1gDlWCm44TEWABLN3huKPhKnpvTueOQ7tL0UVZeACYqNLWF7WlVFuEcmZAFwQ6bBARjddTpx2WALR+WR22pDM717JiqL+lHX0ehyPbTkSPS0a+14X+WBJdobh2DI4FvJTZ45KQb/Mxyj9Htsq0w4KUM/PF8k67eiKKIiKqTsQCNZTByj1MjjE3WcJREdEYrKIHnimKMmqf5+EY0BPRiWAVwJDQ3IEqzYlo7XoYV8uZnRURh+6JaKeIqCiKVq0USKkiYhadiHmJHkySDHkQYyAisp6IvQO2wxL5FQAklmQdascZk0vxxt42bGroxA1nTbb++ipKlDlD47IfPtte1QQ8YEpmiykUrDIQ52cihG3wCaYo5cx2loY5Uc48u4bdxHY12WvT5kQFaQzOXWP2Bas47wDjIuJY9K/gIuK0iuw6Ee1wfGUC/9sV+PSBZG0aPREBIKCJuM45EWMCHIfcvWRXT8SYls7svChlZ3BCvyDlzKX59rnn+fXULUta6beT2JnOHHcwTdtYacHDz7LrRFRFxLzsiIhA9gW34dBLmb2mhZThKNb6i2Zn34Ti7P7k9PipRNt3diw8JJwvj+WojqwAwpCQsvT5XKqImPQ6KCLy9471aveulGJt8TwST8ElCeRE5CJij7Vy5kRc3ddy9q55ozKMiNhmo4gYiiXBO0j4U9l3IkpKEqf6juiPj0U5cySBYkn9bHaUM7vcqpAIoLcJU9V5Dw+XtItkjImICdn+sMqMUI9D3jOTypkH4vzIlrAN0ZyIek9E64MrfqEfSyfirOrsioii7K88zYloj0gVF8ABxsuZx6QnYradiIZV87GEOw8WTSzGdafXY3JZAKdOTG8lUyuRjzvoREw535vT7oRSsZyI9iRcAuK4irggYIdbj19PnRZGOXb2REw6eI33G/6ec9SFvuz2RFTLmbPkRATsDefIBD1Uxf7PprWzybIT0enzS3Mi9lv/nJF4Ei5hRETmRHQhhXxELAnc7jgbIyXVEmlHMJQz+z1632YrC3zRhAD7y+BE5CW/VheK4pqIKJATURVIW3ujI1ZCZgKfH7hkCZ4ETzDOgojoyQPcbPvnSwf0x7sOA4nsLYApioLucBwlsNGJCOh9EfuaUVPMPldjt729KhUuIrocFhFVUZn3zKRy5oE4PxMhbCMiSNN9jp2NtbVy5rF0Ilazi0dDRygrZbGiCAIBDw9Wsauc2fl05nzf2IiIoVgCx7rYClz2eiLaJ9ZkgpZa5/fgB9cswGtfPx9FaU42uRjklBNRURTtmuHkcWin0AYAkYQYCw+AvYE/Woqxw/3NeF+4ThvLmZ0WRjll+WyC2RmKWU5SdPIan2e4Xy6sLwZgjzA6HLoTMXvHpiYiZsm1NxydBiei3RRlOVhFlIUHrSeiDYvl4ZhA5cxuvxZoUICwpf3ojasiRjYEmnQxiFKSJOnjKgsVHpF4yiAiOu9E5ItgVu9fSVVElFziiIjl6iJYLJmy7ZrChfGiPA+kKD9Gs+SWNZQ0aygpoPNQdt4PbE6XTCm6E9GOnoiA3hext0mrTmrqidgaDKbEmWiXdDuUzMxRj4d8hUTEoRBDbSJsISqIs41TZNNKtKIoemmie+wmLaX5Xm1lb3ez/W5EUXoicsHNLieiCOnMBWMkIh5oZTfn0nyvVi5oN3zyOvZORPZ+QV/mA8l8r73HVKYYE9Wd7YmoH4e8zN8KvAWCCAtFukBqXzqz04JAieYqsiNplX8m50NVAL2cOZlSLJfNOhmsYrxfLqwrBmBPifZwZLsnImCs2hjrcubsORGz7a4MC9IOxs7F8lA8CTcvj3UyGRdgYQa8H5jUb2k/+pKqy8vvoBORpzPH2LYU2pDQHDH2sHRqfxmciMb+nFbcesmkCOXMquAc6wVSKfjcLu3z2dUXUWvnkOcB1ATxrJQzAye4APelatk3WeyLyB36pbKN6cyAwYnYhIqgB1e61qMg1WNvcrYqIqbcTpczs+MhTy13p56IA3F+JkLYRkSwdGa7eiJGEylthSPfhKBhhdlqSfPuLJQ0i1LOzIXSxi57bgAipTP3RxO2lT4Mxf5WtZQ5S/0QAdiyYm4GnmzNezJmQp7DTkRjf08nJ5kFBuHBjsGHWE5E+45LXUQUI2nVjpAE/pmcFjk4XresnctWg0icXCgy/j0X1jNRoi+a0Jz9dqOnM4+BiJgl195w8BLckiw4EbPd5zEkSDmzXe4vAIiI5EQENNGvECFLIqlfFRFdeU6WM6sOs0QESMZtWQSLDnAiOnQcGp2I6kJRLJHSRHYzJNQ2NLIITkRAE355X8RW20TEGHyIodgvA1FVRMyWW9YgIrZLpdihTFL/kz0RkZcYV7i4E9GmcmbNidgM16Y/4BeeB/B1919x3KY5JABIcVbhpbjT68OeNdTjkPfMHOuFPtEhEXEcIUp5LEcbRFpcFTM2Pg6M8YSMi4i7GntGeWbmiCL6Ti7LBwAcau+35fW0XnQOpjNzsTmeVBBNWHeADYcWqpKlUmZAF/FiyVTWJspDwW37QRMiInciWhnIWqFPdUB6XbKjoRZet6yJHlbFtnhSX0xx+poB2BsaI4oTkbv17CgrDWsl2s7vKw5PaLYaRMLLmZ1wIvLzyeuSMbU8qIWrZKukuVdzImaznJmXxDrTEzEb5cy6MGr/fkmm9Pu60yK91vvRtnRmh8tjjaiurAIpZKlthT/FxklyXrEdW2UOr2GMFu21HFiXTLEKKcdFRM2J2Il8r0vr9WhlISyl9umT3A46Ed0+3QmphaswV5pdjreevj6s9n0Vv+v8DNCykz2YLbesQcBr89bioFLD/tO+Dwh1AK//D9B9zNa3bFJFRNvLmQtUEbGvCdjzbwDAbLkBjd32havICS4iOl3OzK6B/qQuItpRVTReEENtImxBtHJmXuYRS1pbFeP9CP0eecxLE2epfRGzEa4SiYkh+k4qYxfpho6QLa8nghMx3+BoykY/S46ezJyftfcIet3grUDHsh8Hf68CE2V8AR93Ijpj/Q+p75vvc/5aaJfYZhTDfQIsFNkZGhMSRHDjZZ12BKtwF67TTikjvK9Um8UgkqQWWuRAObP696wt9kOWJT0wJkvhKmORzpzt/oHDwV0VWQlWyWI5s3ExzXn3Mvuc0URqgAPeDKGYAEEdRoxORLM9H5Nx+BV2brrzHXQiur2Aiy2iINZnObAuqpoA3E47RwOqOBXqgCRJupvewj2MCziSx0EBR5JO6IvIq6bsKmdWOhtQLXWiPNkMdKhJyVlzIhZr3/YF6nEwpQpx7fuB574GvPJd4LX7bH1L7kQsTPFgleLhn5wJwUr2tfsocPgtAEC91GpbNRugH4PwOOxEVK+B3kSfNg+zo1JlvOD8TISwjYhA/bIANiHkkwwrg+N+dYKZ78BgcbYhodnusljuRHR6kjlRFRHb+mK2lFwmBEhndsmSJkhks4dFtpOZAUCWJa0voR2ur3TpU9/LTDlzQEv8dsiJGBUjqAOwT2wzTpxFuMbb1RNRURRh+gfy0sSeiPUelqG4GO5KI5qTo8faJIz3HHU54DbnLtwJJWxyoYuI9jveFEXR05nHopzZhnCOTNDTmbMXrNIdjlsO8hmM8b7i9CJs0OfWAoashquE40mDKCXAdcOnOxFNj+EjehWPJ+CgiAgYEpp79fuyyYVZ3o5IhsM9LLm7LN4PJKLagoCVOZdHdV1lLWQkXXh/Qs2JqIqIFu9fnGh/9/DvaTcGF2C8cBIOKqqIeGwjsP3v7PujG2x9y6buMLyIw6eogpxdPRF5OfPxLXqpudSFls4ue14fgJxUBUmvw05E9XiQot3awpgdi8zjBednIoRtRAXqlwUAkiTZssLOHR0BB1xF0yuDcMkSusNxNNt04+JoPREdLk0s9Hu0Mr7DNpQ081I3j4OpuIAeCJIt914imdJKwLMpIgIwrJo74EQ00Yc04HCwCp9kmgmFsRu7xDbjIpE0hin1w1FosRyME4mnwNdnnBbcigxCkVUHVViQxGkjdjk5nEw+D6rjgPoSNrngqdNW3ZVDEU2ktFC3bJYzF9vUPzpT9HRm+wVSft1TFL2/rl1EDKEqTl8LmfuLBzJZv8bLQpUz29ATMdIFAOhT/Mjz+WzaMJNozrY+vULA5DnHj0GP5PD+8hfpAmaow9DX17zQ4UmyqiTZaRFR219MiOb3r1abrvXxUCcAoD1vCrDiG8C8DwC1p9ry2idgKGeWS6foImLCUALculsTTO2gsTuCIqihKpIM+GwS8XmwCgYuDkXbDtvz+gDcSdUN67iIqP7NIj3aPNlqT+nxBImI4wjReiICeq8dKz2mQg46Ef0eFyarTr3ntzZic0OnVq5rFU0UEGB/8ZLmw+3WS5r7NNHX2UGwMVwlGzR2RxBPKvC5ZdQWZddyr/fvGUMnYtR8OXM+D1Zx2IkoRDmzjWIbIIYLEbBPHO03CM1O9zdzu2TDwpe11WZRgh+MVNjUmN7JlhUfOLUOVy2sxQ1nTQagOxGt9nkcCn69laXsjj+KbOyrlwlaOnO+/U5Ev8elnc92B8aI0kOVo41zLToRQ7Gk8+WxRtQJtKWeiBHm9upBwPlroVcVpWK9emCdyYVZbtpwvPxcknSBKtxhcCKaPxZ9qojo8md3cXxU/MXsa6gdAFBZyJ309pTNJkPs2Ex4i4Dz7wA+9DAre88GBhHRXzUNPQiiU1IFKpdX/bkCNL5r21s2dkdQIvFk9GLArsoB7kQchNxtp4jI9rHstIjIy9sj3ZqISE5EHTFmI4Qt8PJYnyBOREDvtWOlh4DmRHRoADK7hl1E7v3nDnzgV2/hv5/aasvrhg2r6U4zqZRdqO0IV+m1UAZrJ9xBZ1f/lMHwRv7lQR/kLDtynHQimglW0ZyIDvVE7NdEROcnYnaJbbwHkyhOc7vKtMOGFONsn0fpoPdFtOdzjXUY2Eho5WAWG9NzJ6ITwSpTyvPxs48sxhz1vszDYrLRE7FHu5d5snpsFmWxf+BI8L5p2eiJCBhCR2wu0+YLy6JcC4tt6mkZNvZEdKo81ojmROxHbzRhbgFddZH1KgHnXdmGHnva4p7J+zI3AbglAXpYGhOaNSeiuc8VT6bgV9j9wZ3nsBOxWE0w7mTiVEXQXieiopbaK9nqg2jEICIWT5gFANiXUsNVTvs0MPkc9v2xTba9ZWN3BMXciWhXKTMAePwDAmgiJTMBAL4++4Jh3ClBRET+OaM9KLWxZ/Z4gUTEcYRoThUAtljrnRYEPn7mJMyuLkC1ugr23tEh+miYICJQEM4kNaG5wQYnoh7I4eyAcckkdtN8YsORrLx+h2ppL8nPfoKdnUm46WJFDOatB5zqicgdkE64lwdjX09Eca4XgH5M9kYTlnqeieYq4q4sK/csQLzPBehODqtOxIQWrOL8WKNMKzGyf2DfrfVDzO51hI+TukNx23svD0cimdJcWNlIZwayFxgTFqzfqB3jXIAJUyL2RCyUWGmhmXuYEu5iv4uA8/tL64nYpy/uWSxnFiJNW0totl7OHI4nka/ub7ffYRGxRBURuw4B0F3ndok4Ukzt15mtPohGuIjoDaKyqhYAcF/sOkRO/wJw/jf1MupjG215u1gihba+qO5ENIiYtsDdiMUToUxeDgAoiTVqi91W8aoiotuXvdDKtODHRjKGygBbSCQRUcf5ESBhG6I5VQDYYq13spwZAM6cWoYXblmOP3zydAD2OduicXH21+Ry+52IhSbKYO3kk2dPhkuW8Oa+Nmw/bo/wa4Q7lUrzs9/np8BikmCmKIqilzObEO/ztZ6IDomIPFhFhHJmTQC25sqMChacxc9vRQH6LPS+5OXMjpe6qZTYkG4JGMuZnReyOZqTw7KIqJbyCeAc5U7EbPRE7Bmjexl3ssWSKW2xINsY+y8WZyk0pihLvR7Dggn0doRZAOyaITvdY8+I6sIpcTFRyYxTNhFmY69eJeD8WNerioixPsvlzPw8dUvqGMdJ5ygXiEIdlo/FcCyJIARxIpZMZl9VJyJfMOoKxS0HnwGAS+0/6MobAxGxej6QXwnMeT/8XjfK8r3YpMzE/sX/xYSqCaqIeNweJ2KzWvJd5lLndHk2OhEBvS/ilBXwV0wBANRJrWjutn4fVhQFHp7o7nPYiegtAMDGOTU+tk0kIuqIMRshbEGUoA4jVq31gO4qcloQ4E7Ejv6YLastYYF6WE4sHX9OxPrSAN43n5UL/P71A7a/PncilmapFMwIL70Zq3Lm/lgS3FxmpidintYT0aFgFVVEFCJYxSYnomjBWX6PC15V0LTy2cICuUYBvQTTyj0LAMJxHqwixv4CgMpCtfS3P2apt29CTWf2OJDOPJhspjPz4zrbImLA64LHNTDhN5FM4dv/2IbntzZm5T358V3gd8OdJUepXs6cnZ6IolwLi21YLAd4OrNIIiITV0okNi40IwYn+rsAiOJEPLGc2ezCLG8fJYQTMaA7EUssOhFDsSQCEhOgJKeDVTQR8RAA5vjlOUodFs81AHAlmEvPHSi2/FqjEigFvrYL+MBvAAC1xayPemOX2lqkdjH72tUA9LdZfrsmVUSc6I/o728nU1Yw4XzhdZDU/VQnteJ4d3jk30uDeFJBnlZS73BfTlnWroNVXjbny0bVQ67i/AiQsA2xg1XMDyK5IOD0JLM44IFXHWy32JDUHBGoJyIPj2nsiWjbZRZRREQAuOncqQCAZ99rxLEu6zc3I044EccqWKVP3YduWTJ1PeHnatixYBXuVHH+GLQ7nVmk67sukJoXi0ULICm1IQwMEO9zAeyz8URlK849fn0QIbhI64mYhcRE7lLKdjmzJEknlP6+faADj649jB+9sCsr76mFqmSplBkAivN4mba9ky5Ry5mtOhEj8SRcIpUza8EqbOxkJiAnEeoCAPQh3/n2B0YRMc/avSt6Qjmzg5/N4EQstuhEDMUSmhMRXodLSXlPxJ5jQCIGlyxp92erbrBkSoEvwVx63vxiS6+VNoZzuqaImVI00c1fBJTNYN8f32z5rRq72T6s8aqvb3c587lfA+44wno5Fk8EwETERhtExEgiiTyJ7V+P3+FjENBSrSs87G/akYUFy1xFnNkIYZmoYD2zAHvKmUVxIkqSpDk5rDamB8TqcVaa70XQ54aiAEc7rbkRezUXmLPlzAAwv64Iy6aWIZlS8Jf1Dba+Ni93LB3Dnohj5UTkq/NBvxuSlHnJIj9Xs5WMPRr92jHo/Lllh9AGGIKzBHKa29Grk7erEEUQKLEpgU/EnoiyLGmim9mFsFRK0crXzbiU7cboRLS7n+BYORGBE/sH8tYizT3RrPRJ5E7EbIWqANlLnQ4LJtDb5V5m6cwCBHVw1J6IBWDHopmAnKRazhxxCSAGDChnthqswvaTLIITkQtEkS7t/mV2zhWJJxHgIqLTTsRgJeDOA5QU0M16m/OEXKvO855wHAWqw9YfLLb0WmbgTsTjXYa55AT7+iI2qWJepVudz9ldzixJusisiojlUg9a2jstv3QknkQe2P51vCcicEJbBypn1iERcZyQTCmIJcdnsAqfZAYFcBXxkuZmi05ERVEMadrO7y9JkjBJdSMearMoIgqSzsy5ejFrYrzuYIetr8st7XzQlk34ZL2xO4LP/2kjPvXwBkvliKPBhWCz+5ALJ84Fq4iUzmxPKI6+SOT89YJjR6m2LrY5v68Ae1pwAOL1beNUFFjri9gbTUDRWh04v89K81mJWyKlWO71OBitJ2KWegYa0cJV1HPpSAe7D4fjSW0h1U74mCxboSpAFnsialUczh9/gH7N6LaQQq0oCsLxpBjlsRx18hxQVBHRxDVRUUXEsMthQQoY0okYS6RMVd9owSqKQD0Rw13aooDZ+1colkS+JIgTUZJOKGnWFo0sCjnd4TgKwK6xrrxiS69lBu5EHODc08JVrPdF5OJkqcx7IhZbfs1hyStGRD2/w60HLb9cJJaCH+xeLnkc7okIaOXMxTLbV1TOrCPObISwRCyhCwoiONs4djSc1koTBRAEqlQRsanbmhMxmkhpEzERypkBaCLi4Q6rIqI45cyAntL87pGuAeeJVfgkrGxMRET2t1x7oB3Pb23CK7tasGpnc9bej+9Ds25SLgglUoqtf/N06RekBQJgZzozX3QQ43oBGEu1rZcziyK26ZMwq05ENTBGEKGDU1nA3fQmRURVWPO6ZSHGGj63C1PK2WR3Z1Ovra/Nz9mxuJfx0JvjatuNBsN92G5xFDCWM2dPIM12T0RRrhl2OBH5mFArZ3ZSlOKok2evEoMXcXNicISJiHG3w73NgAHpzEGvW+uvZ6bCg/coFsOJWMy+hru0RYGeSBzJVOYO5lAsiSBUYcsrwD7TEpp5uIravsJikFaXwYnohOOyZnBPRACoWci+tuyw/Pp8jloENZ3Z7p6IgwgFJrBv1BAcKxjLmeHJs/x6llEXUwpV0bkzFEPKxLk1HiERcZxgXEkTYWDP4S4tS05ETRBw/nPxcuZmi+XM0bh4ou+kMjYRO2wxoVmUdGbO1PJ8FAc8iCZS2NHYY9vrckt7NntKcYb6W/55/ZGsvZ9VN6lxchdyIFyFO3fEcCJaF9oAIJIQz2nOm9NbciJGxSxntioiiupEtNqSg5fli3J9B4C5NUzs2HHcvus7ABzpYJPp2qLsT2SmVbL7775WNunLtojIBa9sOhH1noh2lzOLlejOP6eVxXJ+vXBrPRGdv3fxcmYAKEDI1OeTouycjLlFcCKqnyfSBVmWUOAzXyUQiSchIWUQEUVwInZqSeuKYs4BHIlE4ZfU33O6nBkY1olotaS0KxRDARdL/WOQzjyICcWDeiICQClLOUbPMSBp7ZrZqAar5CfVe6LdPREHkSyqBwB4eo9afq1wzFBSL4ITUb1u5KttHZIpxXJl0XhBnNkIYQleGutxSXDJmfcwyxZ8hbY7HDet3PPSRBGciLyc2WqwipbsJkvON5tWmVSqljNbSGiOJ1NarxhRnIiyLGHJRHYD3XjYer8ODh/E8EFNNplSng9JYi6i/3fDaQCAN/a2aiVvdsODEwpMnnMel6yFEGWjFG80uBPR6T6qgC609UUTSFgoQRex560doTGhuKDlzFZ7IgoW/sCpKFDvYRadiIWCXN8BYG6tKiLauEgEAAdUQW9qRfbL+qZXMtfPvpY+KIqChvaxciJmv5zZTC+9kQgLFEoHACX5eu9vs/0r+WfySAKIUhzZBXiZkFQohUyJUnKMnZMJjwCClCp0oIstwPI2MWaciJF4Si89B4QREd0uWRt7m1kIi0cMbm6ny5mBE0RErSeiHeXMamCQUSwfK2rUhamm7ghCsQRe3tGMfk8Z4PYP6AFpFt4T0Z/oYg/Y3RNxEN4yJoD6+o9aDueMxJPwqz0R4RVARFSdiO5YrzYnopJmhhjqBWEZLaRDoKb7gL5Cm1LMTzR56YoITkS7ypn5qrNfIFdRXYma0Gwhxdg4GAsKIPpyTp3ERUR7+iImkiltQD0WTsT60gBW3boCL39tBS6cU4VzZ5RDUYC/bLA3LIZjR0k6F/DCBific+814lev7ctKUIARfs0Q4Rg0hk/0WQia4QsPIl3jbUlnFs2JaFj4MlMOxhExnRmwoScivzaMQZ/AdNGdiN22vWYolsBx9T4/tSL7ZX3TK5jIcqC1D93huNaXFgBabQhyG0xnv3r/ymIwmHER2U6EK2dWx7mJlGL6Gs9FxHyZl/EJMHkGgAAbO1Wiy1RYhyvGRKmEd+yFmhMoMab9Rg0JzSYce4nkQBHRyfJzfzH7GukCoI9JzeyveJgtnCTgBtw+O7bOGpqIqJYzB+0pZzb2RHTCiVhZ4IOs9vL9j1+vxWcefQe/fG3/CaKpGeLJlLpIqMAdVe+JWS5nLqyZCgCoVVosmzUisRh8knodFeE6qIqIiHSj1CYn7HhBHAWDsERUoJAOI163rE3kzfaL0fqbCSAI2FXOzAUBkSaYVfyz9Zj/bNzBludxwS2IwxIAlkzSnYh2CFj8WJak7JaDGZlaEdREm4+ewdLQ/vrO0awErOjBKuYnmLwfYb/a0zSZUnD7/72LH72wG9ttLjscTJ9AwpTXLWuOGStim94TUZzzyp50ZrGuhfx8Tinmy7SThl6gojgsOVZ7IvYI7EQ80NZvW/uEg22sdKk44NHcL9mElzO39cXw3tGBYmirxQnzUIxlsIrd6cz8WijKNSPP69LaTJj9rHxh2ckSyyGpXgAAOEU+YEoMdsfZvT7lFcCJmF+hihIK0HVEb8dhspx5oBNRgHTmeAhIRPW+vv2Zf65URC0/dwnQiw4AilXhl5cz59sj4vT0hfW+ew44Ed0uWTOlcAf93pa+Ez6vGVp7o1AUoNAVg5RU7x1ZLmeWVPGzTmrFG3vbLL1WLGxoqSVET0TeBqHHtnTw8YI4sxHCEtyJ6BPIpcIpttionosQIoQk2FbOLOD+qlQ/W08koQ1oM6VHsGRmzsK6YrhlCc09URyz4LTkaBOwPI8j7QMumluF8qAPrb1RvLnP2g17KHjJYtDCfuQTPN6OoKEjpJU22112OBi+8CCCExGwR2zjjdxFdCKacTxweNmvCE5zYPDCl7nPZRSyRBCyjXARsdXkYpFowVkAUFngR3nQB0UBdtsUrsJFxKnlY1PSF/C6MUFttv/KrpYBP8tOOTN30mffiRg1mYA7HNrCgyDlzIAhRMasiKj+fYIOhj0MSe1iAMAieX/mATmKAk9cDXbgbh4nMab9dh3SFknNLO6dWM7s4PXQVwhAHYcawlXM3L+SEba/Yi4BHGCA7h6NdAHhLk1EtCrihPsMbjkHREQAqFWv93wK0dwTOcF5aYZG1UE/s0D9G7m82Q/JKWbGhjqpFWsszkmSUYOI6PZbei1bMDgR7RKxxwskIo4T+ADNL5BLhWPFWg8YeyI6P2DkQltfNGGtNFHA/VXod2uDcrNuRBEnmAATtOapbhU7+iLyAUzJGDhUhsLjknHWtDIAwB6bE0kBQ09EC/uRi0JckN5lEA53Ndq/zZxUSjGUu4lxHNqR0CziNWOyGsa0r6XP9Gvo5cxi7CtAL/E0KyLyY16SxArCAfR7WGtf1JQrmx/DIgWrAPb3RTzQqoqIY1DKzOG9F1/dzUREtzq7zE6wSvZ7IgZ9bm2RbVODff2IRXMvA4Zxrsn+j1rbHsW5Pm1DMmEJAGCBdCDzgJxYnx48IoKICAxwevHFvV4Ti3vReFJP0gac7Ykoy4aE5k5tYcCMoK1E1fJzUUREbz6QX8m+7zqs9SC32pMu1s+uR3HZD7icGXt8dsU0XDK3Cj+5dhEAVfyzoZyZt9uaEVCvJfmV0KLIs0VRHQCgVOrD/uPNlnpKx1UhOyr5s7/d6cCvxVHdidjRb/89ORcRa3RLmEafYIozqOIUW7DWK4pi6Ino/CQz6HNrLhUrZb9hAfeXJEmWS5r1VF+xJpgAsGQS6wlih4jIJ2BlDomIADBZdcgcspimPRS9FoNVAF0U4u7DXQaxc3dz9pyIIYPjRRwnovUAEhGDVebUMLfMofaQ6UUVkQUBM/cswNCzzeOCJMIg2EC5OgmLJxVTk0y91YEY5xbH7oTmsQxV4fBwlcNqqApf+LK7nFlR9H1fnEUnoiRJmFnFrhEfe3Ad7vrHNlsciREBQ4v0ihvz5cwyUghAMBFRdSJOklsghdszW3iIsLL8mOKCW4SABGCASKMt7pkpZz6hJ6LD02neFzHcacmJKMXUnohuAUJVOCW68FuWz+Yo3eG4pVY+iX52bDoZ+HPx3Cr87vrTsEw1BLT1RZEoYo4+dJl3IrarAledT50b5Jdb2s608Bdp16watGPtgXbTL5WMsvtfTBagJycwsCeievxRsAqDRMRxgl4eK94uLbFwQ4smUlpzexGciIA9vQOjgqULcrhLpdlq033BJpgAcOqkYgDAu0etN9/nVvaxCFUZjinlbFDOy+7spDdqXQzmEzzuNDOWGWbTicjfT5bEce1pvZcs9ETU+t4KdI0vC/q0Fg+7TDrAtEABQa7vgLV7FmAURsW7DvrcLk3wMNMX8aRxImrlzGPnROQiIocHgtntRAzFkoipE/Bs38Me+/QZ+ODiCVAU4JG1h/HNp7Za7kuslzOLc35ZTXWPxJMIwtBqRZRy5rxipEqnAwDm4YC2KJgWan+9XgQQEGRBz1guauW+HDWWM0su5x1TvOddpMtw/zIhaMfYdS/pEUlEnMy+dh5CkaGFkBW3G+/9mBSgV2d5vg9uWYKiAB3eWvagBSdim1opVetWx9nBSotbmCaqG7FWarfUZikVYyJiXBagHyIwoCcilTMPRJzZCGEJPsEUyaXCsWKtDxkGLAFBPhtvhmtFRIwI6CoC9J6PzSbTp3sjYk4wAWBiKRPdzH42I/wGMhYN94eDl5JmQ0Tk5cxWnHx80sDP4d3NunDY3h/LSokeoIeq5HvdwrjA7HAiinrNsCre8P6VYgkCbH+ZHSiG4+IE+wyFHq6S+bVQ1IUi7kTc1dhrKVUbYE49Xs48bSydiINKp3kgWFtfDCmLn8kIF8e9Ljnrx2hZ0IcfX7sIv7/+NLhkCX/fdAyPvHXI0muGBXQv83Fho8nxRShmEBFdXsAjQC8wFbmOlTQvlPZn1pYozKo+upSgOPuqxFjOzK7zZsqZBzgRneyHyOEiYrhTa8dhpoWUHGfXvZRQIuIU9rV9P2RZ0kTSNgt9ERXVJasI4PiVZUm7fhyXVMEv3Kk5eTOFJ1dXyOqYLL/C8jamhUFEtNIXkTsRE7Ig10At/bzbUM5MIiJAIuK4QcRSN44Vaz2fYPo9sjBpv7qIaF4ECQvY3wyw7rLsE7TUDWDN9wFWGmZ1kimCiDhFLWdu7onalkjKsUMo4D0RQ7EEQrGEVnbN/2Z2BSAMRmt/IIrzAeO3JyJgvYw0HBOvNNEOQQAQ6zMZ0a6FZpyIgrasmFKejzyPC+F40nKLh9beKPqiCcgSMLFs7MowBzsRF9UXQ5JY2rdZV+xQGEuZx2qh5eK5Vbjj8tkAgO88txPvHOow/VphAcuZa4v5NcNccFs4nkSBaKEqHLUv4kJ5f2ZmgBAraexEgTj7yljO7OOBZyaDVSTV5OBkP0SO1hPRWrAKFxEVz9g5sEelYhb72rYHgD0JzZLa+1ESJAWdz70aQy4goJYfmwxX4T3byxRVhBxjEbFObsfh9pDpEEslroqILlFERLWcOdaL0gA71ymdmSHWbIQwTSQh5gQTsOZE5KEqIvRD5NjjRBTTOVplUzmzKL3ojJQHvdqEzOoqkggiYnHAq5UlHmoL2fraet8z80KBns6cxN7mPigK2wdLp7DelLuastMXkQvZorQ/AIzpzFbKmcVLdAf0vm1mnYghAcuZJ6ku38MmxSgR+zwa0Z2IZkREdgxzF48ouGQJs6qZ+LLTYknzftWFWFcSGNPzrSzo08ZLPreM2qI8lKqCgJ19EcciVGUoPn3OFFy5oAbJlII/vm2+5xdfNBOpHUxNESu9a+wyNy4cUM4sgDtqALWnAgAWyPvRnZETkQnFnUpQnH3Fg1WiPShzs/PczOJeJC6wE9HCnMuliojwCuRE5CJiy05AUbRxd7vJcAtFUeCKs3uEK0+Mc626yLBwaTFchf9dipQu9sAYi4iz85h4ufVol7nXUcuZk6KIiIbrcaWXXf/IicgQT3EiTKGJUoJNMAE9wdacE1FdcRZogmlHT0RRSxOrLJYz90Ssi0/Zwu2StabMVvYdoB/LToqIgF7SbHe4ih6QY8WJyH43HEtqrsNZ1QXaRH9XlpyI3L0skpBtpxPRJ9hCES9n3tXUi4SJRuehqHj9Ayep7jMecJEpYYHCwIaiXBUR20yIiHZcG7IFD/qx6nLmLSLGMlSFw92I9aUByLKECnVf2dn+oXMMQlWGQpIkXH5KDQDgWKc5l0oqpWjjJ5FEeu5ENOu+CcUS4joRq+cjARcqpB5E2zMQf1UnolDlzN4AEKwCAJTFmwDoi9+ZEIkn4ebpzE6HqgCGkssuSz19PUl27ZNEOgbLZrC/caQL6GvRE5pNusFCsSQCKXaueQLFNm2kNQaYUwwl92bgf5f8uBoiOWY9EesBAFM87H3fM9l/XlFFxJRbkJ6Ibi+gbkupm80dO/pjlnv7jgcEuPIRdqAFqwg2wQSM5cxmeiKK7EQcj+XM3IloNZ1ZnP1lhAvAVidkWrCKwyLi1HL7+yLGkyntemJlP3Lhvz+awE7VdTi7uhCzq7nopLuFNjV04mMPvo2fr9pr+v04/QKWktrSE1HQvrf1JQEEfW7EEiktjCJdEsmUFvCQL9D+4iLikc6QqdYHojsRrZSD8RACEfvezqriTkRrIqKWzDyGoSqcaWpfxElqD99siIhdDjkRAaCm2FqrAH4dBMS6xnMnYnNPxNQ1IxxLoYA7EXn5nCh4/Djqnca+bdqc/u+FmBOxAwUICDSG527EkuhxACbTmeMpXUR0CXAtNDgRjUnhmQodniQTcGS/QOXMHr/eF7F1p3b/MutE7A7HEZTYuebKE+Ncq1GdiE09BieiyYTmNtW17o+pLSPGIp0Z0JyIVUorAGDrMXMiopRg+0YYERHQwlWKZTU5OpnSqp5OZsRSMAjT6Mmd4gyqOLq13oITUaDBoh3lzFFBnaNGl6WZVRZRm+5zeBmfVSeiVs7sYDozAExWRcRDNoqIfYZVeSt9Bbnwv6OxR1uRnFVdgNmqE3Fvcx+6QjF86+ltuObXb2HNvnb89vUDllf3xHYiWihn5gtFAqUzA6wpOHeAZdoXkZcyA2IJbjVFefC4JMSTCo6bcBbxxS+R7ltGuIO6zYSIKPJC0Sx1gWJ3s7Vy5gMOOhHPnFoGQE9mrghmwYnYz/YhD2AYS2pVsa3JpNhmDNsTafxUWeCDLAGJlKJN4jMhHE9qwoZwTkQAbQEmIro6D6T/S6qI2KUI1BMR0ESawshRAOYqBKKJJPxQr58eAcQOrSdiJ8qDPkgSEEukMl4o8nIR0SeQiAgAFayfKlp3o0y9JpotKe0KxVEI9jklQQR7Pq9ssljOHE0ktYowT0QNN8kf23Tm/EgzJKSw7Vi3qfG8pPZEVDxj1494VNTjJC/Zr5l/qKSZRMRxg6jlsQAsWes1J6JAggDvXdHcE0HcRPkeoJcmijRxBvQbWSSeMiV49AradJ9jh4tUURQheiICuohopxORC8F5Hhc8FsKMzp1RDr9HxvbjPdh4mJU3zK4uwMTSAPI8LkQTKZx3/2v449uHwccZfdGEpX0D6CKiSNeMIlvSmcV0IgKGcJUMe9HxUmaXLMErSHAWwLanXnWCNXRkXtIsYliMkXJtEpbZuRZNJLXenCI6EfkCxZGOsCWXgFbOXD72IuLKRbV44/bz8bkVTLTJTjkzu38VO7AIVlHgg1uWkEwpptLB+bnl98iQ5bEJhUkHt0vWE1ZNLDyEYwkUgJczi9GnbQBqX7VUX2v6vxPWnYhC3bdUkSavj4mI/bFkxq04IvEU8iR1TiOCY0pzInbB73FhQjHbpn0tfRm9jC/Fjl23IL0CNSpVEbFlp74IZrKcuSsc01sHCBKsUm00pxSbL2fmcxOvrEBS2wmMWU/EghpAkiGnYqiSe9EZiptq7yAn1fuCCOcVh1+TI92oLvSjssA3YEHrZEWcUTthCVGTOwG9704kntK2M100QUCgUoiaQj8KfG7Ekwr2Nmd2g+aIKvr6PS5N8DBT0swnboUCulQAoFK9UZuZvHBCMX0i7bSIOCULPRF7o0zoClrch1Mrgnjs00tRoIp5sgTMqCyALEuYqU72u0JxTCnPx58/s1SbsGc66B2M7l4W5xjkwSrdVnoiJsS8ZgB6X8SMnYgGx95YpcSmCy8nNXNucYdlnkecY9CI1pg+w0mYsXeY1etDNijJ92puerN9EVMpRevXx4XksUSSJK0fImAQEW0MVtHLmcdeCHbJkkFsMyEiaueWeNfBWlW4MVOqLboT0VPIhAgp3J7+L2lOxKBYCypqzzlv3xHtoUwXHZgTUT0nhXAi6uXMgN5bdV9r+uMpRVHgV9gx6MkT7Bg0OBHLg9bSmbtDcb11gCCCvTFYReE9EbsagFRm4ja/p0/Lj0CCAkACAmV2burwuDxMSARwZhkTabeZKGmW1XJmeMVzIiLSjVdvOw/r//sizKkR49hxEvEUJ8IUoopSACsrdKsD4kzdiFp/M4GCVWRZwrwJ7OJh5gIJ6ANh0UoTAWvBMb0CB6sAxnJm8xMybaXPLTs+MJ5czm6ybX0xzQVqFTtL0k+bXIrHbzoTdSV5uPyUGs15+/4FNQj63PjyBdPxr6+ci7Oml2MaH/S2WOtnxoWpoEDXjPqSACSJDRCbTEwwUykFsYSY5cwAMLeGDbAyTcUNCezY4wnNDSbCVUJRscuZjY3pMyk34teGoM8Nl0AuMCOzhui5mgltfVHEkim4ZEnrU+Uk2Q1WcWYRrFbri2jGsSfeIhGHHy+mnIhxQ09EAUXEQAkLI/FGO9L/JdUJ1SmciDgZACB3HtK2K5PKm2RKQTypwA91zCVC2aUhWAUApqu9Vfe3pL8IFk8qyFePQU9AMIFEExF3am2E2k0urDT1RIQLMeILK9FECt2eSpb4nYwBvY0ZvQ5vpTAlT72WBEoB1xheK9WS5tOK2XFnpi+iO8m2XRJBnOdoImKPcAveTiLebIQwhd4TUbxdKkmSHq7Sn5nQERLQiQgA8yewC4rZxrGiljMDg3pzZIg2yRTQpQLon63VghORC+Fl+V7HbyYFfo+2KnuozVyS7GDsFoJPmVCEN24/H7/82KnaY585dyq23n0Jbr1klrbwYWblfCj6NAFHnGOwJN+LRfXFAIDXdrdk/PsxQ6mViAtFvHdce38sI7dlSGBBgIermHIiCh+swoSpTJuD895hojrNAWBOtbWE5iOqC7G60A+3ACX22eiJ6GSwCqCHkDSacCKKfG5ZciLGEijgTkRBSiyNFJYxh1Eg0ZX2woNiKGcWan/xkI6uBlT42PUvk1Yj2vhdcyI6v9gwwImYSumLshmMp8KxJALqZ/KK5kQsVxOaw52odLEFonaTTsSjnWG9dYAg55rf49Kc4U19CU2My7SkmTsRJ/rU/T5W/RA56nbPDrB9tPVY5ot5brWcWRbKiaiXMxM6zo+QCFsQ2YkImA9XEdGJCDBhBLAgIvLSRIEag3OqtJLfzCYtyZSiTUhFbLoP2ONE5AMXpyZgg5msOqYO2lTS/OZe1vNoko2lfEOJrYMf4yvn1suZxQtWAYDzZ7HB3KsmRERjGwi/gAtF+T631mcvE+eeyAEk/Lw6bMaJGBfXYQkwAYZvWyYlzaI7zQEW3gQAu0yKiLyH04QSMVwQdpczK4qi9fnkfbjGGp7QbKZfVjiu9+wVDWtOxKShJ6JgAg6AkgomIpagR3OyjkgqCYS7ALBgFaH2V2EtUDQRUJJY4dsDILPzSxMReU9EEZyIPFhFSQGxXm1Rdn8G46lQPKGV1AvnRPTkaQ7S8hAL9+mNJDQDTSYc6QgZWgeI8zmHDFfJMKGZJ1ZP8HARcYySmTmqiDjRzcrqzYSruFOqiOgb+57Ew8KdiFFroW3jDfFmI4Qp+IVUxJ6IgDFcJUMnIi9NFMypwp2IOxt7Mm7IDAARrTm4QAMrFbPlzEZHi6giouZE7IsiZSIZEtAdIbwk0Gm0cJVW6yJiOJbE3zcfAwB86LQ6y6+XCZoTMYPym6EQdeGBi4hv7m3LeODLF4lcsiSEO2ooJqvOvcMd6e8/kQNIJpbpwSqZDoJF/lwcrS9iBm4O7tbhPT5FRBMRG3tMJUMe7WRCTl2xWCJiVyhuasI8mKaeCDpDcbhkCTOqnElg5QnN5sqZ2bVQKGebCndYHjflREwiqJUzi5EYa8RbwO5fpejFsXTCpsJdak82oAv5YrnNJQmYfgEAYIX8LoDMhF9uAsiXeTmzANcKTx7gVhcFwl3aouyxrrC2sDoaIYMTEV7B0pkBraQ5v2efNtc1UzF1pDOstw4QJJ0Z0PsiWklo5ouCVbIqdgXH2olYDwAoSzTDLUvo6I9lfD30qCKiSyQRUQtW6XJ0M0RDzNkIkTFasIqAzjZAD1fpyNCJ2MdDEgRzFU0uy0fQ50Y0kcJeE86piMCib7XJcmbek8/rluET9DgsD3ohScw1abYUYr9aHjLFgeTOoZipTgS3Huuy/FrPbW1EbySB+tI8nD1tbFcweflNW18U3RkuNhgR1Yk4r7YQFQU+9MeSeOdQZ0a/qy0SCehC5HDRLRPnXr9WmijWvgKAupI8yBKbWGXqAuOLXyJ+Lk6Z6hzNpK8Uv8aL7EScXhmES5bQE0mgyURfXx6qIooTsSjPo42f1u7PINRiGHj40YzKoGOLmDWGEIFMEdm9rPV6NJXOnBSuT9sAVEeTX4qjqT2N41Dth9ijBCC7veL1UJ12IQBgQXQTgAxFRHW+VeDi6cwClDMDA0qaS/K9KFMXig6kucAcjiaQD/WcFFJEnAUAkFp3aenT/HqdCcc7+/TWAQI5EbW514CE5syciDyxulxWnfhjlczMUZ2Irt5jmFHFrmNbj2ZWsedV2JjE7RPA4csx9EQkdMSdkRAZwZ0qPgFFKcC4wpLZBV/viSjWgFGWJcxTE0nNlDRHBE4Y5AnGzRmWM4uezAwAbpes9QMzm9DMy0O4c85plk1lg/u3D3QgbsIVa+Tx9Q0AgOtOn6ilg44VQZ9bG0RZ6YvIhSnR+qjKsoTzZrIB3au7MitpFr1dBaCX/x5qy8SJKOb1HQB8bpfW4yzTkmbNiSjw/uITzEwSLu0MXcoWPrdLS3rf1Zh5SfNRdVJaJ4iIKEkSPrB4AgD9+mwFLiLOdTBZkp9XZtKZRR478c/V2hfVgrDSJRw3OBEF6dM2AE8AMUmtKGpNI+yB90MUrZSZM3UFILlQEW3ABLRmdCzyYzAoCxSsApwQrqL3RUzvOhgN90KWVPdN5lO0AABOf0lEQVS2T4zx7QAq5rCvrbtRV8L+5kczFBG7Q3EoEcPfQyDBns+Tm3ssOBHVcuYSRV2odkhERPdRnKLOkXdkGLjnU52Ibr8YRg0AA9KZCR0xFSciY3SnioA3awATS3lpWGYX/H6+6iyYqwjQS5rNJDTroq94+0vriZihiyMX+mUBel/EFpN9EXnPvmkVYgyy5tUWojjgQV80gfeOdpl+nd1Nvdh4uBNuWRrzUmaOmT4+g+FORNHKmQHg/NmstOSVDPsiRgROc+dM0sqZzTgRxdtXgCFcJQNhFBA7dZpTZqacWQtWEfsaz0uad5pIaNZ6IhYLIgwA+MgZEwEAL+9syfi+PBg+oZtb67yI2NYXzbhEW+RglbJ8L7xuGYqSeTuYcDxpcEeJI2xoSBLCHuZ062tPQ0QMMRGxC0HhqgIAMFGg7nQAwHLXexk52vj4PV/mPRHFWHAYEK4CY4uY9MZT0RAT11KQxBFGjZTqgTjcKX40Q9fvkc6Q3nvU5RUjFEdlQBVYCXciHsroNXg5c0Gyiz3glIjY34r5VWyMwReu0iGZUuBXS+o9IoqI1BNxAOLOSIiMEFmUAoD6Ur2/VCaENFeReJ9rfp35cJVwXPxy5pbeaEbONl7qJuSA0QDv+WjGiRhNJLVjWBQnoixLWunxm3vNl7v9YwvrhXjhnEpUFjgzsLIjoTkkaDkzAJwzoxxuWcKB1n6t91o6RBPiOxEnaUEk6Qtuoott/DOZvW+JKHRwStWerpkEq/TkgBMRgJaEnmn5r6Ioek9EQZyIADCzqgCnTSpBMqXgyY1HLb3WdgGciCUBj7Yg0tydaasAcZ2IkiSZKtXujcQRiacMwSoCOhEBxH2lAIBwdxqLYGo5c6cS1EpPhWM6K2leLr+XUcgPF74DkkA9EQE9XEUNtMk0rC4RYnOZiORnfSNFo7CWfe1txIQiNo7PZBzFny9iKTOgOxGPd0X0BPG+JiCe/rHZprYnCcSYiD/mPRH9xVop/MIitm92ZuBEjMST8IONSbx+MeZYAMiJOAziKRiEKSICi1KA7kQ8kuFkjLuK8gUUBE6xEK4icklOZYEPQZ8byZSSdi8VIDdK3QBoApmZhOZDbSGkFKDA59YcjSJw9nQmIq7Z12b6NQ6qbqszp5bZsk1mmJbhyvlQ8LJ6oRq5qxT6PZip9onJZHVWcyIKeL3g8GCV5p6oVs47Gno5s3j7CtATyt89mlnCYC4sqJSrbR14+VM66MEqYjsRz1NDjNYd6NB66KVDR39MW5DlCcKiwN2Ij69vMB0K1hOJa4L4HAdFREmSNDdipgnNEcGTz3URMf3PdbQzDA8S8HNRSkQnIgAE2Ngg0ZuGiMjLmVGg9YoUDrUv4tnydrT29COZ5nkVVa8RAZGCVYATnIh8PLU/zXF8IsLGXRFZQBciAASrAEkGUglMC7DrWKY9EY90hBHkYr1gbQP42HBfax/CrkJd5OxKr42FoijaoqA3qi6gjbUTUZKAQtZ+Y7qvCwC7xnelmYcQjieRJ3EnooAiYjizfubjHTEVJyJjdBFRzIEVdyJ29Me0CVY6hATtbwYAU9RwlUg8lbFzKipwjzNZljCnRhU6GtNfdckVl4rZ9GnAUMpcGYQk0ErtOaqIuKmhM+0kvsHwBLVaB10Dma6cD0ZRFK1EVlQBZ7Zaarm7Kf1+bblQzlwc8Gr9UNN17oleznzGFOa8eX1PK7751La0Jpm9kbh2LawR1YEDPV1+vPVEBIBpFfmoL81DLJnCmn3puxF5f63KAp9w4WBXLKhBod+No51hvH3AnOOc94isLfKjRC1ndwozYhtgdPmKeQzy5OlMxNEjHSFd2ACEFRHdBaogEUo/WKVLKXB0TDEitYug5JWgUAphlnIo7eoUTcjmScailP7ynoiDypkPtfWnVVWUVEMjYrKg+8vlAfLZAtEkD5ubZNoTUWQnYk2RH5UFPiRTCrY19hjCVQ6l9fu90QRiyRQABa6QaigYaxER0Eqa88ONmoEo3b6IkXgSeaoTURYpWKWgmn0Nd2bkDB3viDsjITIiopa7iTrJDPrcWg+mIxn0RewTuL+ZLEtaSVCmPR9iSXFFRACmPlcuJHcCQIWhXDtT9gkWqsKZWBZAfWkeEikF6w92mHoNnijJJ0FOwP+uRzpD2kA9E6KJlCb0iHjNAIDZqkC/qzl9EVEvZxbz+s6ZXJ5ZSXNY8HLmxRNL8MNr5kOSmAPszqe3jfo7XDwoDniEFbIBoFS9H7dlUM6cK9d4SZJwgepGfDWD/qN834lUyszxe1y4QO2puiHDdHfOjuNs4u1kP0ROjXqfyTShOSxwFQegO1gbMwjqONIZRpALG54AE0sExF9UBQDwxTpHvz+H9GAVYUVE2QVJLRutkLrSdrVFeA967hwVLZ1ZDVapLfIj4HUhkVLSCgdLqU7EmEsg8WYwaklzjcSOr6aeSEaVYEc6wyiCOj4RzIkoSRIWqq043j3SZeiLmF5CM3chVnpjkJLq/MZBERHdRzOeS0biKYM4L9B1w1Cmje5jjm6KSIg9IyHSoisU05LgigQuM8q0L6KiKEI7EQF9ML7dRGkiIK4oMNdEqlauuFSqtGAVE07EVjFFREB3I764oyntshxOLJFCq9pLxckyvvKgF+VBLxQF2HKkK+PfN7owRb1mzKpm59auDPvEAOIuOnD4qnO6acZ8f4nqKgKAa0+fiJ9dtxgA8JcNDaOWavOJqLB9wFTKg+w62JFJOXOY7a9Cwa/xAHCeKri9tqsl7VJ03l9rQomYk2je63HLEZMiYqPz/RA5E4p5/6/MXB2iLzzwlPpMemUf7QyhkCczC+pCBABfETunStHLwh9GwhCsMkFAUV4jwNzmpVJv2u5R3vLAL5rYofVEZNcHSZIwQy2R3X589ONRibLxbSIHRMTCeCu8LhnJlILmDAwBRzpCmgCJgtpsbKEl9Gt8V8YJze3qGH56QD2OvUHA68C+LKpnX7uPZDyXjERj8EmCpZ4DrExbE0ePOLstAiGmgkFkxLZj7OScWBoQ2iGQaV/EXHAVaRdIsyKiYCVTnLk1rP/DjuM9aU/A+nIlndkOJ6IgycxGeF/Ex9cfwdLvv4wfv7Qn7d9t7olAUQCvW9Ycw04gSRKWz2Arp5k4iDjGpvsuWZxycyO8nPlgW3/abkvRneYcPoE+lKYTkYuNtUWCODmG4coFNSgJeKAowP5RWlfw8ioR3WxGuBOxoz+W9jW+N5obTkQAWDa1DD63jOPdEexO0/V7TPB9p7lUMuzRyREhmZnDS/0zdSLyHpeiOhHPVe9f7x7tSrtVAOvTJmaJpREpn40xSqWe0QW3sO5EFHpBRe3zWIw+FmiRBlz41tOZBRE7tJ6IXdpDp04sBgBsOjz6woMSU0VEt0CpuINRRUS5t1Fb8D6agTHlaGcYtZJa6stFIYEYVkRMJYGDrwPx4Y9RXlUw2a+Ov9Tzdcyx4ESMRQxjR1HEeY7hcxEMsWckRFrwFc/5atCHqEzM0IkYMjg+RHUVaRfIxvTFNi4IeN0yZEGFjhlVQbhkCZ2hOJrSdOzxfjIlAbEnmHo6czQjxx4LmhHXiXjx3Cpcd3o9Cv1utPXF8LNVe9N2efDn1RT5He/1qDuIWjP+3T4tiEnMCSbA+q2VBDxIKen3fozmihOxLP1rfCSe1Jy9IogaI2F0dOwZRZDik+sJxYJMLIeBi4jxpKL1cBwN7kQsyhPzfmzE73HhrGlMIHg1zWuJvu8Em7yozK0thMcloaM/llFbGACIJ1PY06SebzXOjxV5T8RD7f0ZCaJaObOgTsTqIj/m1BRCUYDVe9JbCDvaGUJQ4snM4joRueBWloZrL9Wv9kREUNvXQpKnOxHTHS/x+3a+FqwiyOdTXZXo1wP2lkxiwuI7aYiIUowJOLkgIqLnuLbYM+yxuOlR4L0ntf+298cQjidRK6k9PYvrs7mlpphfVwRJYouR3X4WUIKuw8DztwGPvB9Y+4thf5eHpE3yqq7T/DFOZuYYRUR1bLevpU9LNR+Jzi6DY9Yt2H2YRMQTIBFRUA629eN7z+3Aff/aOepzt6ki4injTETkpW5+jyysq2hGVRBuWUJ3OK4FU4wGL8fxC+wq8ntcmtsu3RUk7nKYVS3wIBgsnbkoz4NkSsmoQf2xzjCiiRS8blkrzRcJn9uFH1yzABu/dbEmcqa777gbxMl+iJzlM8ohS8Du5vTLizgip7lzJEnSzpFdaYarcHFUVOcyJxMn4t7mPiRTCkoCHlQXCjIJG4GZVeyc2tM8mhORl8Q6fy6NhN/j0no28jKokVAUJWd6InLOVxckXt+TnojIXaSi7juf26UtXG452pXR7+5v7UMsmUKBzy2E03JubSG8bhkHWvvx9Jb0e0zxewIPBhKRC2arbvo0xGvujirgTkTB+rQNgDsR0TOq4MZFxJi3WOzrhSqMliD98QYXEXkAhDBORC4a9eviNRcRdzb2jBq6x8uZFa/IIqIqrPUc0xZ7hgxX6TwMPPMl4KmbgAibI/MquElutZxZQCdiod+DaXzuFS5mD7buAt55iH1/9J1hf5f3RJyT2sceqJqXrc0cGYPYVlPoQ3HAg0RKwd5Rxk4AsOcYO3Zjkg+QBZsjk4h4AoLtIYLTE47j928cxF83HBl1lTZXnIj1GZYz96tlK6K6EAE2qM9UsIkIvpLOyaRUuzsc15wRIvRbGgmXLOF982sAAE9vTn/ysq+VCT5Ty/OFFbUBwOOSsUC9FqTbh+S4mpDpZD9ETnHAqw18X92VWUlzu1o+JnpfztlqX8TdTentn42qi2Cm4AL9JNWJeKwzrPXpHQ6e/D63ttBx92s6zFSdiHtHcyIKXhJrJJOE5v5YEty4Lfr5xTl1IruO7GoavVJAURRt39ULvO8WGRvvZwC/j8+pKRSiAqKywI+vXDgDAHDvszvSErL7ogltnMGvoSJyvhrqs3pP66jVDl2hOPqiCT1YRWgnIi9n7h05hCSVghztAgD4ixxyQ6WL6t4rkfrSciImkiltkcynCNYTMciCbxDqAJJs/lRTlIcJxXlIKaNfM/p72TgjEBR4LsmdiL2Nmtt/yGOx4W32VUkBje8C0MXGGvByZvGciACwsK4YALC+U614ShnE37bdw/4eN0VMjanPqTs9G5s3OoW1ACQgEYEU7hhQsTca+481AwAU0VyIwIBejwSDRERBmV1TAK9LRmcoPqJzr9vw81MmiDuoAvRStyOdobTKSA+2sht1hRqEISqZ9kXklm7RSxMzufDzkIgJxXkoDojrEOBcvYgNRF7Y1pR2X7r9Lex4nCZgKfNgMj0meZKkCE5EADhPnYS9lmFfRB5wNKtK7Gvh7AyciJF4EhsOsZXzc2c41OMmTSoLfMjzuJBSgANtI68682NT9EUHzoxKtZy5ZeR9djRHglWAzBKau0LsOW5ZErYf3WCmVwYhS0BnKD7qZ+wMxdGrOnWETZOF3hcx0+Ap7XwTqHXATcunYnZ1ATpDcXz3udGrbnar18vKAp927IrIovpiFOV50B2OY3PDyGWkR1Tnco1PPT59Ags4+cy1VyCFsb+pY/jnRbshK2xcVVDiQDpsJmgiYnpOxIaOEOJJBXkeF+SkWn0kiuARKAUkGYAChPSS5lPTKGmOxJOI9LNrRHlpaVY30xIFzACAnuOoUxe9h9xvDWv1749vAcDOtQKEkK+olRLc1SgYi9Q+lu8cC+vhLzwZuPMQkDhxweXNvW14a3878lwKakO72IN1p2V/Y4fC7dMF7e4j2hhv+yhhU8mUgiPNTAiVfYK4e42QE/EESEQUFJ/bpQ32RhosblMTt+pLxRdvqgv98LgkxJNKWn321h1kg5Qzpgh8Q4NRbEsvjS8cU5PdBC9NzCRViz9nTo4IAqdPLkVtkR+90UTabredqmtMxFCVwWQiAAOGnogCOBEB3cmxZl972iIvoLd2mC/4gkom5cybGjoRiadQUeDDDMEFbEmScOZUdr3+09sNIz5XpJCHdODlzEc6wlq4w2DCsaTmhq0XNOHXSFk+T2geXUTk45BpFcGccI4CbKGOt1EZzUHK+9fNqAwiIHD1A3cibjvWjXhyZLevERHPN49Lxg+uWQBJAp7afGxUEYeLiKK3THG7ZCyfmV5AGF90qPZzEVHgz+YvhiKzc6O58eiw10GezNyn+FFZIrAoCmjlzKXoRW8kgR61ZcNw8FLmqRX5kOLq8SqKE1F2aW5R9DVrDy9RRamNI4iI2451owpsvwVLq7O2iZbhTsR4CBOD7PjjLUQGcGSd/v3xzQCAzQ1deqhKXgngE3M8tUh1Ir57pAtKzUL24KXfZ6FLSgpo3z/g+Yqi4IcvMOHwKwvikBNhthhRNmMsN3sgBsGNL3xtODTygsq+lj5ICXZOuX0CltQbRUQTwWbjERIRBWZRGivOuVLKDLAy0jp1YtXQPnpJc86IiJlG2GshCWKfflyIOtwe0nphDYeILoeRkGUJ71fdiOn2Y+JuML6qKzJczG3oCI06KAag9fMUxYk4p6YAVYU+hONJvLW/bfRfABtIadfDOrGvhzOrCiBJQGtvdNQyvjX72Oc/Z3p5Tog3N547FQDw5MYjw4pTqZSCnY1MEBAh5CEdyoI+Lbl8uEAcLoIEfW4U5kD4CP886ZSSaseh4G7YwaQbiPPCtiYAwGWnCDyBBus7Wuh3I5pIaaLaaCiKoouIgi30Laov1savo7n2ePuH2YKLiED6fRF5e59Kbw6IiJKkpxkrPXj3yDAL56qI2IWg+I5sLiLK7Jo+WkkzDwObUZEHJHk5s0ALRkG1fLxPP+6WTGJzqE0NnUgNUwW2paETC+UDAACpZlFWN9ESnjwtDKfexa4Xx7siAz9XuAtoMTibG7fgUFs/Xt7ZrIeqCNgPkTO7pgD5Xhd6Igm8t+S7wCdfAJbcAJTPZE8YVNL8r21N2HqsG/leF/6zTt3vExY721PQILgtVReWdzb1oDs8/Hzk3SNdqJPY9ksFAt6HC9Qy7WR0QHjRyYzYKsZJTjq9b7bmSKgKJ92+iN2hOHapA0bhRUR1UH6kIzziBZITyZFy5pJ8L2rVVL31B0coXQGEnaCMxNWLWCnDq7ta0R0aeb8d7wrjSEcYLlnS+vWJjHHf7WocfaLZKFBPRIA52i4/hZWtPPzW4bR+p7knitbeKGRJfGEq3+fWHFKjuRHf3McGvWdPzw3xZtm0MpwyoRCReAp/XDv0vjvSGUJfNAGvW8bUCgFXnIdhxijhKtwRUVeSlxOCL++J2J6GE/FNg5idS2iBOCMkoYdjSaxWw1cunSfg5MWALEsZlzQ3dkfQFYrDLUvaMSwSvAfYloauEZ/Hr5Ui90PkLJ9RAUliY6OmEUL3eDlzmVsVpEQOVgEgqU63EqkXm4YTfcNsvNipBIVuDQBAE6SK0QsJqdFFRPU6MqvMUPklSjozoIuIhnCVOTUFyPO40BtJYO8w18GDB/ehUupCCi6gev5YbKl5VDdiRaoNLllCLJnCL1/dhzv+/h67Bx/dAEDRS2o7DuCx196FogDnV6vnmaD9EAHm0Ob3ob/tigCTlrEfVMxiX9v2as9t64viO//cAQD4zLlTEWxl/R8xwaFSZo5BRKws8GNqeT4UBXjn0PBzyc1HujBbUitYnAqFGQm3F+DiJvVFBEAiotDwgeK24z3DNqnfejR3nIgAMLGUDShGS2hef6gDisJKBioLBLpBD0FxwKuttu5Kw40YiavlzIKLiABwuRpA8tCag8M+J5ZIaalb83LEiQgwt97s6gLEkin87JW9Iz533UEm5JxSW6glmoqO3hdx5DL7cCyJLlVEFWnA/6mzp0CWWLLqzjTOK76gMr0yKHxoEQAsUCfO//vi7mGv792hOLaqKaxnTy8boy2zhiRJuGn5NADAo2sPDVmOvkPrXVkAjyt3hiGjhatwJ6Lw7hsV3ldud1MvXt/TiuZh2ow0tIdwpCMMtywJv6g3mHQCcVbvaUUknkJdSV5O3MN4YMyqnc2jPJPBz7fplUH4BGyjoi2Yj5A4rSiKJiKKXs4MMOcyF0dH6u3Ly5mLZPXcE9mJCGh9EUvRo5XHdvTHBlYXhbiIWCDUmGJI1J6ILqRQgJAW3DMc+1URbmap4TwSpScioCc09+nHnNsla+fYhmFEnNSxTQCAcPEMwCuQs3IoVBHR1d+E6kI2P/zfl/bg8fVHcP+/d+v9EKddCBRPAgDsfXcNAODCGnXBTGAnIgBcpVZKPfdeo962olwtT25lTsR4MoXP/2kTGrsjmFqRjxuXTwWOqenNTvVD5AwKIeHjhnUjGFLeFV1EBKgv4iByZ/R+EjK5LICiPA9iw5StDAhVqc0VEZHdnJ7ceASv7Bp+ALxOTZlaOiU3Js68fPTtAyM79pIpRXM8BHJA6PjUOVPgkiWs2deu9ZsbzP7WPsSSKRT43TmRSGrkG5fPBgD8Yc3BEV0d69T9unRqbhyPQPp9EXkyc9DnRqHfk/XtSpeJZQHNjfj7Nw6M+vxcc2V/7eKZKPS7samhC3c/u33I56w90IaUAkyryEeNIKXm6fC+U6oxoTgP7f0xPPPu8RN+novOZWD00lgtVCVHroM8tGztgXZc/9B6XPGzN9AfPbHPGXchnjqxBPk5sojCmV6pu0eHS2h+cTsrZb50XnVOOEhXqhPM1Xta0wqDELEfohG+YL51hD6PzT1RdIfjcMmStk9Fh/f2HakvIq/KCUIV4Xxi7iMNtfy3TGIiYiSexNW/XIMLf/wa3lNF4GTHIQBAK4rEX1Bx+7TQihKpD/8Yob2NoijYrwY+Ti1Wp89uv7Nlo4MJqkE2fQOPOb4IOdT9uL0vqoVxeCYuye722QHvi9hzHBfOqYQs6cGiL+5oRvKwmsw8cSlQuwgAMDu1H/MnFKFW5uXM4joRAeb4L8v3or0/pt1/Uc6diHsAAN9/fifWH+xA0OfG7z5+GoJKSBMYRXIiAtBKmvncfjDhWBK7m3swR+Yi4ilZ30RTkIg4AIGufMRgJMlYtnJi2cBf32EK/6SyAEoETqoz8oHFdagryUNzTxSfevgdfOvpbUM+j69W8Cb9onO52kfp/715YNjS2FgihS//ZTOeffc4ZAn4jyVir4QBzFFz5QJdyFEUBS09ESQMA31jymouTMCMnDerElcvqkVKAb7xt/eGncDw43FpDrlw0u3VyZOZa4rEc/zetJz113tmy3Gt5Ho4tuVQf1gAmFyej59+ZDEkCfjzugb8fdOJg5I39rLB47kzBE+4HITbJeO609kg/V9bG0/4ea71UOXMVMWLXU29+Nmqvfjgr9YM6ON2TBURc2Ux5byZlThvVgXm1hSiwO9GW18MT2w4sUyH90PMlZJ6I9MqWEJzdziO1t4Tez/GEim8rDr6RO+HyJlaEcSyqWVIKcBfh9hfgxE9CX1qeT4K/G5E4qlhBXre3mZKeX5OVHEAwPlqX8Q397YN6TZXFEVbeMhLqYmxwouI7BpQ6epDdziOu5/ZriUW/9fftiKeTCGx/zUAwBbMQqW6UCE0qhux0tWPTQ1dw5ZcNvdE0RdNwCVLmBBUx7puwcZN+SeWMwPAfyyphyyx1kSDe/q+e7QLCyS2UOutzwERkScW9xzDvStPwe7vXo5nv3gOJpUFEI9FoRzbyH5efyZ6SpkYNV8+iJuWT4XExR/BnYhul6zNvf6xWRW2tZ6Ie3GgpQd/WHMIAPDjDy9kCyvHNwFQgOKJupjsFINFRNUQtO14D/qiCSSSqQELltuOd6M01YUyqZcljFfMHvNNTgsSEQdAIqLgLFIDArYMamB8pCOEH7/EViM+t2LamG+XWSoKfHjxq8tx8/KpkCXgj28fPsFe3xuJY7tagpkrTsSrF0/AjMogeiIJ/Pb1/UM+5/vP78Rz7zXC45Lw84+cigvnVI3xVpqDByX8871GXPi/q3HG91fhlie2aK4O0V0Oo/GtK+eiJODBrqZefPsf25Ac1Hi6pSeCg239kCTgtMk5JCKqfQH3NPVhU0Mn/ryuYciAHD2ZWTzhY2F9MZZOKUUipeBhdcA0HLkUMsU5f1YlvnIhK1H5xav7Bjil+qIJPKu6BlbMyi0REdAFmTX72rXjTlEU/GPLMbytrkbn2jWDl8Y2dkfw45f2YFNDF27640atDJj3RJxQLHg5mEpRwIOHP3kGnv/KuZor+/+9eXDAIlEqpWDNfh6qkhv3YyN+jwuTyljfzaF6Wa7Z14aeSALlQZ9WJpwLfGTpRABsMTkxSkqz6PdoWZb0vojDVATkUikz55TaIpQHfeiPJYcsI23tiyKaSEGWAE9CPTYF74mIfCYizgiya95fVBHbJUvY2diDh1/bAU8jE3H25Z8KWc6BhWXVXXnFNFaJ8dvXh6584OLbpNIAvCm1/FykUBVA7wM4yIlYXeTHBbPZz/6yvmHAz7Yc7sQCNVQFtYuzvomW0ZyIbIHS45IhSRJWLqzFXOkQ3MkISxIvn4EH97Hx4GmeQ3jf/Bpd/BHciQgAKxezvu0v7mhmSeglkwHZAyTC+NcaVrZ8wexKXML7+B5VS5mddiEC+t+3rxlIRFFbnIf60jwkUwoeX9eAc3/0Ki7839Xawt7b+9sxR1Z7aJdOE7ekflCZ9skOiYiCs2hiMYCBvWIURcE3n9qKcDyJM6eW4trTxb8YGgl43bjjfXO07f7hv3YNmDz/a2sTUgpzWFYL6I4aCpcs4euXMqv5Q2sOomVQb6m2vigeV2/cP//IYlyhrjDlAqdMKMLZ08uQTCk40MZWy//5XiNe2NYERVG0Qb+oLofRKAv68L0PzIckAY+vP4Jb/7plgCORuxDn1hSiKE+cct/RqCvJQ4HPjVgyhQ/+6i1886mt+OoT755Q0sfLmWsFPde4G9EogqZSyoDP0dwT0UNVBJ0oD8dnzp2KgNeFA639A/rFPLHhCHoiCUwtz8eKHHMiAqyMdGpFPmLJFF7d3YpIPIlPPbwBX/nLFvTHklhYX6wJB7lCSb4XVYXMWVMe9GFKeT5ae6P47GMbEU0ktdLSXHEiGrnm1DqU5XtxrCuM59Wk4ob2EH72yl50heII+txaH89cY4bqIN2tutyMC0X/703W7/eqhbVw5YLgoXLpvCqUBDxo7I5oLVIGE44lcbwrrLW9EfkevbCeTfaHCxLkLX3m5JCIKMsSzpvFU5pPLGn++ybmMKotzoMUVR2YwvdEZCLiZK/uGJ1WkY/7PsDCON567Z+QU3EcU8qQKp7qyCZmjBqucvk0dm1/eWczth3rRnc4PuAfNzdMqwwCcbUywiPYtX6YcmYA+MgZbM71t01Hcbi9H7f/37v40G/ewqvr3kGJ1Iek5Ba3F50RQzmzkasWTcA0iT0Wr5yPF7a34OFDxQCA6lQTXOF2/XcEdyICwOL6YkwqCyAUS+InL+0BXG6gjJmGdm5lguFHzpio/wJPpK5ZMNabeiKBUr1XaA+7zp0xmYn133t+Jxq7I2jqieCXr+5DbySOh9YcFL8fIkBOxEE4LiL+8pe/xOTJk+H3+7F06VKsX7/e6U0SCj7J2tfSh889thE/eWkPPvCrt/DG3jZ43TLu++CCnCsh5XzlwpnwuWW8c7gTr+5uwcG2fnz1iS24/W/vAWArLLnExXOrcOrEYkTiKXzxz5txuL1f+9mjaw8jmkhhYV2R8OmPQ/GDDy7AjedOwS8+uhg3q6LOt5/Zjm8+tRUbD3dCkpATqcXD8b75NfjZdYvhliX8Y8txfPaPG7VACB6qkmuBArIs4cxp7KYd8LrgliW8vLMZ/1IFgj3NvXjnUIdWTiVqA/TzZ1ViemUQvdEEHl/fgJ5IHCt/uQbL7nsFz757HIqiYJPa4H1aRRABb271bAv63FqPM77QkEim8JAqbnzm3Km54eYYhCRJuEy91v17WxN+/spevLq7FV63jK9fOgtP3rwMXrfjQ5CM+eE1C/DVi2Zi1ddW4OFPno5CvxubG7pw6U9eR4u6qp4rPRGN+D0uXL9sMgDgRy/swmUPvI7l//MqHniZhU4tn1meUyE4RriDdMuRLnzq4Q1Y+v1VeO9oF7Yd68ab+1jC5yfPnuzsRmaIz+3SWqL819+24u5ntmtBewDwyFuHsPCeF3HWD14BwFqTFAfEbXuzqJ6NH941VN0oioI1+9rw1OajWhLwrBxIZjbC+yK+sqsF0YQeMvXm3jb86AXWh+6z504CIurn9gvupK+cCwCoi+phdF+/dDY+dFodzp1RjjMU1qLoreQ8zM2VqgDViVjl7sdFcyqhKMCVP38TC+95ccC/+/7F9tf0yiCQEFREHKacGQBWzKxATZEfnaE4Lvjf1fjrO0ex4VAn6iNqUEf5PNYjUnQK9XJmI9Mrg1hYxBZMXm/24ta/voseBNHpV402638PKEnm5guKXwkmSRK+cRmrEPj9GwdZP0u1pLky2oCqQh/ON1apdKhVcGXTx3pTT0SShu2LCLC2FADwp3WHcfczO9AZiuP0PLX1jaj9EAESEQfh6GzriSeewK233orf/OY3WLp0KR544AFceuml2L17Nyorc0tAyhZlQR+Wz6zA63ta8a9tTZoAIEnAnVfM0U7EXKS6yI9PnD0Zv119AJ99bJPWM0aSgBuWTdacfbmCJEn41pVzcd3v3sb6Qx245Cev48sXzsDHl03CH9ceAgDcuHxqToq+9aUB/PcVbPB40ZwqvLSzGQda+/H4+iOQJOA7K0/B1IrcaHY+HO9fWIt8nwufe2wTVu1qwSf/sAFnTi3DX98Z2NMjl7j/Qwuxr6UX82qL8KtX9+Fnr+zDXc9sxyu7WvB/GwfeBEXsiQgwMfSmc6fi9r+9h4fePIR1Bzq00uUvPb4Z//Pv3TiilpHmUimzkY+cMRGPrz+Cf21twt3vj+H1vSwwoSzfiw+eOsHpzTPNpfOq8avX9mPVrmYkksz99bPrFudM77mhOG9WJc5ThYGiPA9+8dFT8dnHNuKQmk4a8LpQliM9igfz8WWT8OvV+7SFBZcsYemUUlw8twrX5EAP3+GYUcXuTc8aQgVu/uNGLRDtffNrUF8qaPnUCNxw1mQ88+5xNPdE8fBbh/DHtw/jxx9eiJKAF/c8ux3ccClJEP46wp2Ie1p60RdNoLU3ijuf3oo1+wY24p+dQ05EADhnRjlcsoQDbf1Y8p2XsXRKKfK8Lryxl4VmfWhJHT42PQa8mGQBH0HBr401CwFJhj/cjLMq4wiW1+HSeVWQJAm//s8lSP7mINAJzDv7Slx5oaB9zQajiogIteMrF87E2wc60DdEwBTAFv0umlMFhFXBRjQRkYtjoQ4gmWDuNRW3S8aHT6vHy6+8iIOpGsyaWI1PnT0F87avAvYA/kk50A8RAAonAJCASBew9f+A+f+h/ej00ghwDNjRX4BwIonFE4sRnP8ZYNVdwBv3q79fK1YYzghcPr8GnztvGn792n7c/n/vYsrMaswHMF06hmtPq4ebL+wpCtCulqSXCtLirLgeaN/Lwl6mLMeFsysxoTgPc2oK8NPrFuOzj23EG3vb8De1H/jS/EagG0C1yCKiKkj3twDxCOARc940VjgqIv74xz/GjTfeiE9+8pMAgN/85jd47rnn8NBDD+Eb3/iGk5smFI988nRsO9aDF3c04WBbP86cWoaL51ahqjD3D97PrZiGv6w/gu5wHG5ZwrJpZbjtkllaoEyusXhiCV64Zbk2+P2ff+/Gg28cQGcojvrSPM2Zk8v4PS784IML8OHfroVLlvDjDy/EykViT1DS5YLZVXjkU2fgM4+8g7UH2rFW7d12wexKXDgn9xY2ivI8WDKJrf594YLpeG5rI/a39msCYqHfjZ4IGyyL3Gtq5eJa/M+Lu9HUw0ogvG4ZHz1jIv68rkEr1VtUX4zPnJsj5VODmD+hCPNqC7H9eA++8sQW7FJ7mN1w1uScCREYigV1Ragp8qOxm7V3uHReVU4LiEOxfGYF1n3zQqze04rX97Ri6ZSynFwoAoDSfC9+eM0CrN7dinNmlOOC2ZVCu9fShTsRAaA44EFRngeH20Paccnd9blGXUkAq79+Pt7c24bH1zdg1a4W3PLEFgQ8LqQU4MOn1eG7V7NWHaK7SCsL/JhQnIdjXWGc9t2XEEukkFIAn1vG6ZNLIUksHTzXxN6iPA8ePPUQtu7ciR/3X4ZVhrLmBXVF+M7Vp0Da9RR7oHKu+OKGN5+FHrTswJ+v8AGz9P5rwVQv0LkdADD37KsAb47cu9RgFYQ6ML+uCO/edckJvbE5LllibQ+2CupEDJSyYAolBYTagIKB99ubJx7FV33/jeMV56D6s/9kVQ6bWX/9nOiHCLC+oWfcBKz/LfD3m9g+mH0FAGBmgJXZz5w+E3+/4CwsqiuGnFwCvPOg3scuB/ohGrntklnYdqwbb+xtw4M73fipF5guH8dyYyuzUDsQVd3MpVOc2dDBTD4H2P8KsOcF4IwbURb04c3/Ol8bH3390llaeOCSCQEUdKoiqMjlzHklrA9qPMScsGWCCLYO4ZiIGIvFsHHjRtxxxx3aY7Is46KLLsLatWtPeH40GkU0qifr9fSMnDg6npAkCfPrijC/LjddNiNRHPDiyc8uw/6WPpw1rRxFgdzpOTccU8rz8dinl+Kpzcfw3ed2oqM/BgD4zDlT9VWjHOeMKaV46vNnIeB1Cy0+meHMqWX4841L8amHNwBgwStXLazNWWGA43O78MNrFuDj/289JpYG8P0PzsfCuiJsONSJRColdL8zn9uFT5w1Gf/zb1Z2872rT8GHTqvHp86egq3HunHa5JKcXlSRJAkfOWMi7nx6G15X+5vVFvnx8TMnObxl1pAkCZfOq8bDbx1Cgc+Ne1cKvMJsgQK/B1cuqMWVC2qd3hTLrFw0YdwsCnGmVQQxsTSARDKFhz91BmQJWPmLNeiPJXHWtDKckqMOZoAt6l00twoXzK7E3c9ux6NrD2s9R+9deUpOtQy4dF41HlpzEJE4q0o5d0Y5vnv1KVowTk4S6sD5O76F81MJXPyfn8SG3lKkUgryvC68b34NWyRqZsIbquY6u63pUrsYaNkBHNsEzLpcf/zQGgAKK7kszJ2+37qIyBaNNaFwJLSeiIKJ2rKLJWj3t7BQi0EiYuDoGgBAbeubQPNWJgofZo9h4rKx3lrzXPYD5kR87wngyU8AX1gPlE6Bu4+VxF565mKAB2XJfuCCO4Gnbmb/z4F+iEZcsoTffnwJHnnrMHZvaQe6gLmeRgSNLYjaVWdsYZ04wvacq4BV9wIHVgPhLiCveMA8akFdMa49rR5PbT6Gu8/2QXomztLpRRZ5JYmdJ6k4kBrarXwy4ZiI2NbWhmQyiaqqgX0JqqqqsGvXrhOef9999+Gee+4Zq80jxpCZVQUDnALjAUmS8MFT63D+rEo88PIedIfj+PBpAl8YTbA4h5IsM2VBXTHe/K8L4JIl4R0cmXDa5FJs+tbF8Htk7Wa+bFpulGlfv2wSNjd0YfHEYnxIPZcmlgUwsUywQbxJrjm1Dmv3tyOlKLhkXhUumF2VU0E+w/Hpc6ZgT3MvPnHW5JwWeoncxeuW8fKtK7TvAeDX/7kEv3h1H775vjlObpptyLKEe66ahwnFedhwqBPfuXpezrmYv3XlHHzm3ClIphR43fL4uF7sfFabbM4J9GDOKaee+BxNRMyRRZbaxcCWPwHHNw98fO+/2dcpy8d+m6yglTOfmKA9LFxEdAt4jAYrVRFxiMClxnf179/+le5anHkZUD5j7LbRKrIMrPwV0LqLfaaGtcyB16v21SsctKA3/8PAW79gwmnJ5DHfXKsEvG587rxpwNm1UL7/FQST3Sw8p0DVULR+iAI548pnAOWzgLbdwN4XgQUfPuEp931wPr79/rnI3/139kDVPCbUiczH/+70FghDznSgv+OOO3Drrbdq/+/p6UF9/fgSZYjxR0m+F/eMU/fNeCfXJmDpkpcrJUaDKPB78OANp43+xBwlz+vCLz82xAQzx6kvDeDPN57p9GYQJzmDHXnLZ1Zg+czcSz0fCUmScPOKabh5hdNbYg5JkoQN+DLNdsOEs7dp6Oe07GBfRS7jMzJBvU8d38R6sUkScGwjsPkx9vic9zu3bWZQ05kRzkREZG1UhHMiAkxEbMaQ4Spoek//fuv/MQERAJbfPiabZisuNxO0G98FOg4AybieSj1YRJRl4D8eAtb/Djjtk2O/rXbhyYNUMpl93taduojYLqCICLBrwRu72WLKECKiLEvI97nZ9QPInYUUAoCD6czl5eVwuVxobm4e8HhzczOqq0/smeTz+VBYWDjgH0EQBEEQBEEQhFD0tQIHX9f/P5SIGO7Se7VV5kg5c9UpLOE21A50NQCJKPD055kgNf9DwNTznN7CzDAEq6RNXNCeiICe0Nw3cH6N3ib2mCQz8S0VZ2nF0y4E6nIkVGUwpWo/246D6vmlsGMzUH7icytmAlfcf0KJd87BrxMthqpN7kQUJVSFM+dK9nXfy/o5MxQHVrOvk8/J/jYRtuGYiOj1erFkyRKsWrVKeyyVSmHVqlVYtiyH+jIQBEEQBEEQBEFwdv5Dd3oBQ4uI3IVYWAfkFY/JZlnG7dNdk8c3Aa9+j5WV5lcAl//I2W0zgyFYBcrQgSonkBBYRAyqDuvB5cy8lLl8JnDu1/THV/zX2GxXNtBExAN6KXNBjfgBRVaoUFPPW3fqj4nqRKxZxHocxkMsZGUoepvUzyLlXiuEkxxHz7Jbb70Vv//97/HII49g586d+NznPof+/n4trZkgCIIgCIIgCCKn2KamLhdNZF+5yGFE64eYI6XMHF7S/PI9wJqfsu+v+LEuyOUSvJxZSQKR7vR+JxeciIPLmbmIWL0AmPU+4PTPsDLmiUvHdvvspERNIu44APQcZ9/nUqiPGSrVPr4tqoioKLqIKJoTUZL09gbrfju0SM9diDULc/P6cRLjqIh47bXX4v7778e3v/1tLFq0CFu2bMELL7xwQtgKQRAEQRAEQRCE8PQ06qm3S29iX4dyIuaqiFirioidB9nX8/8bmHuVc9tjBY8f8AbZ9+mWNGs9EQUUEYPqHLpvGBGxZiFLcb7if4EL/ntst81uSlURMdKlu3oLxrmIyJ2ILbuYKNfXDMT7WZm6iKExZ9zIAogOrga2Pnnizw+qImKutUEgnBURAeCLX/wiDh8+jGg0inXr1mHp0hxeESEIgiAIgiAI4uSlbQ+QVwLUnQHUnc4e6xtHImL9Geo3EvC++4EVORjMYSTPUNKcDvEI++oWUUTk5cyDRUQ1VKVm4dhuTzbx5uui6SFVtC+c4Nz2jAXlMwDJBUS7mbuZuxCL6gG319ltG4rSqcDyr7PvX7hj4DmmKMCB19j3JCLmHI6LiARBEARBEARBEOOCqSuA2/YAH/qDHuTQ2zSwnC+Vyr1kZk7FLOCDvweuf5o5jXKdQIYJzblWzhzqALob2PfV88d+m7IJ74t4dAP7Ot7Lmd0+vfdhyw49VEW0fohGzvoyUDEHCLUBL9+lP96+D+g5Brh8wMQznds+whQkIhIEQRAEQRAEQdiFywMU1QFBVURMRFjZJafrMBDrA1xeoGy6I5toiQUfHj/uoUwTmrVy5kB2tscKvJw31A5s+H/s+ybVhVgyOXcCfNKFi4jJKPtaWOvctowVxpJmLVRF4GuI2wu8/wH2/eY/AV1qIj13IU48U0xBnhgREhEJgiAIgiAIgiDsxuNnpc3AwL6IR99hXyvnMsGRcA7uROxvHfl5nIRazuzxZ2d7rJBfBpyh9uF87lbg7zcBr97H/j+eSpk5PFyFU3ASiIg8XKV1J3PzAeKFqgxm4pnAlBUswGjdb5gre/vT7GdTVzi6aYQ53E5vAEEQBEEQBEEQxLgkWA2EO1kPMy4A8OCVSWc7t10EgwtRbXvTe77ITkQAuPxHQKAceO37wHtP6I+Px2OtdJCION7LmQHdibj9H0Csl33Prysic9aXWJDKxkeAYCVw+E1WyjzvA05vGWECEhEJgiAIgiAIgiCyQUE1cw31NuuPHX6LfZ10ljPbROjwnpQ86GY0RO6JCACSBJz3X0D1KcDel4DiiewzTr/I6S2zH17OzBnv6cwAcy8DuoB4xk3AlOXObU+6TL+ICaCtu4CXvs0eu/DbJ+5DIicgEZEgCIIgCIIgCCIbcGGjt5F97WsF2naz70lEdJ6qU9jXlp1AKgnIrpGfL3I6s5HZV7B/4xmjEzFQzoJHxjtl04DiSUC0F1j5i9zZx5LE3Ij/+AL7/6SzgTM/7+w2EaYhEZEgCIIgCIIgCCIbGBOaAaBBdSFWztX78RHOUTqFCYKJMNBxECgfJaRCK2cWXEQ8GcgrYf/CnSdHKTPAeqh+YR0gybknms7/EPDG/7L9dfWvAJniOXIV2nMEQRAEQRAEQRDZYLATkUqZxUJ26T3lmreN/nzRy5lPNng5bOEEZ7djLPHk5Z6ACLBtvvkN4MtbWFo4kbOQiEgQBEEQBEEQBJENBjsRKVRFPNLti6gozLEIkIgoCjwY52Tohzge8AWBvGKnt4KwCImIBEEQBEEQBEEQ2YCLG31NrIyvSXW7kRNRHKrns6+jiYjJGKCk2PckIorBjEsA2Q1MXeH0lhDESQP1RCQIgiAIgiAIgsgGBVXsa28TcHgtAAUonaY7FAnn0ZyIo5Qz836IAOAJZG97iPRZeC0w7+rcLO8liByFnIgEQRAEQRAEQRDZIKiKiMkY8PJd7HtyTYlF5Vz2teswEOkZ/nk8mVlysYALQgxIQCSIMYVERIIgCIIgCIIgiGzg9gGBMvZ92x7AXwwsv93RTSIGESjVgzladgz/PC2ZmVyIBEGcvJCISBAEQRAEQRAEkS2MoQ9X/hgopBAI4UinpJmSmQmCIEhEJAiCIAiCIAiCyBrFE9nXU65h/wjx4CJi0wgiYkItZ/b4s789BEEQgkLBKgRBEARBEARBENnigjuBmkXAmZ9zekuI4ahZxL7uXwWkUoA8hNeGypkJgiBIRCQIgiAIgiAIgsgaVfN0pxshJjMvBXxFQFcDcHA1MO38E59D5cwEQRBUzkwQBEEQBEEQBEGcxHjygAUfZt9venTo53AR0U0iIkEQJy8kIhIEQRAEQRAEQRAnN6d+nH3d9U+gv/3En5MTkSAIgkREgiAIgiAIgiAI4iSnZiH7l4wB7z1x4s+7j7Kv3vyx3S6CIAiBIBGRIAiCIAiCIAiCIE69nn195yEgmdAfT8TYYwAw45Kx3y6CIAhBIBGRIAiCIAiCIAiCIOZ/CPAXA+17gbW/0B/f/hTQexwIVum9EwmCIE5CSEQkCIIgCIIgCIIgCH8RcOn32fev3Qe07wcUBVj7c/bYGTcBbp9z20cQBOEwJCISBEEQBEEQBEEQBAAs+igw9TwgEQH+75PAv24HmrYCngBw2qec3jqCIAhHIRGRIAiCIAiCIAiCIABAkoArH2CiYeO7wPrfsccX/ycQKHV00wiCIJzG7fQGEARBEARBEARBEIQwlE4BPvpXYPe/gEQYcHmBFf/l9FYRBEE4DomIBEEQBEEQBEEQBGFkyrnsH0EQBKFB5cwEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYwIiYgEQRAEQRAEQRAEQRAEQYyI2+kNMIuiKACAnp4eh7eEIAiCIAiCIAiCIAiCIHIPrqtxnW0kclZE7O3tBQDU19c7vCUEQRAEQRAEQRAEQRAEkbv09vaiqKhoxOdISjpSo4CkUikcP34cBQUF6O3tRX19PY4cOYLCwkKnN40gxg09PT10bhFEFqBziyCyA51bBJE96PwiiOxA51buMF73laIo6O3tRW1tLWR55K6HOetElGUZdXV1AABJkgAAhYWF42pHEoQo0LlFENmBzi2CyA50bhFE9qDziyCyA51bucN43FejORA5FKxCEARBEARBEARBEARBEMSIkIhIEARBEARBEARBEARBEMSIjAsR0efz4a677oLP53N6UwhiXEHnFkFkBzq3CCI70LlFENmDzi+CyA50buUOtK9yOFiFIAiCIAiCIAiCIAiCIIixYVw4EQmCIAiCIAiCIAiCIAiCyB4kIhIEQRAEQRAEQRAEQRAEMSIkIhIEQRAEQRAEQRAEQRAEMSIkIhIEQRAEQRAEQRAEQRAEMSIZiYj33XcfTj/9dBQUFKCyshJXX301du/ePeA5kUgEX/jCF1BWVoZgMIhrrrkGzc3NA57z5S9/GUuWLIHP58OiRYuGfK9///vfOPPMM1FQUICKigpcc801OHTo0Kjb+OSTT2L27Nnw+/2YP38+nn/++WGf+9nPfhaSJOGBBx4Y9XUbGhpwxRVXIBAIoLKyEl//+teRSCQGPOeXv/wl5syZg7y8PMyaNQuPPvroqK9LEMDJfW6Nts27d+/G+eefj6qqKvj9fkydOhV33nkn4vH4qK9NEHRuDb/Nd999NyRJOuFffn7+qK9NECfrufXuu+/iIx/5COrr65GXl4c5c+bgpz/96YDnNDY24qMf/ShmzpwJWZZxyy23jLqtBGGEzq/hz6/XXnttyHtXU1PTqNtMEHRuDX9uAWLpGeNhX33iE5844Vp12WWXjfq6o2lPTo8zMhIRV69ejS984Qt4++238dJLLyEej+OSSy5Bf3+/9pyvfvWrePbZZ/Hkk09i9erVOH78OD74wQ+e8Fqf+tSncO211w75PgcPHsTKlStxwQUXYMuWLfj3v/+Ntra2IV/HyFtvvYWPfOQj+PSnP43Nmzfj6quvxtVXX41t27ad8NynnnoKb7/9Nmpra0f93MlkEldccQVisRjeeustPPLII3j44Yfx7W9/W3vOr3/9a9xxxx24++67sX37dtxzzz34whe+gGeffXbU1yeIk/XcSmebPR4Prr/+erz44ovYvXs3HnjgAfz+97/HXXfdlfbrEycvdG4Nv8233XYbGhsbB/ybO3cuPvShD6X9+sTJy8l6bm3cuBGVlZV47LHHsH37dvz3f/837rjjDvziF7/QnhONRlFRUYE777wTCxcuHPU1CWIwdH4Nf35xdu/ePeD+VVlZOerrEwSdW8OfW6LpGeNlX1122WUDrlWPP/74iK+bjvbk+DhDsUBLS4sCQFm9erWiKIrS1dWleDwe5cknn9Ses3PnTgWAsnbt2hN+/6677lIWLlx4wuNPPvmk4na7lWQyqT32zDPPKJIkKbFYbNjt+fCHP6xcccUVAx5bunSpcvPNNw947OjRo8qECROUbdu2KZMmTVJ+8pOfjPg5n3/+eUWWZaWpqUl77Ne//rVSWFioRKNRRVEUZdmyZcptt9024PduvfVW5eyzzx7xtQliKE6WcyudbR6Kr371q8o555yT9msTBIfOreHZsmWLAkB5/fXX035tguCcjOcW5/Of/7xy/vnnD/mzFStWKF/5ylcyfk2CMELnl35+vfrqqwoApbOzM+PXIojB0Lmln1ui6xm5uK9uuOEGZeXKlel+REVR0tOejDgxzrDUE7G7uxsAUFpaCoAp3PF4HBdddJH2nNmzZ2PixIlYu3Zt2q+7ZMkSyLKMP/zhD0gmk+ju7sYf//hHXHTRRfB4PMP+3tq1awe8NwBceumlA947lUrh4x//OL7+9a9j3rx5aW3P2rVrMX/+fFRVVQ143Z6eHmzfvh0AU4P9fv+A38vLy8P69eup7JLImJPl3DLDvn378MILL2DFihVZew9i/ELn1vA8+OCDmDlzJs4999ysvQcxfjmZz63u7m7tcxNENqDz68Tza9GiRaipqcHFF1+MNWvWmH594uSGzi393BJdz8jFfQWwFgyVlZWYNWsWPve5z6G9vX3E7UlHe3Ia0yJiKpXCLbfcgrPPPhunnHIKAKCpqQlerxfFxcUDnltVVZVRn4opU6bgxRdfxDe/+U34fD4UFxfj6NGj+Otf/zri7zU1NQ34Yw/13j/84Q/hdrvx5S9/Oe3tGe51+c8AtmMffPBBbNy4EYqi4J133sGDDz6IeDyOtra2tN+LIE6mcysTzjrrLPj9fsyYMQPnnnsu7r333qy8DzF+oXNreCKRCP70pz/h05/+dNbegxi/nMzn1ltvvYUnnngCN910k+nXIIiRoPNr4PlVU1OD3/zmN/jb3/6Gv/3tb6ivr8d5552HTZs2mX4f4uSEzq2B55bIekau7qvLLrsMjz76KFatWoUf/vCHWL16NS6//HIkk8mMX5f/TARMi4hf+MIXsG3bNvzlL3+xc3sAsD/OjTfeiBtuuAEbNmzA6tWr4fV68R//8R9QFAUNDQ0IBoPav+9///tpve7GjRvx05/+FA8//DAkSRryOZdffrn2upko+9/61rdw+eWX48wzz4TH48HKlStxww03AABkmUKwifShc2tonnjiCWzatAl//vOf8dxzz+H+++/P+DWIkxs6t4bnqaeeQm9vr3bfIohMOFnPrW3btmHlypW46667cMkll1j6nAQxHHR+DTy/Zs2ahZtvvhlLlizBWWedhYceeghnnXUWfvKTn5j7IxAnLXRuDTy3RNYzcnFfAcB1112Hq666CvPnz8fVV1+Nf/7zn9iwYQNee+01APaM4Z3AbeaXvvjFL+Kf//wnXn/9ddTV1WmPV1dXIxaLoaura4Ai3NzcjOrq6rRf/5e//CWKiorwox/9SHvsscceQ319PdatW4fTTjsNW7Zs0X7GLa3V1dUnpPEY3/uNN95AS0sLJk6cqP08mUzia1/7Gh544AEcOnQIDz74IMLhMABo9tXq6mqsX7/+hNflPwOY1fehhx7Cb3/7WzQ3N6Ompga/+93vtIQfgkiHk+3cyoT6+noAwNy5c5FMJnHTTTfha1/7GlwuV8avRZx80Lk1Mg8++CCuvPLKE1Y+CWI0TtZza8eOHbjwwgtx00034c4770z78xBEJtD5ld75dcYZZ+DNN99M+3MTBJ1bJ55bouoZubqvhmLq1KkoLy/Hvn37cOGFF5rWnpwmIxFRURR86UtfwlNPPYXXXnsNU6ZMGfDzJUuWwOPxYNWqVbjmmmsAsOSshoYGLFu2LO33CYVCJ6jdXChIpVJwu92YPn36Cb+3bNkyrFq1akDE9UsvvaS998c//vEh69Y//vGP45Of/CQAYMKECUO+7ve+9z20tLRoyV8vvfQSCgsLMXfu3AHP9Xg82sH9l7/8BVdeeaXjyj0hPifruWWWVCqFeDyOVCpFIiIxInRujc7Bgwfx6quv4plnnrH0OsTJxcl8bm3fvh0XXHABbrjhBnzve99L+7MQRLrQ+ZXZ+bVlyxbU1NSk9Vzi5IbOrdHPLVH0jFzfV0Nx9OhRtLe3a9crq9qTY2SSwvK5z31OKSoqUl577TWlsbFR+xcKhbTnfPazn1UmTpyovPLKK8o777yjLFu2TFm2bNmA19m7d6+yefNm5eabb1ZmzpypbN68Wdm8ebOWNrNq1SpFkiTlnnvuUfbs2aNs3LhRufTSS5VJkyYNeK/BrFmzRnG73cr999+v7Ny5U7nrrrsUj8ejbN26ddjfSSfNKJFIKKeccopyySWXKFu2bFFeeOEFpaKiQrnjjju05+zevVv54x//qOzZs0dZt26dcu211yqlpaXKwYMHR3xtglCUk/fcSmebH3vsMeWJJ55QduzYoezfv1954oknlNraWuVjH/vYqK9NEHRuDb/NnDvvvFOpra1VEonEqK9JEJyT9dzaunWrUlFRofznf/7ngM/d0tIy4Hn8cyxZskT56Ec/qmzevFnZvn37iK9NEBw6v4Y/v37yk58oTz/9tLJ3715l69atyle+8hVFlmXl5ZdfHvG1CUJR6Nwa6dwSTc/I9X3V29ur3HbbbcratWuVgwcPKi+//LJy6qmnKjNmzFAikciwr5uO9qQozo4zMhIRAQz57w9/+IP2nHA4rHz+859XSkpKlEAgoHzgAx9QGhsbB7zOihUrhnwd4wH6+OOPK4sXL1by8/OViooK5aqrrlJ27tw56jb+9a9/VWbOnKl4vV5l3rx5ynPPPTfi89OdjB06dEi5/PLLlby8PKW8vFz52te+psTjce3nO3bsUBYtWqTk5eUphYWFysqVK5Vdu3aN+roEoSgn97k12jb/5S9/UU499VQlGAwq+fn5yty5c5Xvf//7SjgcHvW1CYLOrZG3OZlMKnV1dco3v/nNUV+PIIycrOfWXXfdNeT2Tpo0adS/z+DnEMRw0Pk1/Lnzwx/+UJk2bZri9/uV0tJS5bzzzlNeeeWVUbeXIBSFzq2Rzi3R9Ixc31ehUEi55JJLlIqKCsXj8SiTJk1SbrzxRqWpqWnU1x1Nexru7zNW4wxJ3QCCIAiCIAiCIAiCIAiCIIghoWZ9BEEQBEEQBEEQBEEQBEGMCImIBEEQBEEQBEEQBEEQBEGMCImIBEEQBEEQBEEQBEEQBEGMCImIBEEQBEEQBEEQBEEQBEGMCImIBEEQBEEQBEEQBEEQBEGMCImIBEEQBEEQBEEQBEEQBEGMCImIBEEQBEEQBEEQBEEQBEGMCImIBEEQBEEQhMbdd9+NRYsW2fZ65513Hm655RbbXo8gCIIgCIJwBhIRCYIgCIIgTgLSFfNuu+02rFq1KvsbRBAEQRAEQeQUbqc3gCAIgiAIgnAeRVGQTCYRDAYRDAad3hzLxGIxeL1epzeDIAiCIAhi3EBORIIgCIIgiHHOJz7xCaxevRo//elPIUkSJEnCww8/DEmS8K9//QtLliyBz+fDm2++eUI58yc+8QlcffXVuOeee1BRUYHCwkJ89rOfRSwWS/v9U6kUbr/9dpSWlqK6uhp33333gJ83NDRg5cqVCAaDKCwsxIc//GE0NzefsA1GbrnlFpx33nna/8877zx88YtfxC233ILy8nJceumlmfyJCIIgCIIgiFEgEZEgCIIgCGKc89Of/hTLli3DjTfeiMbGRjQ2NqK+vh4A8I1vfAM/+MEPsHPnTixYsGDI31+1ahV27tyJ1157DY8//jj+/ve/45577kn7/R955BHk5+dj3bp1+NGPfoR7770XL730EgAmMK5cuRIdHR1YvXo1XnrpJRw4cADXXnttxp/zkUcegdfrxZo1a/Cb3/wm498nCIIgCIIghofKmQmCIAiCIMY5RUVF8Hq9CAQCqK6uBgDs2rULAHDvvffi4osvHvH3vV4vHnroIQQCAcybNw/33nsvvv71r+M73/kOZHn0NekFCxbgrrvuAgDMmDEDv/jFL7Bq1SpcfPHFWLVqFbZu3YqDBw9qwuajjz6KefPmYcOGDTj99NPT/pwzZszAj370o7SfTxAEQRAEQaQPOREJgiAIgiBOYk477bRRn7Nw4UIEAgHt/8uWLUNfXx+OHDmS1nsMdjjW1NSgpaUFALBz507U19drAiIAzJ07F8XFxdi5c2dar89ZsmRJRs8nCIIgCIIg0odERIIgCIIgiJOY/Pz8rL+Hx+MZ8H9JkpBKpdL+fVmWoSjKgMfi8fgJzxuLz0IQBEEQBHGyQiIiQRAEQRDESYDX60UymTT1u++++y7C4bD2/7fffhvBYHCAe9Asc+bMwZEjRwa4Gnfs2IGuri7MnTsXAFBRUYHGxsYBv7dlyxbL700QBEEQBEGkD4mIBEEQBEEQJwGTJ0/GunXrcOjQIbS1tWXkBIzFYvj0pz+NHTt24Pnnn8ddd92FL37xi2n1QxyNiy66CPPnz8fHPvYxbNq0CevXr8f111+PFStWaKXWF1xwAd555x08+uij2Lt3L+666y5s27bN8nsTBEEQBEEQ6UMiIkEQBEEQxEnAbbfdBpfLhblz56KiogINDQ1p/+6FF16IGTNmYPny5bj22mtx1VVX4e6777ZluyRJwj/+8Q+UlJRg+fLluOiiizB16lQ88cQT2nMuvfRSfOtb38Ltt9+O008/Hb29vbj++utteX+CIAiCIAgiPSRlcIMZgiAIgiAIglD5xCc+ga6uLjz99NNObwpBEARBEAThIOREJAiCIAiCIAiCIAiCIAhiREhEJAiCIAiCIEzR0NCAYDA47L9MSqYJgiAIgiAIsaFyZoIgCIIgCMIUiUQChw4dGvbnkydPhtvtHrsNIgiCIAiCILIGiYgEQRAEQRAEQRAEQRAEQYwIlTMTBEEQBEEQBEEQBEEQBDEiJCISBEEQBEEQBEEQBEEQBDEiJCISBEEQBEEQBEEQBEEQBDEiJCISBEEQBEEQBEEQBEEQBDEiJCISBEEQBEEQBEEQBEEQBDEiJCISBEEQBEEQBEEQBEEQBDEiJCISBEEQBEEQBEEQBEEQBDEiJCISBEEQBEEQBEEQBEEQBDEi/x9ljA1CJjAiHgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "df_all = df_all.set_index(\"trip_hour\")\n", + "df_all.plot.line(figsize=(16, 8))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv (3.10.17)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb b/notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb index db51afd412..4f1329129e 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb @@ -35,12 +35,12 @@ "\n", " \n", " \n", - " \"Colab Run in Colab\n", + " \"Colab Run in Colab\n", " \n", " \n", " \n", " \n", - " \"GitHub\n", + " \"GitHub\n", " View on GitHub\n", " \n", " \n", @@ -430,7 +430,7 @@ "source": [ "from bigframes.ml.llm import GeminiTextGenerator\n", "\n", - "model = GeminiTextGenerator()" + "model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")" ] }, { @@ -914,8 +914,8 @@ }, "outputs": [], "source": [ - "@bf.remote_function([str], str)\n", - "def extract_code(text: str):\n", + "@bf.remote_function(cloud_function_service_account=\"default\")\n", + "def extract_code(text: str) -> str:\n", " try:\n", " res = text[text.find('\\n')+1:text.find('```', 3)]\n", " res = res.replace(\"import pandas as pd\", \"import bigframes.pandas as bf\")\n", @@ -1093,7 +1093,7 @@ "import uuid\n", "BUCKET_ID = \"code-samples-\" + str(uuid.uuid1())\n", "\n", - "!gsutil mb gs://{BUCKET_ID}" + "!gcloud storage buckets create gs://{BUCKET_ID}" ] }, { @@ -1272,7 +1272,7 @@ "outputs": [], "source": [ "# # Delete the Google Cloud Storage bucket and files\n", - "# ! gsutil rm -r gs://{BUCKET_ID}\n", + "# ! gcloud storage rm gs://{BUCKET_ID} --recursive\n", "# print(f\"Deleted bucket '{BUCKET_ID}'.\")" ] } diff --git a/notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb b/notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb index d458a0f53b..1a9b568897 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb @@ -369,7 +369,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.14" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb b/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb index 254ac65358..08891d2b44 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb @@ -32,12 +32,12 @@ "\n", " \n", " \n", - " \"Colab Run in Colab\n", + " \"Colab Run in Colab\n", " \n", " \n", " \n", " \n", - " \"GitHub\n", + " \"GitHub\n", " View on GitHub\n", " \n", " \n", @@ -1614,7 +1614,7 @@ "source": [ "from bigframes.ml.llm import GeminiTextGenerator\n", "\n", - "q_a_model = GeminiTextGenerator()" + "q_a_model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")" ] }, { @@ -1736,7 +1736,7 @@ "provenance": [] }, "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "venv (3.10.14)", "language": "python", "name": "python3" }, @@ -1750,7 +1750,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.9" + "version": "3.10.14" } }, "nbformat": 4, diff --git a/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb b/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb new file mode 100644 index 0000000000..5399363e34 --- /dev/null +++ b/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb @@ -0,0 +1,905 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Format LLM output using an output schema\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BigQuery Studio\n", + " \n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This notebook shows you how to create structured LLM output by specifying an output schema when generating predictions with a Gemini model." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Costs\n", + "\n", + "This tutorial uses billable components of Google Cloud:\n", + "\n", + "* BigQuery (compute)\n", + "* BigQuery ML\n", + "* Generative AI support on Vertex AI\n", + "\n", + "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models), [Generative AI support on Vertex AI pricing](https://cloud.google.com/vertex-ai/generative-ai/pricing),\n", + "and [BigQuery ML pricing](https://cloud.google.com/bigquery/pricing#section-11),\n", + "and use the [Pricing Calculator](https://cloud.google.com/products/calculator/)\n", + "to generate a cost estimate based on your projected usage." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Before you begin\n", + "\n", + "Complete the tasks in this section to set up your environment." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Set up your Google Cloud project\n", + "\n", + "**The following steps are required, regardless of your notebook environment.**\n", + "\n", + "1. [Select or create a Google Cloud project](https://console.cloud.google.com/cloud-resource-manager). When you first create an account, you get a $300 credit towards your compute/storage costs.\n", + "\n", + "2. [Make sure that billing is enabled for your project](https://cloud.google.com/billing/docs/how-to/modify-project).\n", + "\n", + "3. [Click here](https://console.cloud.google.com/flows/enableapi?apiid=bigquery.googleapis.com,bigqueryconnection.googleapis.com,aiplatform.googleapis.com) to enable the following APIs:\n", + "\n", + " * BigQuery API\n", + " * BigQuery Connection API\n", + " * Vertex AI API\n", + "\n", + "4. If you are running this notebook locally, install the [Cloud SDK](https://cloud.google.com/sdk)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Authenticate your Google Cloud account\n", + "\n", + "Depending on your Jupyter environment, you might have to manually authenticate. Follow the relevant instructions below.\n", + "\n", + "**BigQuery Studio** or **Vertex AI Workbench**\n", + "\n", + "Do nothing, you are already authenticated.\n", + "\n", + "**Local JupyterLab instance**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# ! gcloud auth login" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Colab**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# from google.colab import auth\n", + "# auth.authenticate_user()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set up your project" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set your project and import necessary modules. If you don't know your project ID, see [Locate the project ID](https://support.google.com/googleapi/answer/7014113)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "PROJECT = \"\" # replace with your project\n", + "import bigframes\n", + "bigframes.options.bigquery.project = PROJECT\n", + "bigframes.options.display.progress_bar = None\n", + "\n", + "import bigframes.pandas as bpd\n", + "from bigframes.ml import llm" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create a DataFrame and a Gemini model\n", + "Create a simple [DataFrame](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.dataframe.DataFrame) of several cities:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/core/global_session.py:103: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", + " _global_session = bigframes.session.connect(\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
city
0Seattle
1New York
2Shanghai
\n", + "

3 rows × 1 columns

\n", + "
[3 rows x 1 columns in total]" + ], + "text/plain": [ + " city\n", + "0 Seattle\n", + "1 New York\n", + "2 Shanghai\n", + "\n", + "[3 rows x 1 columns]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.DataFrame({\"city\": [\"Seattle\", \"New York\", \"Shanghai\"]})\n", + "df" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Connect to a Gemini model using the [`GeminiTextGenerator` class](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator):" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/core/log_adapter.py:175: FutureWarning: Since upgrading the default model can cause unintended breakages, the\n", + "default model will be removed in BigFrames 3.0. Please supply an\n", + "explicit model to avoid this message.\n", + " return method(*args, **kwargs)\n" + ] + } + ], + "source": [ + "gemini = llm.GeminiTextGenerator()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Generate structured output data\n", + "Previously, LLMs could only generate text output. For example, you could generate output that identifies whether a given city is a US city:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
cityml_generate_text_llm_result
0SeattleYes, Seattle is a city in the United States. I...
1New YorkYes, New York City is a city in the United Sta...
2ShanghaiNo, Shanghai is not a US city. It is a major c...
\n", + "

3 rows × 2 columns

\n", + "
[3 rows x 2 columns in total]" + ], + "text/plain": [ + " city ml_generate_text_llm_result\n", + "0 Seattle Yes, Seattle is a city in the United States. I...\n", + "1 New York Yes, New York City is a city in the United Sta...\n", + "2 Shanghai No, Shanghai is not a US city. It is a major c...\n", + "\n", + "[3 rows x 2 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = gemini.predict(df, prompt=[df[\"city\"], \"is a US city?\"])\n", + "result[[\"city\", \"ml_generate_text_llm_result\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The output is text that a human can read. However, if you want the output to be more useful for analysis, it is better to format the output as structured data. This is especially true when you want to have Boolean, integer, or float values to work with instead of string values. Previously, formatting the output in this way wasn't easy.\n", + "\n", + "Now, you can get structured output out-of-the-box by specifying the `output_schema` parameter when calling the Gemini model's [`predict` method](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator#bigframes_ml_llm_GeminiTextGenerator_predict). In the following example, the model output is formatted as Boolean values:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
cityis_us_city
0SeattleTrue
1New YorkTrue
2ShanghaiFalse
\n", + "

3 rows × 2 columns

\n", + "
[3 rows x 2 columns in total]" + ], + "text/plain": [ + " city is_us_city\n", + "0 Seattle True\n", + "1 New York True\n", + "2 Shanghai False\n", + "\n", + "[3 rows x 2 columns]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = gemini.predict(df, prompt=[df[\"city\"], \"is a US city?\"], output_schema={\"is_us_city\": \"bool\"})\n", + "result[[\"city\", \"is_us_city\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can also format model output as float or integer values. In the following example, the model output is formatted as float values to show the city's population in millions:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
citypopulation_in_millions
0Seattle0.75
1New York19.68
2Shanghai26.32
\n", + "

3 rows × 2 columns

\n", + "
[3 rows x 2 columns in total]" + ], + "text/plain": [ + " city population_in_millions\n", + "0 Seattle 0.75\n", + "1 New York 19.68\n", + "2 Shanghai 26.32\n", + "\n", + "[3 rows x 2 columns]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = gemini.predict(df, prompt=[\"what is the population in millions of\", df[\"city\"]], output_schema={\"population_in_millions\": \"float64\"})\n", + "result[[\"city\", \"population_in_millions\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the following example, the model output is formatted as integer values to show the count of the city's rainy days:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
cityrainy_days
0Seattle152
1New York123
2Shanghai123
\n", + "

3 rows × 2 columns

\n", + "
[3 rows x 2 columns in total]" + ], + "text/plain": [ + " city rainy_days\n", + "0 Seattle 152\n", + "1 New York 123\n", + "2 Shanghai 123\n", + "\n", + "[3 rows x 2 columns]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = gemini.predict(df, prompt=[\"how many rainy days per year in\", df[\"city\"]], output_schema={\"rainy_days\": \"int64\"})\n", + "result[[\"city\", \"rainy_days\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Format output as multiple data types in one prediction\n", + "Within a single prediction, you can generate multiple columns of output that use different data types. \n", + "\n", + "The input doesn't have to be dedicated prompts as long as the output column names are informative to the model." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
cityis_US_citypopulation_in_millionsrainy_days_per_year
0SeattleTrue0.75152
1New YorkTrue8.8121
2ShanghaiFalse26.32115
\n", + "

3 rows × 4 columns

\n", + "
[3 rows x 4 columns in total]" + ], + "text/plain": [ + " city is_US_city population_in_millions rainy_days_per_year\n", + "0 Seattle True 0.75 152\n", + "1 New York True 8.8 121\n", + "2 Shanghai False 26.32 115\n", + "\n", + "[3 rows x 4 columns]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = gemini.predict(df, prompt=[df[\"city\"]], output_schema={\"is_US_city\": \"bool\", \"population_in_millions\": \"float64\", \"rainy_days_per_year\": \"int64\"})\n", + "result[[\"city\", \"is_US_city\", \"population_in_millions\", \"rainy_days_per_year\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Format output as a composite data type" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can generate composite data types like arrays and structs. The following example generates a `places_to_visit` column as an array of strings and a `gps_coordinates` column as a struct of floats:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
cityis_US_citypopulation_in_millionsrainy_days_per_yearplaces_to_visitgps_coordinates
0SeattleTrue0.74150['Space Needle' 'Pike Place Market' 'Museum of...{'latitude': 47.6062, 'longitude': -122.3321}
1New YorkTrue8.4121['Times Square' 'Central Park' 'Statue of Libe...{'latitude': 40.7128, 'longitude': -74.006}
2ShanghaiFalse26.32115['The Bund' 'Yu Garden' 'Shanghai Museum' 'Ori...{'latitude': 31.2304, 'longitude': 121.4737}
\n", + "

3 rows × 6 columns

\n", + "
[3 rows x 6 columns in total]" + ], + "text/plain": [ + " city is_US_city population_in_millions rainy_days_per_year \\\n", + "0 Seattle True 0.74 150 \n", + "1 New York True 8.4 121 \n", + "2 Shanghai False 26.32 115 \n", + "\n", + " places_to_visit \\\n", + "0 ['Space Needle' 'Pike Place Market' 'Museum of... \n", + "1 ['Times Square' 'Central Park' 'Statue of Libe... \n", + "2 ['The Bund' 'Yu Garden' 'Shanghai Museum' 'Ori... \n", + "\n", + " gps_coordinates \n", + "0 {'latitude': 47.6062, 'longitude': -122.3321} \n", + "1 {'latitude': 40.7128, 'longitude': -74.006} \n", + "2 {'latitude': 31.2304, 'longitude': 121.4737} \n", + "\n", + "[3 rows x 6 columns]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = gemini.predict(df, prompt=[df[\"city\"]], output_schema={\"is_US_city\": \"bool\", \"population_in_millions\": \"float64\", \"rainy_days_per_year\": \"int64\", \"places_to_visit\": \"array\", \"gps_coordinates\": \"struct\"})\n", + "result[[\"city\", \"is_US_city\", \"population_in_millions\", \"rainy_days_per_year\", \"places_to_visit\", \"gps_coordinates\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To clean up all Google Cloud resources used in this project, you can [delete the Google Cloud\n", + "project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#shutting_down_projects) you used for the tutorial.\n", + "\n", + "Otherwise, run the following cell to delete the temporary cloud artifacts created during the BigFrames session:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bpd.close_session()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Next steps\n", + "\n", + "Learn more about BigQuery DataFrames in the [documentation](https://cloud.google.com/python/docs/reference/bigframes/latest) and find more sample notebooks in the [GitHub repo](https://github.com/googleapis/python-bigquery-dataframes/tree/main/notebooks)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb b/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb new file mode 100644 index 0000000000..72651f1972 --- /dev/null +++ b/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb @@ -0,0 +1,1790 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "id": "TpJu6BBeooES" + }, + "outputs": [], + "source": [ + "# Copyright 2023 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "EQbZKS7_ooET" + }, + "source": [ + "## Build a Vector Search application using BigQuery DataFrames (aka BigFrames)\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"Vertex\n", + " Open in Vertex AI Workbench\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "vFMjpPBo9aVv" + }, + "source": [ + "**Author:** Sudipto Guha (Google)\n", + "\n", + "**Last updated:** March 16th 2025" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "SHQ3Gx-oooEU" + }, + "source": [ + "## Overview\n", + "\n", + "This notebook will guide you through a practical example of using [BigFrames](https://github.com/googleapis/python-bigquery-dataframes/issues) to perform [vector search](https://cloud.google.com/bigquery/docs/vector-search-intro) and analysis on a patent dataset within BigQuery. We will leverage Python and BigFrames to efficiently process, analyze, and gain insights from a large-scale dataset without moving data from BigQuery.\n", + "\n", + "Here's a breakdown of what we'll cover:\n", + "\n", + "1. **Data Ingestion and Embedding Generation:**\n", + "We will start by reading a public patent dataset directly from BigQuery into a BigFrames DataFrame.\n", + "We'll demonstrate how to use BigFrames' `TextEmbeddingGenerator` to create text embeddings for the patent abstracts. This process converts the textual data into numerical vectors that capture the semantic meaning of each abstract.\n", + "We'll show how BigFrames efficiently performs this embedding generation within BigQuery, avoiding data transfer to the client-side.\n", + "Finally, we'll store the generated embeddings back into a new BigQuery table for subsequent analysis.\n", + "\n", + "2. **Indexing and Similarity Search:**\n", + "Here we'll create a vector index using BigFrames to enable fast and scalable similarity searches.\n", + "We'll demonstrate how to create an IVF index for efficient approximate nearest neighbor searches.\n", + "We'll then perform a vector search using a sample query string to find patents that are semantically similar to the query. This showcases how vector search goes beyond keyword matching to find relevant results based on meaning.\n", + "\n", + "3. **AI-Powered Summarization with Retrieval Augmented Generation (RAG):**\n", + "To further enhance the analysis, we'll implement a RAG pipeline.\n", + "We'll retrieve the top most similar patents based on the vector search results from step 2.\n", + "We'll use BigFrames' `GeminiTextGenerator` to create a prompt for an LLM to generate a concise summary of the retrieved patents.\n", + "This demonstrates how to combine vector search with generative AI to extract and synthesize meaningful insights from complex patent data.\n", + "\n", + "\n", + "We will tie these pieces together in Python using BigQuery DataFrames. [Click here](https://cloud.google.com/bigquery/docs/dataframes-quickstart) to learn more about BigQuery DataFrames!" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "EHjmqb-0ooEU" + }, + "source": [ + "### Dataset\n", + "\n", + "This notebook uses the [BQ Patents Public Dataset](https://bigquery.cloud.google.com/dataset/patents-public-data:patentsview)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "AqdihIDJooEU" + }, + "source": [ + "### Costs\n", + "\n", + "This tutorial uses billable components of Google Cloud:\n", + "\n", + "* BigQuery (compute)\n", + "* BigQuery ML\n", + "* Generative AI support on Vertex AI\n", + "\n", + "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models), [Generative AI support on Vertex AI pricing](https://cloud.google.com/vertex-ai/pricing#generative_ai_models),\n", + "and [BigQuery ML pricing](https://cloud.google.com/bigquery/pricing#bqml),\n", + "and use the [Pricing Calculator](https://cloud.google.com/products/calculator/)\n", + "to generate a cost estimate based on your projected usage." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GqLjnm1hsKGU" + }, + "source": [ + "## Setup & initialization\n", + "\n", + "Make sure you have the required roles and permissions listed below:\n", + "\n", + "For [Vector embedding generation](https://cloud.google.com/bigquery/docs/generate-text-embedding#required_roles)\n", + "\n", + "For [Vector Index creation](https://cloud.google.com/bigquery/docs/vector-index#roles_and_permissions)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Z-mvYJUCooEV" + }, + "source": [ + "## Before you begin\n", + "\n", + "Complete the tasks in this section to set up your environment." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "xn-v3mSvooEV" + }, + "source": [ + "### Set up your Google Cloud project\n", + "\n", + "**The following steps are required, regardless of your notebook environment.**\n", + "\n", + "1. [Select or create a Google Cloud project](https://console.cloud.google.com/cloud-resource-manager). When you first create an account, you get a $300 credit towards your compute/storage costs.\n", + "\n", + "2. [Make sure that billing is enabled for your project](https://cloud.google.com/billing/docs/how-to/modify-project).\n", + "\n", + "3. [Click here](https://console.cloud.google.com/flows/enableapi?apiid=bigquery.googleapis.com,bigqueryconnection.googleapis.com,aiplatform.googleapis.com) to enable the following APIs:\n", + "\n", + " * BigQuery API\n", + " * BigQuery Connection API\n", + " * Vertex AI API\n", + "\n", + "4. If you are running this notebook locally, install the [Cloud SDK](https://cloud.google.com/sdk)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Ioydzb_8ooEV" + }, + "source": [ + "#### Set your project ID\n", + "\n", + "**If you don't know your project ID**, see the support page: [Locate the project ID](https://support.google.com/googleapi/answer/7014113)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "executionInfo": { + "elapsed": 2, + "status": "ok", + "timestamp": 1742191597773, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "b8bKCfIiooEV" + }, + "outputs": [], + "source": [ + "# set your project ID below\n", + "PROJECT_ID = \"bigframes-dev\" # @param {type:\"string\"}\n", + "\n", + "# set your region\n", + "REGION = \"US\" # @param {type: \"string\"}\n", + "\n", + "# Set the project id in gcloud\n", + "#! gcloud config set project {PROJECT_ID}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GbUgWr6LooEV" + }, + "source": [ + "#### Authenticate your Google Cloud account\n", + "\n", + "Depending on your Jupyter environment, you might have to manually authenticate. Follow the relevant instructions below." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "U7ChP8jUooEV" + }, + "source": [ + "**Vertex AI Workbench**\n", + "\n", + "Do nothing, you are already authenticated." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "VfHOYcZZooEW" + }, + "source": [ + "**Local JupyterLab instance**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "id": "3cGhUVM0ooEW" + }, + "outputs": [], + "source": [ + "# ! gcloud auth login" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "AoHnXlg-ooEW" + }, + "source": [ + "**Colab**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "executionInfo": { + "elapsed": 2, + "status": "ok", + "timestamp": 1742191608487, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "j3lmnsh7ooEW", + "outputId": "eb68daf5-5558-487a-91d2-4b4f9e476da0" + }, + "outputs": [], + "source": [ + "# from google.colab import auth\n", + "# auth.authenticate_user()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "a9gsyttuooEW" + }, + "source": [ + "Now we are ready to use BigQuery DataFrames!" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "xckgWno6ouHY" + }, + "source": [ + "## Step 1: Data Ingestion and Embedding Generation" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Hjg9jDN-ooEW" + }, + "source": [ + "Install libraries" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "executionInfo": { + "elapsed": 947, + "status": "ok", + "timestamp": 1742195413800, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "R7STCS8xB5d2" + }, + "outputs": [], + "source": [ + "import bigframes.pandas as bf\n", + "import bigframes.ml as bf_ml\n", + "import bigframes.bigquery as bf_bq\n", + "import bigframes.ml.llm as bf_llm\n", + "\n", + "\n", + "from google.cloud import bigquery\n", + "from google.cloud import storage\n", + "\n", + "# Construct a BigQuery client object.\n", + "client = bigquery.Client()\n", + "\n", + "import pandas as pd\n", + "from IPython.display import Image, display\n", + "from PIL import Image as PILImage\n", + "import io\n", + "\n", + "import json\n", + "from IPython.display import Markdown\n", + "\n", + "# Note: The project option is not required in all environments.\n", + "# On BigQuery Studio, the project ID is automatically detected.\n", + "bf.options.bigquery.project = PROJECT_ID\n", + "bf.options.bigquery.location = REGION\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "iOFF9hrvs5WE" + }, + "source": [ + "Partial ordering mode allows BigQuery DataFrames to push down many more row and column filters. On large clustered and partitioned tables, this can greatly reduce the number of bytes scanned and computation slots used. This [blog post](https://medium.com/google-cloud/introducing-partial-ordering-mode-for-bigquery-dataframes-bigframes-ec35841d95c0) goes over it in more detail." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "executionInfo": { + "elapsed": 2, + "status": "ok", + "timestamp": 1742191620533, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "9Gil1Oaas7KA" + }, + "outputs": [], + "source": [ + "bf.options.bigquery.ordering_mode = \"partial\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "XGaGyyZsooEW" + }, + "source": [ + "If you want to reset the location of the created DataFrame or Series objects, reset the session by executing `bf.close_session()`. After that, you can reuse `bf.options.bigquery.location` to specify another location." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "v6FGschEowht" + }, + "source": [ + "Data Input - read the data from a publicly available BigQuery dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "executionInfo": { + "elapsed": 468, + "status": "ok", + "timestamp": 1742192516923, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "zDSwoBo1CU3G", + "outputId": "83edbc2f-5a23-407b-8890-f968eb31be44" + }, + "outputs": [], + "source": [ + "publications = bf.read_gbq('patents-public-data.google_patents_research.publications')" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 34 + }, + "executionInfo": { + "elapsed": 6697, + "status": "ok", + "timestamp": 1742192524632, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "tYDoaKgJChiq", + "outputId": "9174da29-a051-4a99-e38f-6a2b09cfe4e9" + }, + "outputs": [], + "source": [ + "## create patents base table (subset of 10k out of ~110M records)\n", + "\n", + "keep = (publications.embedding_v1.str.len() > 0) & (publications.title.str.len() > 0) & (publications.abstract.str.len() > 30)\n", + "\n", + "## Choose 10000 random rows to analyze\n", + "publications = publications[keep].peek(10000)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 556 + }, + "executionInfo": { + "elapsed": 6, + "status": "ok", + "timestamp": 1742191801044, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "XmqdJInztzPl", + "outputId": "ae05f3a6-edeb-423a-c061-c416717e1ec5" + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
publication_numbertitletitle_translatedabstractabstract_translatedcpccpc_lowcpc_inventive_lowtop_termssimilarurlcountrypublication_descriptioncited_byembedding_v1
0WO-2007022924-B1Pharmaceutical compositions with melting point...FalseThe invention relates to the use of chemical f...False[{'code': 'A61K47/32', 'inventive': True, 'fir...['A61K47/32' 'A61K47/30' 'A61K47/00' 'A61K' 'A...['A61K47/32' 'A61K47/30' 'A61K47/00' 'A61K' 'A...['composition' 'mucosa' 'melting point' 'agent...[{'publication_number': 'WO-2007022924-B1', 'a...https://patents.google.com/patent/WO2007022924B1WIPO (PCT)Amended claims[][ 5.3550040e-02 -9.3632710e-02 1.4337189e-02 ...
1WO-03043855-B1Convenience lighting for interior and exterior...FalseA lighting apparatus for a vehicle(21) include...False[{'code': 'B60Q1/247', 'inventive': True, 'fir...['B60Q1/247' 'B60Q1/24' 'B60Q1/02' 'B60Q1/00' ...['B60Q1/247' 'B60Q1/24' 'B60Q1/02' 'B60Q1/00' ...['vehicle' 'light' 'apparatus defined' 'pillar...[{'publication_number': 'WO-03043855-B1', 'app...https://patents.google.com/patent/WO2003043855B1WIPO (PCT)Amended claims[][ 0.00484032 -0.02695554 -0.20798226 -0.207528...
2AU-2020396918-A2Shot detection and verification systemFalseA shot detection system for a projectile weapo...False[{'code': 'F41A19/01', 'inventive': True, 'fir...['F41A19/01' 'F41A19/00' 'F41A' 'F41' 'F' 'H04...['F41A19/01' 'F41A19/00' 'F41A' 'F41' 'F' 'H04...['interest' 'region' 'property' 'shot' 'test' ...[{'publication_number': 'US-2023228510-A1', 'a...https://patents.google.com/patent/AU2020396918A2AustraliaAmended post open to public inspection[][-1.49729420e-02 -2.27105440e-01 -2.68012730e-...
3PL-347539-A1Concrete mix of increased fire resistanceFalseThe burning resistance of concrete containing ...False[{'code': 'Y02W30/91', 'inventive': False, 'fi...['Y02W30/91' 'Y02W30/50' 'Y02W30/00' 'Y02W' 'Y...['Y02W30/91' 'Y02W30/50' 'Y02W30/00' 'Y02W' 'Y...['fire resistance' 'concrete mix' 'increased f...[{'publication_number': 'DK-1564194-T3', 'appl...https://patents.google.com/patent/PL347539A1PolandApplication[][ 0.01849568 -0.05340371 -0.19257502 -0.174919...
4AU-PS049302-A0Methods and systems (ap53)FalseA charging stand for charging a mobile phone, ...False[{'code': 'H02J7/00', 'inventive': True, 'firs...['H02J7/00' 'H02J' 'H02' 'H' 'H04B1/40' 'H04B1...['H02J7/00' 'H02J' 'H02' 'H' 'H04B1/40' 'H04B1...['connection pin' 'mobile phone' 'cartridge' '...[{'publication_number': 'AU-PS049302-A0', 'app...https://patents.google.com/patent/AUPS049302A0AustraliaApplication filed, as announced in the Gazette...[][ 0.00064732 -0.2136009 0.0040593 -0.024562...
\n", + "
" + ], + "text/plain": [ + " publication_number title \\\n", + "0 WO-2007022924-B1 Pharmaceutical compositions with melting point... \n", + "1 WO-03043855-B1 Convenience lighting for interior and exterior... \n", + "2 AU-2020396918-A2 Shot detection and verification system \n", + "3 PL-347539-A1 Concrete mix of increased fire resistance \n", + "4 AU-PS049302-A0 Methods and systems (ap53) \n", + "\n", + " title_translated abstract \\\n", + "0 False The invention relates to the use of chemical f... \n", + "1 False A lighting apparatus for a vehicle(21) include... \n", + "2 False A shot detection system for a projectile weapo... \n", + "3 False The burning resistance of concrete containing ... \n", + "4 False A charging stand for charging a mobile phone, ... \n", + "\n", + " abstract_translated cpc \\\n", + "0 False [{'code': 'A61K47/32', 'inventive': True, 'fir... \n", + "1 False [{'code': 'B60Q1/247', 'inventive': True, 'fir... \n", + "2 False [{'code': 'F41A19/01', 'inventive': True, 'fir... \n", + "3 False [{'code': 'Y02W30/91', 'inventive': False, 'fi... \n", + "4 False [{'code': 'H02J7/00', 'inventive': True, 'firs... \n", + "\n", + " cpc_low \\\n", + "0 ['A61K47/32' 'A61K47/30' 'A61K47/00' 'A61K' 'A... \n", + "1 ['B60Q1/247' 'B60Q1/24' 'B60Q1/02' 'B60Q1/00' ... \n", + "2 ['F41A19/01' 'F41A19/00' 'F41A' 'F41' 'F' 'H04... \n", + "3 ['Y02W30/91' 'Y02W30/50' 'Y02W30/00' 'Y02W' 'Y... \n", + "4 ['H02J7/00' 'H02J' 'H02' 'H' 'H04B1/40' 'H04B1... \n", + "\n", + " cpc_inventive_low \\\n", + "0 ['A61K47/32' 'A61K47/30' 'A61K47/00' 'A61K' 'A... \n", + "1 ['B60Q1/247' 'B60Q1/24' 'B60Q1/02' 'B60Q1/00' ... \n", + "2 ['F41A19/01' 'F41A19/00' 'F41A' 'F41' 'F' 'H04... \n", + "3 ['Y02W30/91' 'Y02W30/50' 'Y02W30/00' 'Y02W' 'Y... \n", + "4 ['H02J7/00' 'H02J' 'H02' 'H' 'H04B1/40' 'H04B1... \n", + "\n", + " top_terms \\\n", + "0 ['composition' 'mucosa' 'melting point' 'agent... \n", + "1 ['vehicle' 'light' 'apparatus defined' 'pillar... \n", + "2 ['interest' 'region' 'property' 'shot' 'test' ... \n", + "3 ['fire resistance' 'concrete mix' 'increased f... \n", + "4 ['connection pin' 'mobile phone' 'cartridge' '... \n", + "\n", + " similar \\\n", + "0 [{'publication_number': 'WO-2007022924-B1', 'a... \n", + "1 [{'publication_number': 'WO-03043855-B1', 'app... \n", + "2 [{'publication_number': 'US-2023228510-A1', 'a... \n", + "3 [{'publication_number': 'DK-1564194-T3', 'appl... \n", + "4 [{'publication_number': 'AU-PS049302-A0', 'app... \n", + "\n", + " url country \\\n", + "0 https://patents.google.com/patent/WO2007022924B1 WIPO (PCT) \n", + "1 https://patents.google.com/patent/WO2003043855B1 WIPO (PCT) \n", + "2 https://patents.google.com/patent/AU2020396918A2 Australia \n", + "3 https://patents.google.com/patent/PL347539A1 Poland \n", + "4 https://patents.google.com/patent/AUPS049302A0 Australia \n", + "\n", + " publication_description cited_by \\\n", + "0 Amended claims [] \n", + "1 Amended claims [] \n", + "2 Amended post open to public inspection [] \n", + "3 Application [] \n", + "4 Application filed, as announced in the Gazette... [] \n", + "\n", + " embedding_v1 \n", + "0 [ 5.3550040e-02 -9.3632710e-02 1.4337189e-02 ... \n", + "1 [ 0.00484032 -0.02695554 -0.20798226 -0.207528... \n", + "2 [-1.49729420e-02 -2.27105440e-01 -2.68012730e-... \n", + "3 [ 0.01849568 -0.05340371 -0.19257502 -0.174919... \n", + "4 [ 0.00064732 -0.2136009 0.0040593 -0.024562... " + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "## take a look at the sample dataset\n", + "\n", + "publications.head(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Wl2o-NYMoygb" + }, + "source": [ + "Generate the text embeddings" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 34 + }, + "executionInfo": { + "elapsed": 4528, + "status": "ok", + "timestamp": 1742192047236, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "li38q8FzDDMu", + "outputId": "b8c1bd38-b484-4f71-bd38-927c8677d0c5" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 0e9d9117-4981-4f5c-b785-ed831c08e7aa is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job fa4f1a54-85d4-4030-992e-fddda5edf3e3 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from bigframes.ml.llm import TextEmbeddingGenerator\n", + "\n", + "text_model = TextEmbeddingGenerator(\n", + " model_name=\"text-embedding-005\",\n", + " # No connection id needed\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 139 + }, + "executionInfo": { + "elapsed": 126632, + "status": "ok", + "timestamp": 1742192656608, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "b5HHZob_u61B", + "outputId": "c9ecc5fd-5d11-4fd8-f59b-9dce4e12e371" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Load job 70377d71-bb13-46af-80c1-71ef16bf2949 is DONE. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job cc3b609d-b6b7-404f-9447-c76d3a52698b is DONE. 9.5 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + } + ], + "source": [ + "## rename abstract column to content as the desired column on which embedding will be generated\n", + "publications = publications[[\"publication_number\", \"title\", \"abstract\"]].rename(columns={'abstract': 'content'})\n", + "\n", + "## generate the embeddings\n", + "## takes ~2-3 mins to run\n", + "embedding = text_model.predict(publications)[[\"publication_number\", \"title\", \"content\", \"ml_generate_embedding_result\",\"ml_generate_embedding_status\"]]\n", + "\n", + "## filter out rows where the embedding generation failed. the embedding status value is empty if the embedding generation was successful\n", + "embedding = embedding[~embedding[\"ml_generate_embedding_status\"].isnull()]\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 464 + }, + "executionInfo": { + "elapsed": 6715, + "status": "ok", + "timestamp": 1742192727525, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "OIT5FbqAwqG5", + "outputId": "d04c994a-a0c8-44b0-e897-d871036eeb1f" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 5b15fc4a-fa9a-4608-825f-be5af9953a38 is DONE. 71.0 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
publication_numbertitlecontentml_generate_embedding_resultml_generate_embedding_status
5611WO-2014005277-A1Resource management in a cloud computing envir...Technologies and implementations for managing ...[-2.92946529e-02 -1.24640828e-02 1.27173709e-...
6895AU-2011325479-B27-([1,2,3]triazol-4-yl)-pyrrolo[2,3-b]pyrazine...Compounds of formula I, in which R[-6.45397678e-02 1.19616119e-02 -9.85191786e-...
6IL-45347-A7h-indolizino(5,6,7-ij)isoquinoline derivative...Compounds of the formula:\\n[US3946019A][-3.82784344e-02 -2.31682733e-02 -4.35006060e-...
5923WO-2005111625-A3Method to predict prostate cancerA method for predicting the probability or ris...[ 0.02480386 -0.01648765 0.03873815 -0.025998...
6370US-7868678-B2Configurable differential linesEmbodiments related to configurable differenti...[ 2.71715336e-02 -1.93733890e-02 2.82729534e-...
\n", + "

5 rows × 5 columns

\n", + "
[5 rows x 5 columns in total]" + ], + "text/plain": [ + " publication_number title \\\n", + "5611 WO-2014005277-A1 Resource management in a cloud computing envir... \n", + "6895 AU-2011325479-B2 7-([1,2,3]triazol-4-yl)-pyrrolo[2,3-b]pyrazine... \n", + "6 IL-45347-A 7h-indolizino(5,6,7-ij)isoquinoline derivative... \n", + "5923 WO-2005111625-A3 Method to predict prostate cancer \n", + "6370 US-7868678-B2 Configurable differential lines \n", + "\n", + " content \\\n", + "5611 Technologies and implementations for managing ... \n", + "6895 Compounds of formula I, in which R \n", + "6 Compounds of the formula:\\n[US3946019A] \n", + "5923 A method for predicting the probability or ris... \n", + "6370 Embodiments related to configurable differenti... \n", + "\n", + " ml_generate_embedding_result \\\n", + "5611 [-2.92946529e-02 -1.24640828e-02 1.27173709e-... \n", + "6895 [-6.45397678e-02 1.19616119e-02 -9.85191786e-... \n", + "6 [-3.82784344e-02 -2.31682733e-02 -4.35006060e-... \n", + "5923 [ 0.02480386 -0.01648765 0.03873815 -0.025998... \n", + "6370 [ 2.71715336e-02 -1.93733890e-02 2.82729534e-... \n", + "\n", + " ml_generate_embedding_status \n", + "5611 \n", + "6895 \n", + "6 \n", + "5923 \n", + "6370 \n", + "\n", + "[5 rows x 5 columns]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "embedding.head(5)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 53 + }, + "executionInfo": { + "elapsed": 6590, + "status": "ok", + "timestamp": 1742192833667, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "GP3ZqX_bxLGq", + "outputId": "fb823ea2-e47c-415f-84d4-543dd3291e15" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 06ce090b-e3f9-4252-b847-45c2a296ca61 is DONE. 70.9 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "'my_dataset.my_embeddings_table'" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# store embeddings in a BQ table\n", + "DATASET_ID = \"my_dataset\" # @param {type:\"string\"}\n", + "TEXT_EMBEDDING_TABLE_ID = \"my_embeddings_table\" # @param {type:\"string\"}\n", + "embedding.to_gbq(f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\", if_exists='replace')" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "OUZ3NNbzo1Tb" + }, + "source": [ + "## Step 2: Indexing and Similarity Search" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "mvJH2FCmynMm" + }, + "source": [ + "### [Create a Vector Index](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery#bigframes_bigquery_create_vector_index) using BigFrames\n", + "\n", + "\n", + "**Index Type**\n", + "\n", + "The algorithm to use to build the vector index.\n", + "The supported values are IVF and TREE_AH." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 34 + }, + "executionInfo": { + "elapsed": 3882, + "status": "ok", + "timestamp": 1742193028877, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "6SBVdv6gyU5A", + "outputId": "6583e113-de27-4b44-972d-c1cc061e3c76" + }, + "outputs": [], + "source": [ + "## create vector index (note only works of tables >5000 rows)\n", + "\n", + "bf_bq.create_vector_index(\n", + " table_id = f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\",\n", + " column_name = \"ml_generate_embedding_result\",\n", + " replace= True,\n", + " index_name = \"bf_python_index\",\n", + " distance_type=\"cosine\",\n", + " index_type= \"ivf\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "bo8mBbRLzCOA" + }, + "source": [ + "### Vector Search (semantic search) using Vector Index\n", + "\n", + "ANN (approx nearest neighbor) search using the created vector index" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "executionInfo": { + "elapsed": 639, + "status": "ok", + "timestamp": 1742194606771, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "v19BJm_wzPdZ" + }, + "outputs": [], + "source": [ + "## Set variable for vector search\n", + "\n", + "TEXT_SEARCH_STRING = \"Chip assemblies employing solder bonds to back-side lands including an electrolytic nickel layer\" ## replace with whatever search string you want to use for the vector search\n", + "FRACTION_LISTS_TO_SEARCH = 0.01" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 121 + }, + "executionInfo": { + "elapsed": 6927, + "status": "ok", + "timestamp": 1742194625774, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "pAQY1ejpzPap", + "outputId": "485698ad-ac6e-4c93-844e-5d0f30aff13a" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 016ad678-9609-4c78-8f07-3f9887ce67ac is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + } + ], + "source": [ + "# convert search string to dataframe\n", + "TEXT_SEARCH_DF = bf.DataFrame([TEXT_SEARCH_STRING], columns=['search_string'])\n", + "\n", + "#generate embedding of search query\n", + "search_query = bf.DataFrame(text_model.predict(TEXT_SEARCH_DF))" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 104 + }, + "executionInfo": { + "elapsed": 5110, + "status": "ok", + "timestamp": 1742194670801, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "sx0AGAdn5FYX", + "outputId": "551ebac3-594f-4303-ca97-5301dfee72bb" + }, + "outputs": [], + "source": [ + "## search the base table for the user's query\n", + "\n", + "vector_search_results = bf_bq.vector_search(\n", + " base_table=f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\",\n", + " column_to_search=\"ml_generate_embedding_result\",\n", + " query=search_query,\n", + " distance_type=\"cosine\",\n", + " query_column_to_search=\"ml_generate_embedding_result\",\n", + " top_k=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 270 + }, + "executionInfo": { + "elapsed": 3511, + "status": "ok", + "timestamp": 1742195090670, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "px1v4iJM5L0c", + "outputId": "d107b6e3-a362-42db-c0c2-084d02acd244" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Load job b6b88844-9ed7-4c92-8984-556414592f0b is DONE. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job aa95f59c-7229-4e76-bd2c-3a63deea3285 is DONE. 4.7 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
querypublication_numbertitle (relevant match)abstract (relevant match)distance
0Chip assemblies employing solder bonds to back...CN-103515336-AChip package, chip arrangement, circuit board ...A chip package is provided, the chip package i...0.287274
0Chip assemblies employing solder bonds to back...US-9548145-B2Microelectronic assembly with multi-layer supp...A method of forming a microelectronic assembly...0.290519
0Chip assemblies employing solder bonds to back...JP-2012074505-ASemiconductor mounting device substrate, semic...To provide a substrate for a semiconductor mou...0.294241
0Chip assemblies employing solder bonds to back...US-2015380164-A1Ceramic electronic componentA ceramic electronic component includes an ele...0.295716
0Chip assemblies employing solder bonds to back...US-2012153447-A1Microelectronic flip chip packages with solder...Processes of assembling microelectronic packag...0.300337
\n", + "

5 rows × 5 columns

\n", + "
[5 rows x 5 columns in total]" + ], + "text/plain": [ + " query publication_number \\\n", + "0 Chip assemblies employing solder bonds to back... CN-103515336-A \n", + "0 Chip assemblies employing solder bonds to back... US-9548145-B2 \n", + "0 Chip assemblies employing solder bonds to back... JP-2012074505-A \n", + "0 Chip assemblies employing solder bonds to back... US-2015380164-A1 \n", + "0 Chip assemblies employing solder bonds to back... US-2012153447-A1 \n", + "\n", + " title (relevant match) \\\n", + "0 Chip package, chip arrangement, circuit board ... \n", + "0 Microelectronic assembly with multi-layer supp... \n", + "0 Semiconductor mounting device substrate, semic... \n", + "0 Ceramic electronic component \n", + "0 Microelectronic flip chip packages with solder... \n", + "\n", + " abstract (relevant match) distance \n", + "0 A chip package is provided, the chip package i... 0.287274 \n", + "0 A method of forming a microelectronic assembly... 0.290519 \n", + "0 To provide a substrate for a semiconductor mou... 0.294241 \n", + "0 A ceramic electronic component includes an ele... 0.295716 \n", + "0 Processes of assembling microelectronic packag... 0.300337 \n", + "\n", + "[5 rows x 5 columns]" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "## View the returned results based on simalirity with the user's query\n", + "\n", + "vector_search_results[\n", + " [\n", + " 'content',\n", + " 'publication_number',\n", + " 'title',\n", + " 'content_1',\n", + " 'distance',\n", + " ]\n", + "].rename(columns={\n", + " 'content': 'query',\n", + " 'content_1':'abstract (relevant match)' ,\n", + " 'title':'title (relevant match)',\n", + "})" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "executionInfo": { + "elapsed": 1622, + "status": "ok", + "timestamp": 1742195139318, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "5fb_O-ne5cvH" + }, + "outputs": [], + "source": [ + "## Brute force result (for comparison)\n", + "\n", + "\n", + "brute_force_result = bf_bq.vector_search(\n", + " base_table=f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\",\n", + " column_to_search=\"ml_generate_embedding_result\",\n", + " query=search_query,\n", + " top_k=5,\n", + " distance_type=\"cosine\",\n", + " use_brute_force=True,\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "21rNsFMHo8hO" + }, + "source": [ + "## Step 3: AI-Powered Summarization with Retrieval Augmented Generation (RAG)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "K3pIQrzB7T_G" + }, + "source": [ + "Patent documents can be dense and time-consuming to digest. AI-Powered Patent Summarization utilizes Retrieval Augmented Generation (RAG) to streamline this process. By retrieving relevant patent information through vector search and then synthesizing it with a large language model, we can generate concise, human-readable summaries, saving valuable time and effort. The code sample below walks through how to set this up continuing with the same user query as the previous use case." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 34 + }, + "executionInfo": { + "elapsed": 4827, + "status": "ok", + "timestamp": 1742195565658, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "jb5rueqU7T5J", + "outputId": "43732836-ebae-4fb3-b28e-bfea51146c72" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 3fabe659-f95b-49cb-b0c7-9d32b09177bf is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "## gemini model\n", + "\n", + "llm_model = bf_llm.GeminiTextGenerator(model_name = \"gemini-2.0-flash-001\") ## replace with other model as needed" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "41e12JTf70sr" + }, + "source": [ + "We will use the same user query from Section 2, and pass the list of abstracts returned by the vector search into the prompt for the RAG application" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": { + "executionInfo": { + "elapsed": 1474, + "status": "ok", + "timestamp": 1742195536109, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "EyP-ZFJK8h-2" + }, + "outputs": [], + "source": [ + "TEMPERATURE = 0.4" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 72 + }, + "executionInfo": { + "elapsed": 3371, + "status": "ok", + "timestamp": 1742195421813, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "eP99R6SV7Tug", + "outputId": "c34bc931-5be8-410e-ac1f-604df31ef533" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['{\"abstract\": \"A chip package is provided, the chip package including: a chip carrier; a chip disposed over and electrically connected to a chip carrier top side; an electrically insulating material disposed over and at least partially surrounding the chip; one or more electrically conductive contact regions formed over the electrically insulating material and in electrical connection with the chip; and another electrically insulating material disposed over a chip carrier bottom side. An electrically conductive contact region on the chip carrier bottom side is released from the further electrically insulating material.\"}', '{\"abstract\": \"A method of forming a microelectronic assembly includes positioning a support structure adjacent to an active region of a device but not extending onto the active region. The support structure has planar sections. Each planar section has a substantially uniform composition. The composition of at least one of the planar sections differs from the composition of at least one of the other planar sections. A lid is positioned in contact with the support structure and extends over the active region. The support structure is bonded to the device and to the lid.\"}', '{\"abstract\": \"To provide a substrate for a semiconductor mounting device capable of obtaining high reliability. In a semiconductor mounting device substrate of the present invention, a semiconductor chip can be surface-mounted by a flip chip connection method on a semiconductor chip mounting region of a first main surface of a multilayer wiring substrate. A plurality of second main surface side solder bumps 52 forming a plate-like component mounting region 53 are formed at a location immediately below the semiconductor chip 21 on the second main surface 13 of the multilayer wiring board 11. A plate-like component 101 mainly composed of an inorganic material is surface-mounted on the multilayer wiring board 11 by a flip chip connection method via a plurality of second main surface side solder bumps 52. A plurality of second main surface side solder bumps 52 are sealed by a second main surface side underfill 107 provided in the gap S <b> 2 between the second main surface 13 and the plate-like component 101. [Selection] Figure 1\"}', '{\"abstract\": \"A ceramic electronic component includes an electronic component body, an inner electrode, and an outer electrode. The outer electrode includes a fired electrode layer and first and second plated layers. The fired electrode layer is disposed on the electronic component body. The first plated layer is disposed on the fired electrode layer. The thickness of the first plated layer is about 3 \\\\u03bcm to about 8 \\\\u03bcm, for example. The first plated layer contains nickel. The second plated layer is disposed on the first plated layer. The thickness of the second plated layer is about 0.025 \\\\u03bcm to about 1 \\\\u03bcm, for example. The second plated layer contains lead.\"}', '{\"abstract\": \"Processes of assembling microelectronic packages with lead frames and/or other suitable substrates are described herein. In one embodiment, a method for fabricating a semiconductor assembly includes forming an attachment area and a non-attachment area on a lead finger of a lead frame. The attachment area is more wettable to the solder ball than the non-attachment area during reflow. The method also includes contacting a solder ball carried by a semiconductor die with the attachment area of the lead finger, reflowing the solder ball while the solder ball is in contact with the attachment area of the lead finger, and controllably collapsing the solder ball to establish an electrical connection between the semiconductor die and the lead finger of the lead frame.\"}']\n" + ] + } + ], + "source": [ + "# Extract strings into a list of JSON strings\n", + "json_strings = [json.dumps({'abstract': s}) for s in vector_search_results['content_1']]\n", + "ALL_ABSTRACTS = json_strings\n", + "\n", + "# Print the result (optional)\n", + "print(ALL_ABSTRACTS)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "executionInfo": { + "elapsed": 1620, + "status": "ok", + "timestamp": 1742195587180, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "kSNSi1GV8OAD", + "outputId": "37fbc822-1160-4fbd-c7d6-ecb4a16db394" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "You are an expert patent analyst. I will provide you the abstracts of the top 5 patents in json format retrieved by a vector search based on a user's query.\n", + "Your task is to analyze these abstracts and generate a concise, coherent summary that encapsulates the core innovations and concepts shared among them.\n", + "\n", + "In your output, share the original user query.\n", + "Then output the concise, coherent summary that encapsulates the core innovations and concepts shared among the top 5 abstracts. The heading for this section should\n", + "be : Summary of the top 5 abstracts that are semantically closest to the user query.\n", + "\n", + "User Query: Chip assemblies employing solder bonds to back-side lands including an electrolytic nickel layer\n", + "Top 5 abstracts: ['{\"abstract\": \"A chip package is provided, the chip package including: a chip carrier; a chip disposed over and electrically connected to a chip carrier top side; an electrically insulating material disposed over and at least partially surrounding the chip; one or more electrically conductive contact regions formed over the electrically insulating material and in electrical connection with the chip; and another electrically insulating material disposed over a chip carrier bottom side. An electrically conductive contact region on the chip carrier bottom side is released from the further electrically insulating material.\"}', '{\"abstract\": \"A method of forming a microelectronic assembly includes positioning a support structure adjacent to an active region of a device but not extending onto the active region. The support structure has planar sections. Each planar section has a substantially uniform composition. The composition of at least one of the planar sections differs from the composition of at least one of the other planar sections. A lid is positioned in contact with the support structure and extends over the active region. The support structure is bonded to the device and to the lid.\"}', '{\"abstract\": \"To provide a substrate for a semiconductor mounting device capable of obtaining high reliability. In a semiconductor mounting device substrate of the present invention, a semiconductor chip can be surface-mounted by a flip chip connection method on a semiconductor chip mounting region of a first main surface of a multilayer wiring substrate. A plurality of second main surface side solder bumps 52 forming a plate-like component mounting region 53 are formed at a location immediately below the semiconductor chip 21 on the second main surface 13 of the multilayer wiring board 11. A plate-like component 101 mainly composed of an inorganic material is surface-mounted on the multilayer wiring board 11 by a flip chip connection method via a plurality of second main surface side solder bumps 52. A plurality of second main surface side solder bumps 52 are sealed by a second main surface side underfill 107 provided in the gap S <b> 2 between the second main surface 13 and the plate-like component 101. [Selection] Figure 1\"}', '{\"abstract\": \"A ceramic electronic component includes an electronic component body, an inner electrode, and an outer electrode. The outer electrode includes a fired electrode layer and first and second plated layers. The fired electrode layer is disposed on the electronic component body. The first plated layer is disposed on the fired electrode layer. The thickness of the first plated layer is about 3 \\\\u03bcm to about 8 \\\\u03bcm, for example. The first plated layer contains nickel. The second plated layer is disposed on the first plated layer. The thickness of the second plated layer is about 0.025 \\\\u03bcm to about 1 \\\\u03bcm, for example. The second plated layer contains lead.\"}', '{\"abstract\": \"Processes of assembling microelectronic packages with lead frames and/or other suitable substrates are described herein. In one embodiment, a method for fabricating a semiconductor assembly includes forming an attachment area and a non-attachment area on a lead finger of a lead frame. The attachment area is more wettable to the solder ball than the non-attachment area during reflow. The method also includes contacting a solder ball carried by a semiconductor die with the attachment area of the lead finger, reflowing the solder ball while the solder ball is in contact with the attachment area of the lead finger, and controllably collapsing the solder ball to establish an electrical connection between the semiconductor die and the lead finger of the lead frame.\"}']\n", + "\n", + "Instructions:\n", + "\n", + "Focus on identifying the common themes and key technological advancements described in the abstracts.\n", + "Synthesize the information into a clear and concise summary, approximately 150-200 words.\n", + "Avoid simply copying phrases from the abstracts. Instead, aim to provide a cohesive overview of the shared concepts.\n", + "Highlight the potential applications and benefits of the described inventions.\n", + "Maintain a professional and objective tone.\n", + "Do not mention the individual patents by number, focus on summarizing the shared concepts.\n", + "\n" + ] + } + ], + "source": [ + "## Setup the LLM prompt\n", + "\n", + "prompt = f\"\"\"\n", + "You are an expert patent analyst. I will provide you the abstracts of the top 5 patents in json format retrieved by a vector search based on a user's query.\n", + "Your task is to analyze these abstracts and generate a concise, coherent summary that encapsulates the core innovations and concepts shared among them.\n", + "\n", + "In your output, share the original user query.\n", + "Then output the concise, coherent summary that encapsulates the core innovations and concepts shared among the top 5 abstracts. The heading for this section should\n", + "be : Summary of the top 5 abstracts that are semantically closest to the user query.\n", + "\n", + "User Query: {TEXT_SEARCH_STRING}\n", + "Top 5 abstracts: {ALL_ABSTRACTS}\n", + "\n", + "Instructions:\n", + "\n", + "Focus on identifying the common themes and key technological advancements described in the abstracts.\n", + "Synthesize the information into a clear and concise summary, approximately 150-200 words.\n", + "Avoid simply copying phrases from the abstracts. Instead, aim to provide a cohesive overview of the shared concepts.\n", + "Highlight the potential applications and benefits of the described inventions.\n", + "Maintain a professional and objective tone.\n", + "Do not mention the individual patents by number, focus on summarizing the shared concepts.\n", + "\"\"\"\n", + "\n", + "print(prompt)" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "executionInfo": { + "elapsed": 1, + "status": "ok", + "timestamp": 1742195567707, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "njiQdfkT8Y7V" + }, + "outputs": [], + "source": [ + "## Define a function that will take the input propmpt and run the LLM\n", + "\n", + "def predict(prompt: str, temperature: float = TEMPERATURE) -> str:\n", + " # Create dataframe\n", + " input = bf.DataFrame(\n", + " {\n", + " \"prompt\": [prompt],\n", + " }\n", + " )\n", + "\n", + " # Return response\n", + " return llm_model.predict(input, temperature=temperature).ml_generate_text_llm_result.iloc[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 426 + }, + "executionInfo": { + "elapsed": 14425, + "status": "ok", + "timestamp": 1742195608280, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "OYYkVYbs8Y0P", + "outputId": "def839e3-3dee-4320-9cb5-cac855ddea6b" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Load job 34f3b649-6e45-46db-a6e5-405ae0a8bf69 is DONE. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job a574725f-64ae-4a19-aac0-959bec0bffeb is DONE. 5.0 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/markdown": [ + "User Query: Chip assemblies employing solder bonds to back-side lands including an electrolytic nickel layer\n", + "\n", + "Summary of the top 5 abstracts that are semantically closest to the user query:\n", + "\n", + "The abstracts describe various aspects of microelectronic assembly and packaging, with a focus on enhancing reliability and electrical connectivity. A common theme is the use of solder bumps or balls for creating electrical connections between different components, such as semiconductor chips and substrates or lead frames. Several abstracts highlight methods for improving the solderability and wettability of contact regions, often involving the use of multiple layers with differing compositions. The use of electrically insulating materials to provide support and protection to the chip and electrical connections is also described. One abstract specifically mentions a nickel-containing plated layer as part of an outer electrode, suggesting its role in improving the electrical or mechanical properties of the connection. The innovations aim to improve the reliability and performance of microelectronic devices through optimized material selection, assembly processes, and structural designs.\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Invoke LLM with prompt\n", + "response = predict(prompt, temperature = TEMPERATURE)\n", + "\n", + "# Print results as Markdown\n", + "Markdown(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "sy82XLDfooEb" + }, + "source": [ + "# Summary and next steps\n", + "\n", + "Ready to dive deeper and explore the endless possibilities? Start building your own vector search applications with BigFrames and BigQuery today! Check out our [documentation](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.bigquery#bigframes_bigquery_vector_search), explore our sample [notebooks](https://github.com/googleapis/python-bigquery-dataframes/tree/main/notebooks), and unleash the power of vector analytics on your data.\n", + "The BigFrames team would also love to hear from you. If you would like to reach out, please send an email to: bigframes-feedback@google.com or by filing an issue at the [open source BigFrames repository](https://github.com/googleapis/python-bigquery-dataframes/issues). To receive updates about BigFrames, subscribe to the BigFrames email list." + ] + } + ], + "metadata": { + "colab": { + "name": "bq_dataframes_llm_kmeans", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb b/notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb index a77f3f11eb..3220bbf6cd 100644 --- a/notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb +++ b/notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb @@ -34,12 +34,12 @@ "\n", " \n", " \n", @@ -581,7 +581,7 @@ ], "source": [ "# Define the model\n", - "model = GeminiTextGenerator()\n", + "model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")\n", "\n", "# Invoke LLM with prompt\n", "response = predict(zero_shot_prompt, temperature = TEMPERATURE)\n", diff --git a/notebooks/generative_ai/large_language_models.ipynb b/notebooks/generative_ai/large_language_models.ipynb index bcb8f0f1a0..1d7bc7f6ef 100644 --- a/notebooks/generative_ai/large_language_models.ipynb +++ b/notebooks/generative_ai/large_language_models.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -21,21 +21,23 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/google/home/chelsealin/src/bigframes1/bigframes/pandas/__init__.py:259: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", - " return global_session.get_global_session()\n" + "/tmp/ipykernel_176683/987800245.py:1: ApiDeprecationWarning: gemini-1.5-X are going to be deprecated. Use gemini-2.0-X (https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) instead. \n", + " model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")\n", + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/ml/llm.py:486: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", + " self.session = session or global_session.get_global_session()\n" ] }, { "data": { "text/html": [ - "Query job 92699550-36bc-4b51-9aec-fa79bc3a4927 is DONE. 0 Bytes processed. Open Job" + "Query job 6fa5121a-6da4-4c75-92ec-936799da4513 is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -47,7 +49,7 @@ { "data": { "text/html": [ - "Query job 973b8369-8ba3-430b-b148-c577ed180024 is DONE. 0 Bytes processed. Open Job" + "Query job 74460ae9-3e89-49e7-93ad-bafbb6197a86 is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -58,7 +60,7 @@ } ], "source": [ - "model = GeminiTextGenerator()" + "model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")" ] }, { @@ -73,7 +75,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ @@ -94,13 +96,13 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "Query job 1b9963ae-d091-467c-8c16-2d4ab8f5b94c is DONE. 0 Bytes processed. Open Job" + "Query job 562ca203-3b53-4409-9a23-0a80d3840fcc is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -113,38 +115,15 @@ "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/google/home/chelsealin/src/bigframes1/bigframes/core/__init__.py:114: PreviewWarning: Interpreting JSON column(s) as pyarrow.large_string. This behavior may change in future versions.\n", + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", " warnings.warn(msg, bfe.PreviewWarning)\n" ] }, { "data": { "text/html": [ - "Query job 27de013f-af76-4730-b14d-dc3f2a7a9c3f is DONE. 6 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 0cf5695d-fdc5-4b73-b477-f69afd2e2fe1 is DONE. 6 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job fd37ef18-1d38-4bfa-b50d-c83a4a861a9b is DONE. 9.6 kB processed. Open Job" + "Query job 5a6ceff2-53b5-4a4a-83ff-31bffab1b8b8 is DONE. 14.0 kB processed. Open Job" ], "text/plain": [ "" @@ -183,24 +162,22 @@ " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", @@ -210,16 +187,14 @@ ], "text/plain": [ " ml_generate_text_llm_result \\\n", - "0 ## BigQuery: A serverless data warehouse for l... \n", - "1 ## BQML: BigQuery Machine Learning\n", + "0 BigQuery is a serverless, highly scalable, and... \n", + "1 BQML stands for **BigQuery Machine Learning**.... \n", + "2 BigQuery DataFrames is a Python client library... \n", "\n", - "BQML (BigQ... \n", - "2 I'll do my best to provide a comprehensive and... \n", - "\n", - " ml_generate_text_rai_result ml_generate_text_status \\\n", - "0 [{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob... \n", - "1 [{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob... \n", - "2 [{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob... \n", + " ml_generate_text_rai_result ml_generate_text_status \\\n", + "0 \n", + "1 \n", + "2 \n", "\n", " prompt \n", "0 What is BigQuery? \n", @@ -227,7 +202,7 @@ "2 What is BigQuery DataFrame? " ] }, - "execution_count": 4, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -282,7 +257,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.1" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/notebooks/geo/geoseries.ipynb b/notebooks/geo/geoseries.ipynb index ffd772e7b4..953fc8f45f 100644 --- a/notebooks/geo/geoseries.ipynb +++ b/notebooks/geo/geoseries.ipynb @@ -56,7 +56,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/google/home/arwas/src1/python-bigquery-dataframes/bigframes/session/_io/bigquery/read_gbq_table.py:280: DefaultIndexWarning: Table 'bigquery-public-data.geo_us_boundaries.counties' is clustered and/or partitioned, but BigQuery DataFrames was not able to find a suitable index. To avoid this warning, set at least one of: `index_col` or `filters`.\n", + "/usr/local/google/home/arwas/src1/python-bigquery-dataframes/bigframes/session/_io/bigquery/read_gbq_table.py:280: DefaultIndexWarning: Table 'bigquery-public-data.geo_us_boundaries.counties' is clustered\n", + "and/or partitioned, but BigQuery DataFrames was not able to find a\n", + "suitable index. To avoid this warning, set at least one of:\n", + "`index_col` or `filters`.\n", " warnings.warn(msg, category=bfe.DefaultIndexWarning)\n" ] } @@ -103,11 +106,11 @@ { "data": { "text/plain": [ - "78 POINT (-95.84727 44.4092)\n", - "130 POINT (-94.90431 41.67918)\n", - "544 POINT (-95.85272 40.38739)\n", - "995 POINT (-101.83333 47.30715)\n", - "1036 POINT (-88.36343 37.20952)\n", + "18 POINT (-83.91172 42.60253)\n", + "86 POINT (-90.13369 43.00102)\n", + "177 POINT (-117.23219 48.54382)\n", + "208 POINT (-84.50352 36.43523)\n", + "300 POINT (-91.85079 43.29299)\n", "Name: int_point_geom, dtype: geometry" ] }, @@ -136,11 +139,11 @@ { "data": { "text/plain": [ - "0 POINT (-95.84727 44.4092)\n", - "1 POINT (-94.90431 41.67918)\n", - "2 POINT (-95.85272 40.38739)\n", - "3 POINT (-101.83333 47.30715)\n", - "4 POINT (-88.36343 37.20952)\n", + "0 POINT (-83.91172 42.60253)\n", + "1 POINT (-90.13369 43.00102)\n", + "2 POINT (-117.23219 48.54382)\n", + "3 POINT (-84.50352 36.43523)\n", + "4 POINT (-91.85079 43.29299)\n", "dtype: geometry" ] }, @@ -185,11 +188,11 @@ { "data": { "text/plain": [ - "0 -95.847268\n", - "1 -94.904312\n", - "2 -95.852721\n", - "3 -101.833328\n", - "4 -88.363426\n", + "0 -83.911718\n", + "1 -90.133691\n", + "2 -117.232191\n", + "3 -84.50352\n", + "4 -91.850788\n", "dtype: Float64" ] }, @@ -217,11 +220,11 @@ { "data": { "text/plain": [ - "0 44.409195\n", - "1 41.679178\n", - "2 40.387389\n", - "3 47.307147\n", - "4 37.209517\n", + "0 42.602532\n", + "1 43.001021\n", + "2 48.543825\n", + "3 36.435234\n", + "4 43.292989\n", "dtype: Float64" ] }, @@ -367,11 +370,11 @@ { "data": { "text/plain": [ - "59 POLYGON ((-96.92479 43.43217, -96.92477 43.430...\n", - "132 POLYGON ((-91.95104 40.05078, -91.95105 40.050...\n", - "223 POLYGON ((-84.39719 40.78658, -84.39718 40.783...\n", - "328 POLYGON ((-91.80469 31.48623, -91.80469 31.486...\n", - "396 POLYGON ((-79.87705 40.03683, -79.87688 40.036...\n", + "304 POLYGON ((-88.69875 38.56219, -88.69876 38.562...\n", + "288 POLYGON ((-100.55792 46.24588, -100.5579 46.24...\n", + "42 POLYGON ((-98.09779 30.49744, -98.0978 30.4971...\n", + "775 POLYGON ((-90.33573 41.67043, -90.33592 41.669...\n", + "83 POLYGON ((-85.98402 35.6552, -85.98402 35.6551...\n", "Name: county_geom, dtype: geometry" ] }, @@ -400,11 +403,11 @@ { "data": { "text/plain": [ - "0 POLYGON ((-96.92479 43.43217, -96.92477 43.430...\n", - "1 POLYGON ((-91.95104 40.05078, -91.95105 40.050...\n", - "2 POLYGON ((-84.39719 40.78658, -84.39718 40.783...\n", - "3 POLYGON ((-91.80469 31.48623, -91.80469 31.486...\n", - "4 POLYGON ((-79.87705 40.03683, -79.87688 40.036...\n", + "0 POLYGON ((-88.69875 38.56219, -88.69876 38.562...\n", + "1 POLYGON ((-100.55792 46.24588, -100.5579 46.24...\n", + "2 POLYGON ((-98.09779 30.49744, -98.0978 30.4971...\n", + "3 POLYGON ((-90.33573 41.67043, -90.33592 41.669...\n", + "4 POLYGON ((-85.98402 35.6552, -85.98402 35.6551...\n", "dtype: geometry" ] }, @@ -442,14 +445,14 @@ "outputs": [ { "ename": "NotImplementedError", - "evalue": "GeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), instead. Share your usecase with the BigQuery DataFrames team at the https://bit.ly/bigframes-feedback survey.You are currently running BigFrames version 1.36.0", + "evalue": "GeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), instead. Share your usecase with the BigQuery DataFrames team at the https://bit.ly/bigframes-feedback survey. You are currently running BigFrames version 1.41.0.", "output_type": "error", "traceback": [ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", "\u001b[0;31mNotImplementedError\u001b[0m Traceback (most recent call last)", "Cell \u001b[0;32mIn[13], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mfive_geom\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43marea\u001b[49m\n", - "File \u001b[0;32m~/src1/python-bigquery-dataframes/bigframes/geopandas/geoseries.py:67\u001b[0m, in \u001b[0;36mGeoSeries.area\u001b[0;34m(self, crs)\u001b[0m\n\u001b[1;32m 48\u001b[0m \u001b[38;5;129m@property\u001b[39m\n\u001b[1;32m 49\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21marea\u001b[39m(\u001b[38;5;28mself\u001b[39m, crs\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m bigframes\u001b[38;5;241m.\u001b[39mseries\u001b[38;5;241m.\u001b[39mSeries: \u001b[38;5;66;03m# type: ignore\u001b[39;00m\n\u001b[1;32m 50\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"Returns a Series containing the area of each geometry in the GeoSeries\u001b[39;00m\n\u001b[1;32m 51\u001b[0m \u001b[38;5;124;03m expressed in the units of the CRS.\u001b[39;00m\n\u001b[1;32m 52\u001b[0m \n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 65\u001b[0m \u001b[38;5;124;03m GeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), insetead.\u001b[39;00m\n\u001b[1;32m 66\u001b[0m \u001b[38;5;124;03m \"\"\"\u001b[39;00m\n\u001b[0;32m---> 67\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mNotImplementedError\u001b[39;00m(\n\u001b[1;32m 68\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mGeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), instead. \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mconstants\u001b[38;5;241m.\u001b[39mFEEDBACK_LINK\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 69\u001b[0m )\n", - "\u001b[0;31mNotImplementedError\u001b[0m: GeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), instead. Share your usecase with the BigQuery DataFrames team at the https://bit.ly/bigframes-feedback survey.You are currently running BigFrames version 1.36.0" + "File \u001b[0;32m~/src1/python-bigquery-dataframes/bigframes/geopandas/geoseries.py:67\u001b[0m, in \u001b[0;36mGeoSeries.area\u001b[0;34m(self, crs)\u001b[0m\n\u001b[1;32m 48\u001b[0m \u001b[38;5;129m@property\u001b[39m\n\u001b[1;32m 49\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21marea\u001b[39m(\u001b[38;5;28mself\u001b[39m, crs\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m bigframes\u001b[38;5;241m.\u001b[39mseries\u001b[38;5;241m.\u001b[39mSeries: \u001b[38;5;66;03m# type: ignore\u001b[39;00m\n\u001b[1;32m 50\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"Returns a Series containing the area of each geometry in the GeoSeries\u001b[39;00m\n\u001b[1;32m 51\u001b[0m \u001b[38;5;124;03m expressed in the units of the CRS.\u001b[39;00m\n\u001b[1;32m 52\u001b[0m \n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 65\u001b[0m \u001b[38;5;124;03m GeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), instead.\u001b[39;00m\n\u001b[1;32m 66\u001b[0m \u001b[38;5;124;03m \"\"\"\u001b[39;00m\n\u001b[0;32m---> 67\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mNotImplementedError\u001b[39;00m(\n\u001b[1;32m 68\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mGeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), instead. \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mconstants\u001b[38;5;241m.\u001b[39mFEEDBACK_LINK\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 69\u001b[0m )\n", + "\u001b[0;31mNotImplementedError\u001b[0m: GeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), instead. Share your usecase with the BigQuery DataFrames team at the https://bit.ly/bigframes-feedback survey. You are currently running BigFrames version 1.41.0." ] } ], @@ -461,7 +464,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### 3. Use `bigframes.bigquery.st_area` to retirive the `area` in square meters instead. See: https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions#st_area" + "### 3. Use `bigframes.bigquery.st_area` to retrieve the `area` in square meters instead. See: https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions#st_area" ] }, { @@ -481,11 +484,11 @@ { "data": { "text/plain": [ - "0 1493638545.448335\n", - "1 1321524759.411463\n", - "2 1052436575.522383\n", - "3 1937116615.360128\n", - "4 2065462414.544471\n", + "0 1851741847.416806\n", + "1 4018075889.856168\n", + "2 2652483302.084653\n", + "3 1167209931.07698\n", + "4 1124055521.2818\n", "dtype: Float64" ] }, @@ -521,11 +524,11 @@ { "data": { "text/plain": [ - "0 POINT (-95.84727 44.4092)\n", - "1 POINT (-94.90431 41.67918)\n", - "2 POINT (-95.85272 40.38739)\n", - "3 POINT (-101.83333 47.30715)\n", - "4 POINT (-88.36343 37.20952)\n", + "0 POINT (-83.91172 42.60253)\n", + "1 POINT (-90.13369 43.00102)\n", + "2 POINT (-117.23219 48.54382)\n", + "3 POINT (-84.50352 36.43523)\n", + "4 POINT (-91.85079 43.29299)\n", "dtype: geometry" ] }, @@ -554,21 +557,21 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 17, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "0 POINT(-95.8472678 44.4091953)\n", - "1 POINT(-94.9043119 41.679178)\n", - "2 POINT(-95.8527214 40.3873891)\n", - "3 POINT(-101.8333279 47.3071473)\n", - "4 POINT(-88.3634261 37.2095174)\n", + "0 POINT(-83.9117183 42.6025316)\n", + "1 POINT(-90.1336915 43.0010208)\n", + "2 POINT(-117.2321913 48.5438247)\n", + "3 POINT(-84.50352 36.435234)\n", + "4 POINT(-91.850788 43.2929889)\n", "dtype: string" ] }, - "execution_count": 18, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } @@ -594,21 +597,21 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 18, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "0 POINT (-95.84727 44.4092)\n", - "1 POINT (-94.90431 41.67918)\n", - "2 POINT (-95.85272 40.38739)\n", - "3 POINT (-101.83333 47.30715)\n", - "4 POINT (-88.36343 37.20952)\n", + "0 POINT (-83.91172 42.60253)\n", + "1 POINT (-90.13369 43.00102)\n", + "2 POINT (-117.23219 48.54382)\n", + "3 POINT (-84.50352 36.43523)\n", + "4 POINT (-91.85079 43.29299)\n", "dtype: geometry" ] }, - "execution_count": 19, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -617,6 +620,411 @@ "wkts_from_geo = bigframes.geopandas.GeoSeries.from_wkt(geo_to_wkts)\n", "wkts_from_geo" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Discover the set-theoretic boundary of geometry objects with `GeoSeries.boundary`" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 POLYGON ((0 0, 1 1, 0 1, 0 0))\n", + "1 POLYGON ((10 0, 10 5, 0 0, 10 0))\n", + "2 POLYGON ((0 0, 2 2, 2 0, 0 0))\n", + "3 LINESTRING (0 0, 1 1, 0 1)\n", + "4 POINT (0 1)\n", + "dtype: geometry" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from shapely.geometry import Polygon, LineString, Point\n", + "geom_obj = bigframes.geopandas.GeoSeries(\n", + " [\n", + " Polygon([(0, 0), (1, 1), (0, 1)]),\n", + " Polygon([(10, 0), (10, 5), (0, 0)]),\n", + " Polygon([(0, 0), (2, 2), (2, 0)]),\n", + " LineString([(0, 0), (1, 1), (0, 1)]),\n", + " Point(0, 1),\n", + " ]\n", + ")\n", + "geom_obj" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 LINESTRING (0 0, 1 1, 0 1, 0 0)\n", + "1 LINESTRING (10 0, 10 5, 0 0, 10 0)\n", + "2 LINESTRING (0 0, 2 2, 2 0, 0 0)\n", + "3 MULTIPOINT (0 0, 0 1)\n", + "4 GEOMETRYCOLLECTION EMPTY\n", + "dtype: geometry" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "geom_obj.geo.boundary" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Find the `difference` between two `GeoSeries` " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Reuse `five_geom` and `geom_obj` to find the difference between the geometry objects" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": { + "tags": [ + "raises-exception" + ] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "0 POLYGON ((-88.69875 38.56219, -88.69876 38.562...\n", + "1 POLYGON ((-100.55792 46.24588, -100.5579 46.24...\n", + "2 GEOMETRYCOLLECTION EMPTY\n", + "3 POLYGON ((-90.33573 41.67043, -90.33592 41.669...\n", + "4 POLYGON ((-85.98402 35.6552, -85.98402 35.6551...\n", + "dtype: geometry" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "five_geom.difference(geom_obj)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Find the difference between a `GeoSeries` and a single geometry shape." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 POLYGON ((-88.69875 38.56219, -88.69876 38.562...\n", + "1 None\n", + "2 None\n", + "3 None\n", + "4 None\n", + "dtype: geometry" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "five_geom.difference([Polygon([(0, 0), (10, 0), (10, 10), (0, 0)])])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Find the difference in `GeoSeries` with the same shapes" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 GEOMETRYCOLLECTION EMPTY\n", + "1 GEOMETRYCOLLECTION EMPTY\n", + "2 GEOMETRYCOLLECTION EMPTY\n", + "3 GEOMETRYCOLLECTION EMPTY\n", + "4 GEOMETRYCOLLECTION EMPTY\n", + "dtype: geometry" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "five_geom.difference(five_geom)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## You can also use`BigQuery.st_difference()` to find the difference between two `GeoSeries`. See, https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions#st_difference" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 POLYGON ((-88.69875 38.56219, -88.69876 38.562...\n", + "1 POLYGON ((-100.55792 46.24588, -100.5579 46.24...\n", + "2 GEOMETRYCOLLECTION EMPTY\n", + "3 POLYGON ((-90.33573 41.67043, -90.33592 41.669...\n", + "4 POLYGON ((-85.98402 35.6552, -85.98402 35.6551...\n", + "dtype: geometry" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.st_difference(five_geom, geom_obj)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Find the difference between a `GeoSeries` and a single geometry shape." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 POLYGON ((-88.69875 38.56219, -88.69876 38.562...\n", + "1 None\n", + "2 None\n", + "3 None\n", + "4 None\n", + "dtype: geometry" + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.st_difference(five_geom, [Polygon([(0, 0), (10, 0), (10, 10), (0, 0)])])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Find the difference in GeoSeries with the same shapes" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 GEOMETRYCOLLECTION EMPTY\n", + "1 GEOMETRYCOLLECTION EMPTY\n", + "2 GEOMETRYCOLLECTION EMPTY\n", + "3 GEOMETRYCOLLECTION EMPTY\n", + "4 GEOMETRYCOLLECTION EMPTY\n", + "dtype: geometry" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.st_difference(geom_obj, geom_obj)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Use `GeoSeries.intersection()` to find the intersecting points in two geometry shapes " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Reuse `wkts_from_geo` and `geom_obj`" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 GEOMETRYCOLLECTION EMPTY\n", + "1 GEOMETRYCOLLECTION EMPTY\n", + "2 POLYGON ((-98.09779 30.49744, -98.0978 30.4971...\n", + "3 GEOMETRYCOLLECTION EMPTY\n", + "4 GEOMETRYCOLLECTION EMPTY\n", + "dtype: geometry" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "five_geom.intersection(geom_obj)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Find the difference between a `GeoSeries` and a single geometry shape." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 GEOMETRYCOLLECTION EMPTY\n", + "1 None\n", + "2 None\n", + "3 None\n", + "4 None\n", + "dtype: geometry" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "five_geom.intersection([Polygon([(0, 0), (10, 0), (10, 10), (0, 0)])])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## You can also use`BigQuery.st_intersection()` to find the intersecting points between two `GeoSeries`. See, https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions#st_intersection" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 GEOMETRYCOLLECTION EMPTY\n", + "1 GEOMETRYCOLLECTION EMPTY\n", + "2 POLYGON ((-98.09779 30.49744, -98.0978 30.4971...\n", + "3 GEOMETRYCOLLECTION EMPTY\n", + "4 GEOMETRYCOLLECTION EMPTY\n", + "dtype: geometry" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.st_intersection(five_geom, geom_obj)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Find the difference between a `GeoSeries` and a single geometry shape." + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 GEOMETRYCOLLECTION EMPTY\n", + "1 None\n", + "2 None\n", + "3 None\n", + "4 None\n", + "dtype: geometry" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.st_intersection(five_geom, [Polygon([(0, 0), (1, 0), (10, 10), (0, 0)])])" + ] } ], "metadata": { diff --git a/notebooks/getting_started/bq_dataframes_template.ipynb b/notebooks/getting_started/bq_dataframes_template.ipynb index 6b0682bb1a..0970dcedc9 100644 --- a/notebooks/getting_started/bq_dataframes_template.ipynb +++ b/notebooks/getting_started/bq_dataframes_template.ipynb @@ -35,12 +35,12 @@ "\n", " \n", " \n", @@ -81,7 +81,8 @@ "\n", "BigQuery DataFrames (also known as BigFrames) provides a Pythonic DataFrame and machine learning (ML) API powered by the BigQuery engine.\n", "\n", - "* `bigframes.pandas` provides a pandas-like API for analytics.\n", + "* `bigframes.pandas` provides a pandas API for analytics. Many workloads can be\n", + " migrated from pandas to bigframes by just changing a few imports.\n", "* `bigframes.ml` provides a scikit-learn-like API for ML.\n", "* `bigframes.ml.llm` provides API for large language models including Gemini.\n", "\n", @@ -114,7 +115,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -137,13 +138,13 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 3, "metadata": { "id": "oM1iC_MfAts1" }, "outputs": [], "source": [ - "PROJECT_ID = \"\" # @param {type: \"string\"}\n", + "PROJECT_ID = \"bigframes-dev\" # @param {type: \"string\"}\n", "LOCATION = \"US\" # @param {type: \"string\"}" ] }, @@ -158,7 +159,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 4, "metadata": { "id": "PyQmSRbKA8r-" }, @@ -179,7 +180,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 5, "metadata": { "id": "NPPMuw2PXGeo" }, @@ -199,7 +200,7 @@ "# Note: BigQuery DataFrames objects are by default fully ordered like Pandas.\n", "# If ordering is not important for you, you can uncomment the following\n", "# expression to run BigQuery DataFrames in partial ordering mode.\n", - "#bpd.options.bigquery.ordering_mode = \"partial\"\n", + "bpd.options.bigquery.ordering_mode = \"partial\"\n", "\n", "# Note: By default BigQuery DataFrames emits out BigQuery job metadata via a\n", "# progress bar. But in this notebook let's disable the progress bar to keep the\n", @@ -249,7 +250,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 6, "metadata": { "id": "Vyex9BQI-BNa" }, @@ -271,7 +272,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 7, "metadata": {}, "outputs": [ { @@ -306,53 +307,53 @@ " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", + " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", @@ -360,22 +361,22 @@ "" ], "text/plain": [ - " species island culmen_length_mm \\\n", - "198 Gentoo penguin (Pygoscelis papua) Biscoe 43.3 \n", - "235 Adelie Penguin (Pygoscelis adeliae) Torgersen 35.1 \n", - "317 Chinstrap penguin (Pygoscelis antarctica) Dream 45.4 \n", - "117 Chinstrap penguin (Pygoscelis antarctica) Dream 48.5 \n", - "159 Chinstrap penguin (Pygoscelis antarctica) Dream 45.6 \n", + " species island culmen_length_mm \\\n", + "0 Adelie Penguin (Pygoscelis adeliae) Dream 36.6 \n", + "1 Adelie Penguin (Pygoscelis adeliae) Dream 39.8 \n", + "2 Adelie Penguin (Pygoscelis adeliae) Dream 40.9 \n", + "3 Chinstrap penguin (Pygoscelis antarctica) Dream 46.5 \n", + "4 Adelie Penguin (Pygoscelis adeliae) Dream 37.3 \n", "\n", - " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "198 13.4 209.0 4400.0 FEMALE \n", - "235 19.4 193.0 4200.0 MALE \n", - "317 18.7 188.0 3525.0 FEMALE \n", - "117 17.5 191.0 3400.0 MALE \n", - "159 19.4 194.0 3525.0 FEMALE " + " culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "0 18.4 184.0 3475.0 FEMALE \n", + "1 19.1 184.0 4650.0 MALE \n", + "2 18.9 184.0 3900.0 MALE \n", + "3 17.9 192.0 3500.0 FEMALE \n", + "4 16.8 192.0 3000.0 FEMALE " ] }, - "execution_count": 5, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -424,7 +425,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 8, "metadata": { "id": "YKwCW7Nsavap" }, @@ -433,7 +434,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "average_body_mass: 4201.754385964906\n" + "average_body_mass: 4201.754385964914\n" ] } ], @@ -453,7 +454,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 9, "metadata": { "id": "4PyKMR61-Mjy" }, @@ -514,7 +515,7 @@ "[3 rows x 1 columns]" ] }, - "execution_count": 7, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -543,7 +544,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 10, "metadata": {}, "outputs": [], "source": [ @@ -559,21 +560,21 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 11, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "133 {'culmen_length_mm': None, 'culmen_depth_mm': ...\n", - "279 {'culmen_length_mm': 37.9, 'culmen_depth_mm': ...\n", - "34 {'culmen_length_mm': 37.8, 'culmen_depth_mm': ...\n", - "208 {'culmen_length_mm': 40.5, 'culmen_depth_mm': ...\n", - "96 {'culmen_length_mm': 37.7, 'culmen_depth_mm': ...\n", + "0 {'culmen_length_mm': 36.6, 'culmen_depth_mm': ...\n", + "1 {'culmen_length_mm': 39.8, 'culmen_depth_mm': ...\n", + "2 {'culmen_length_mm': 40.9, 'culmen_depth_mm': ...\n", + "3 {'culmen_length_mm': 46.5, 'culmen_depth_mm': ...\n", + "4 {'culmen_length_mm': 37.3, 'culmen_depth_mm': ...\n", "dtype: struct[pyarrow]" ] }, - "execution_count": 9, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -594,21 +595,21 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 12, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "133 \n", - "279 18.6\n", - "34 18.3\n", - "96 18.7\n", - "208 18.9\n", + "0 18.4\n", + "1 19.1\n", + "2 18.9\n", + "3 17.9\n", + "4 16.8\n", "dtype: Float64" ] }, - "execution_count": 10, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -639,7 +640,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 13, "metadata": {}, "outputs": [ { @@ -648,13 +649,13 @@ "" ] }, - "execution_count": 11, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjAAAAGzCAYAAAAxPS2EAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/H5lhTAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOx9d7wVxfn+s2dPuf1eelGQXhWlWIgNkYj5qrFjxwKWCBpsEIyF2MUWorGhghJLjL9ITFSwYgEVJBZsIB1pV9qFW885u/v74+zMzszO7O45t3HNPp8PH+45uzs7u2d35p3nfd731SzLshAiRIgQIUKECNGCEGnuDoQIESJEiBAhQmSL0IAJESJEiBAhQrQ4hAZMiBAhQoQIEaLFITRgQoQIESJEiBAtDqEBEyJEiBAhQoRocQgNmBAhQoQIESJEi0NowIQIESJEiBAhWhxCAyZEiBAhQoQI0eIQGjAhQoQIESJEiBaH0IAJESJEs0LTNEybNq1B2qqsrMT48ePRsWNHaJqGSZMmNUi7IUKE2PsQGjAhQuzFmD17NjRNQ15eHjZu3OjaPmLECOy///7N0LO9E3fddRdmz56N3/3ud5gzZw4uuOCCRjnPo48+itmzZzdK2yFChAiGaHN3IESIEP6oq6vDPffcg4cffri5u9LgqKmpQTTaMEPRe++9h8MOOwy33nprg7SnwqOPPoq2bdvioosuatTzhAgRQo2QgQkRogXgoIMOwsyZM7Fp06bm7kqDwDRN1NbWAgDy8vIazIApLy9HWVlZg7TV1LAsCzU1Nc3djRAhWgxCAyZEiBaAG2+8EYZh4J577vHcb+3atdA0TereELUm06ZNg6ZpWLFiBc4//3yUlpaiXbt2uPnmm2FZFjZs2ICTTz4ZJSUl6NixIx544AFXm3V1dbj11lvRq1cvJBIJdOnSBZMnT0ZdXZ3r3BMnTsTzzz+PgQMHIpFIYN68edJ+AcDGjRsxbtw4dO7cGYlEAt27d8fvfvc7JJNJ6XUvWLAAmqZhzZo1eP3116FpGjRNw9q1a7Pq56xZszBy5Ei0b98eiUQCAwYMwGOPPcbt061bN3z77bf44IMP6HlGjBjB3VMRxBVI+kPaOfHEEzF//nwMGzYM+fn5eOKJJwAAu3btwqRJk9ClSxckEgn06tUL9957L0zT5Np96aWXMHToUBQXF6OkpAQHHHAAZsyYIb1HIUL80hC6kEKEaAHo3r07xo4di5kzZ+IPf/gDOnfu3GBtn3XWWejfvz/uuecevP7667jjjjvQunVrPPHEExg5ciTuvfdePP/887j++utx8MEH46ijjgKQYVF++9vf4uOPP8Zll12G/v37Y9myZXjooYewYsUKzJ07lzvPe++9h5dffhkTJ05E27Zt0a1bN2l/Nm3ahEMOOQS7du3CZZddhn79+mHjxo145ZVXUF1djXg87jqmf//+mDNnDq655hrsu+++uO666wAA7dq1y6qfjz32GAYOHIjf/va3iEaj+Pe//40rr7wSpmliwoQJAIA///nPuOqqq1BUVIQ//vGPAIAOHTrkdO+XL1+Oc845B5dffjkuvfRS9O3bF9XV1Tj66KOxceNGXH755ejatSsWLVqEqVOnYvPmzfjzn/8MAHj77bdxzjnn4Nhjj8W9994LAPj++++xcOFC/P73v8+pPyFCtChYIUKE2Gsxa9YsC4C1ZMkSa9WqVVY0GrWuvvpquv3oo4+2Bg4cSD+vWbPGAmDNmjXL1RYA69Zbb6Wfb731VguAddlll9Hv0um0te+++1qapln33HMP/X7nzp1Wfn6+deGFF9Lv5syZY0UiEeujjz7izvP4449bAKyFCxdy545EIta3337r26+xY8dakUjEWrJkiWtf0zRd37HYb7/9rBNOOIH7Lpt+VldXu9ocPXq01aNHD+67gQMHWkcffbRrX3JPRZDfcc2aNVxfAVjz5s3j9r399tutwsJCa8WKFdz3f/jDHyxd163169dblmVZv//9762SkhIrnU67zhcixP8CQhdSiBAtBD169MAFF1yAJ598Eps3b26wdsePH0//1nUdw4YNg2VZGDduHP2+rKwMffv2xerVq+l3//jHP9C/f3/069cP27Zto/9GjhwJAHj//fe58xx99NEYMGCAZ19M08TcuXNx0kknYdiwYa7tMveMH7LpZ35+Pv27oqIC27Ztw9FHH43Vq1ejoqIi63P7oXv37hg9erSrv0ceeSRatWrF9XfUqFEwDAMffvghgMxvUlVVhbfffrvB+xUiREtA6EIKEaIF4aabbsKcOXNwzz33NJjWoWvXrtzn0tJS5OXloW3btq7vt2/fTj//+OOP+P7779GuXTtpu+Xl5dzn7t27+/bl559/xu7duxs0NDybfi5cuBC3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n332QWFhIQ499FAsWLCAbieusfnz56N///4oKirC8ccfj82bN9N90uk0rr76apSVlaFNmzaYMmUKLrzwQpxyyikAgIsuuggffPABZsyYAU3TXPdt6dKlGDZsGAoKCvCrX/0Ky5cvD9T3XO+xpml44okncOKJJ6KgoAD9+/fHJ598gpUrV2LEiBEoLCzEr371K6xatSqne6rC9lmzUH7/A6iYO7dB2w0RIkSIhsb/ZB4YETUpAwNumd8s5/7uttEoiAf/GaZOnYqZM2fioYcewhFHHIHNmzfjhx9+yPn8q1atwptvvol58+Zh1apVOOOMM7B69Wr06dMHH3zwARYtWoRLLrkEo0aNwqGHHgoAOPPMM5Gfn48333wTpaWleOKJJ3DsscdixYoVaN26NW137ty5+M9//oOdO3dizJgxuOeee1wTdRBMnDgR3333HV566SV07twZr776Ko4//ngsW7YMvXv3BgBUV1fj/vvvx5w5cxCJRHD++efj+uuvx/PPPw8AuPfee/H8889j1qxZ6N+/P2bMmIG5c+fimGOOAQDMmDEDK1aswP7774/bbrsNANCuXTtqxPzxj3/EAw88gHbt2uGKK67AJZdcgoULFzbaPQaA22+/HQ8++CAefPBBTJkyBeeeey569OiBqVOnomvXrrjkkkswceJEvPnmm1nfUxXMyioAgFFZ2WBthggRIkRjIDRgWhD27NmDGTNm4JFHHsGFF14IAOjZsyeOOOIIjpHIBqZp4plnnkFxcTEGDBiAY445BsuXL8cbb7yBSCSCvn374t5778X777+PQw89FB9//DEWL16M8vJyJBIJAMD999+PuXPn4pVXXsFll11G2509ezaKi4sBABdccAHefffdrA2Y9evXY9asWVi/fj06d+4MALj++usxb948zJo1C3fddRcAIJVK4fHHH0fPnj0BZIweYogAwMMPP4ypU6fi1FNPBQA88sgjeOONN+j20tJSxONxFBQUoGPHjq5+3HnnnTj66KMBAH/4wx9wwgknoLa2NlCCuGzvMcHFF1+MMWPGAACmTJmC4cOH4+abb8bo0aMBAL///e9x8cUXB7+ZQUDqgpnZMYMhQoQI0dQIDRgA+TEd3902utnOHRTff/896urqcOyxxzbY+bt160aNDADo0KEDdF3nkqB16NAB5eXlAICvvvoKlZWVaNOmDddO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", + "image/png": "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yMhI7r33Xi5cuEDjxo3566+/2LlzJ48//jj9+/enc+fOANx33324uLiwdu1avLy8+Pzzz+nXrx9nz56lVq1a6npXrlzJ77//Tnp6OiNHjmT27NmlTsYVMWnSJE6ePMkPP/xA7dq1WbFiBYMGDeLYsWM0atQIgLy8PN5//32WLl2KVqvlkUce4fnnn+e7774D4J133uG7775j8eLFREREMHfuXFauXMltt90GwNy5czl79iwtWrTgjTfeAMDf318NVF5++WU++OAD/P39efLJJ3n88cfZsWPHDTnG1sD17QULeOell5jz2We8NH06Dz30EA0aNGDatGmEhoby+OOPM2nSJNauXVvpY3o9JEgRQghRruzsbObOncv8+fN57LHHAGjYsCE9evSwyyxUhtlsZtGiRXh4eNCsWTNuu+02zpw5w5o1a9BqtTRp0oR33nmHzZs307lzZ7Zv387evXtJTk7GyckJgPfff5+VK1eyfPlyJkyYoK53yZIleHh4APDoo4+yadOmSgcpsbGxLF68mNjYWGrXrg3A888/z7p161i8eDFvvfUWYJmg77PPPqNhw4aAJbCxBhsA8+bNY9q0aQwfPhyA+fPns2bNGvV5Ly8vHB0dcXV1JSgoqFQ7Zs2aRe/evQF46aWXGDJkCAUFBRWaJO1qx1gDNAwI4J233mLjihW09vICs9lyzIYN44ExY9B7ezN16lS6du3Kq6++ysCBAwF47rnnGDNmTKWO599xywQpCxYsYMGCBZhMpupuihBC/G0uDjpOvjGw2rZdUadOncJgMNCvX78q235YWJgaSAAEBgai0+nsJgILDAwkOTkZgCNHjpCTk4Ovr6/devLz8+26HkquNzg4WF1HZRw7dgyTyUTjxo3tHjcYDHZtcHV1VQOUktvLzMwkKSmJTp06qc/rdDrat2+PuTgguJZWrVrZrRsgOTmZ0NDQa742LCwMd3d3FKMRxWjE38sLbcOGmJKTMefkYC4owN/Li6RLl1CMRvV1rTt0QOflBVjeA4CWLVuqzwcGBlJQUEBWVhaenp4V2o+/45YJUiZOnMjEiRPJysrCq/gACiHErUqj0VSqy6W6XG0Kf2tQYVvjUpE7PDs4ONj9rtFoynzMejLPyckhODi4zMyN7fDmq62jMnJyctDpdBw4cACdzj6gc3d3v+r2qvLmkbbrt85UbLs/itls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", "text/plain": [ "
" ] @@ -664,12 +665,17 @@ } ], "source": [ - "df.plot(title=\"Numeric features\")" + "(\n", + " df\n", + " .sort_values(\"body_mass_g\")\n", + " .reset_index(drop=True)\n", + " .plot(title=\"Numeric features\", logy=True)\n", + ")" ] }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 14, "metadata": {}, "outputs": [ { @@ -678,13 +684,13 @@ "" ] }, - "execution_count": 12, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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bN68udwdkp48eTLS0tIUX0lJSe/dtmiMw7t3RCaqaIo+3xzTQ0QV0ScdgYmOjsaTJ0+U7h+Tn5+P06dPY+nSpThy5AhycnKQmpqqdBTm8ePHsLCwAABYWFggMjJSab9FVym9u83fr1x6/Pgx5HL5eydV09bWhra29ke9D3V1dRgZGSnux6Onp8fJy6jCKCwsRGZmJp48eQIjIyOoq6sLHYmIqMR9UoFp27atyhTtQ4YMQc2aNREYGAhra2toamoiLCxMcYfhuLg4JCYmKm4a6Onpiblz5+LJkyeK8RShoaGQy+WKKe89PT1x8OBBpdcJDQ1VufHgf1FUlt69qSBRRWJkZKT4nBMRVTSfVGAMDAxQu3ZtpWWVKlWCiYmJYrmfnx/Gjx+PypUrQy6XY9SoUfD09ESTJk0AAO3bt4erqysGDhyIoKAgpKSkYMqUKRg5cqTiCMrw4cOxdOlSTJo0Cb6+vjh+/Di2b9+OAwcOlMR7BvD2hoKWlpYwMzPjjf+owtHU1OSRFyKq0Er8VgKLFi2CmpoaevXqhezsbHh7e2P58uWK9erq6ti/fz9GjBgBT09PVKpUCYMGDcKsWbMU29jb2+PAgQMYN24cFi9ejGrVqmHNmjXw9vYu6bhQV1fnD3oiIiKRkeStBIiIiMSA88CU0q0EiIiIiITAAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiU+I3cyQiEquSvu9Meb/nDJGY8QgMERERiQ6PwJAgpHiHVSIiKjk8AkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDRERE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", + "image/png": "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GGzevLncHZKePHky0tLSFF9JSUnv3bZojMO7d0QmqmiKPt8c00NEFdEnHYGJjo7GkydPlO4fk5+fj9OnT2Pp0qU4cuQIcnJykJqaqnQU5vHjx7CwsAAAWFhYIDIyUmm/RVcpvbvN369cevz4MeRy+XsnVdPW1oa2tvZHvQ91dXUYGRkp7sejp6fHycuowigsLERmZiaePHkCIyMjqKurCx2JiKjEfVKBadu2rcoU7UOGDEHNmjURGBgIa2traGpqIiwsTHGH4bi4OCQmJipuGujp6Ym5c+fiyZMnivEUoaGhkMvliinvPT09cfDgQaXXCQ0NVbnx4H9RVJbevakgUUViZGSk+JwTEVU0n1RgDAwMULt2baVllSpVgomJiWK5n58fxo8fj8qVK0Mul2PUqFHw9PREkyZNAADt27eHq6srBg4ciKCgIKSkpGDKlCkYOXKk4gjK8OHDsXTpUkyaNAm+vr44fvw4tm/fjgMHDpTEewbw9oaClpaWMDMz443/qMLR1NTkkRciqtBK/FYCixYtgpqaGnr16oXs7Gx4e3tj+fLlivXq6urYv38/RowYAU9PT1SqVAmDBg3CrFmzFNvY29vjwIEDGDduHBYvXoxq1aphzZo18Pb2Lum4UFdX5w96IiIikZHkrQSIiIjEgPPAlNKtBIiIiIiEwAJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREolPiN3MkIhKrkr7vTHm/5wyRmPEIDBEREYkOj8CQIKR4h1UiIio5PAJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RE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", "text/plain": [ "
" ] @@ -709,12 +715,12 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 15, "metadata": {}, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAyUAAAGFCAYAAADjF1xYAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/H5lhTAAAACXBIWXMAAA9hAAAPYQGoP6dpAABqp0lEQVR4nO3dd1hTZ/8G8DsJe++hoogg4gBxj9aFCq5WX1utWqvVumfd/mytVm21jlbraGutWut+bau11l19FScquBARwQkiU3Ygye8PampkCDKehNyf6+LS5Jw85z4hQL55xpGoVCoViIiIiIiIBJGKDkBERERERPqNRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERERERCsSghIiIiIiKhWJQQEREREZFQLEqIiIiIiEgoFiVERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJERELNmzcPjRs3LvH+MTExkEgkCA0NBQCcOHECEokEKSkpFZJP23To0AGTJ08uczuJiYlwcnJCTExMmdvSJRKJBL///juAgq+lyvA6x3z5e+7u7o5vvvmmXHO1atUKe/bsKdc2iUqDRQkREZWrs2fPQiaToUePHpVyvDZt2iA2NhbW1tav3camTZsgkUggkUgglUpRo0YNfPjhh4iPjy/HpOXj119/xYIFC8rczqJFi/D222/D3d0dwL9vlp9/2dvbo2vXrrhy5UqZj6Wt3NzcEBsbi4YNG4qOUioXL17EyJEjy7XNTz75BLNmzYJSqSzXdolKikUJERGVqw0bNmDChAn43//+h8ePH1f48YyMjODi4gKJRFKmdqysrBAbG4uHDx9i/fr1+OuvvzB48OBySll+7OzsYGlpWaY2MjMzsWHDBgwfPrzAtqNHjyI2NhaHDh1Ceno6unXrVmV7oWQyGVxcXGBgYCA6Sqk4OjrCzMysXNvs1q0b0tLS8Ndff5Vru0QlxaKEiIjKTXp6Onbu3IkxY8agR48e2LRpU4F9Fi9eDGdnZ1haWmL48OHIzs4usM+PP/4IHx8fmJiYoF69eli7dm2Rxyxs+Nbp06fx5ptvwtTUFG5ubpg4cSIyMjKKzS6RSODi4oJq1aqhW7dumDhxIo4ePYqsrKxXZnrey/Drr7+iY8eOMDMzg5+fH86ePatxjPXr18PNzQ1mZmbo06cPVqxYARsbG/X2oUOHonfv3hqPmTx5Mjp06KC+XdhQni+++ALDhg2DpaUlatasiR9++KHYcz1w4ACMjY3RqlWrAtvs7e3h4uKCZs2aYdmyZXjy5AnOnz+Pzz//vNAehcaNG+PTTz8FAOTl5WHixImwsbGBvb09Zs6ciSFDhmicU05ODiZOnAgnJyeYmJjgjTfewMWLF9Xbk5OTMWjQIDg6OsLU1BReXl7YuHGjevvDhw8xYMAA2NnZwdzcHM2aNcP58+fV2/fu3YsmTZrAxMQEHh4emD9/PvLy8gp9Hl4eSvWqY7/s4MGDeOONN9Tn27NnT0RFRWnsc+HCBfj7+8PExATNmjUrtOfp+vXr6NatGywsLODs7IzBgwcjISGhyOO+PHxrxYoVaNSoEczNzeHm5oaxY8ciPT1d4zGv+pmQyWTo3r07duzYUeRxiSoSixIiIio3u3btQr169eDt7Y33338fP/30E1Qqlcb2efPm4YsvvkBISAhcXV0LFBxbt27F3LlzsWjRIoSHh+OLL77Ap59+is2bN5coQ1RUFIKCgtC3b19cvXoVO3fuxOnTpzF+/PhSnYupqSmUSiXy8vJKnGnOnDmYNm0aQkNDUbduXQwYMED9hjg4OBijR4/GpEmTEBoaii5dumDRokWlylSU5cuXq9/wjh07FmPGjEFERESR+586dQpNmzZ9ZbumpqYAALlcjmHDhiE8PFyjgLhy5QquXr2KDz/8EACwZMkSbN26FRs3bkRwcDCePXumnr/x3IwZM7Bnzx5s3rwZly9fhqenJwIDA5GUlAQA+PTTT3Hz5k389ddfCA8Px7p16+Dg4AAgv+ht3749Hj16hH379iEsLAwzZsxQDzk6deoUPvjgA0yaNAk3b97E999/j02bNpX4eS7u2IXJyMjAlClTEBISgmPHjkEqlaJPnz7qPOnp6ejZsyfq16+PS5cuYd68eZg2bZpGGykpKejUqRP8/f0REhKCgwcP4smTJ+jXr1+JMgOAVCrFqlWrcOPGDWzevBnHjx/HjBkz1NtL+jPRokULnDp1qsTHJSpXKiIionLSpk0b1TfffKNSqVSq3NxclYODg+rvv/9Wb2/durVq7NixGo9p2bKlys/PT327Tp06qm3btmnss2DBAlXr1q1VKpVKFR0drQKgunLlikqlUqn+/vtvFQBVcnKySqVSqYYPH64aOXKkxuNPnTqlkkqlqqysrEJzb9y4UWVtba2+ffv2bVXdunVVzZo1K1WmH3/8Ub39xo0bKgCq8PBwlUqlUvXv31/Vo0cPjTYGDRqkcdwhQ4ao3n77bY19Jk2apGrfvr36dvv27VWTJk1S365Vq5bq/fffV99WKpUqJycn1bp16wo9V5VKpXr77bdVw4YN07jv5ec1OTlZ1adPH5WFhYUqLi5OpVKpVN26dVONGTNG/ZgJEyaoOnTooL7t7OysWrp0qfp2Xl6eqmbNmupzSk9PVxkaGqq2bt2q3kcul6uqVaum+uqrr1QqlUrVq1cv1Ycfflho7u+//15laWmpSkxMLHR7QECA6osvvtC4b8uWLSpXV1f1bQCq3377rdBzLu7YJfH06VMVANW1a9fUee3t7TVed+vWrdM45oIFC1Rdu3bVaOfBgwcqAKqIiAiVSlX49/zrr78uMsfu3btV9vb26tsl/ZnYu3evSiqVqhQKRanOm6g8sKeEiIjKRUREBC5cuIABAwYAAAwMDNC/f39s2LBBvU94eDhatmyp8bjWrVur/5+RkYGoqCgMHz4cFhYW6q+FCxcWGBZTlLCwMGzatEnj8YGBgVAqlYiOji7ycampqbCwsICZmRm8vb3h7OyMrVu3liqTr6+v+v+urq4AoJ4sHxERgRYtWmjs//Lt1/XicZ8PQytukn5WVhZMTEwK3damTRtYWFjA1tYWYWFh2LlzJ5ydnQEAI0aMwPbt25GdnQ25XI5t27Zh2LBhAPKfvydPnmick0wm0+iRiYqKQm5uLtq2bau+z9DQEC1atEB4eDgAYMyYMdixYwcaN26MGTNm4MyZM+p9Q0ND4e/vDzs7u0Kzh4WF4fPPP9f4Po0YMQKxsbHIzMws8vl4rrhjFyYyMhIDBgyAh4cHrKys1IsG3L9/H0D+693X11fjuX7x9f48899//62RuV69eurnqySOHj2KgIAAVK9eHZaWlhg8eDASExPV51zSn4nnvYM5OTklOi5RedKtmV1ERKS1NmzYgLy8PFSrVk19n0qlgrGxMVavXl2i1bGej4Nfv359geJFJpOVKEd6ejpGjRqFiRMnFthWs2bNIh9naWmJy5cvQyqVwtXVVT106cmTJyXOZGhoqP7/84n3pVnNSCqVagx3A4Dc3NxXPu7F4z4/dnHHdXBwQHJycqHbdu7cifr168Pe3l5jvgsA9OrVC8bGxvjtt99gZGSE3NxcvPPOO6/MVxrdunXDvXv3cODAARw5cgQBAQEYN24cli1bpv6eFCU9PR3z58/Hf/7znwLbiirCSnrswvTq1Qu1atXC+vXrUa1aNSiVSjRs2BByubxkJ/tP5l69emHJkiUFtj0vbIsTExODnj17YsyYMVi0aBHs7Oxw+vRpDB8+HHK5HGZmZiX+mUhKSoK5ufkrn2eiisCihIiIyiwvLw8///wzli9fjq5du2ps6927N7Zv347Ro0fDx8cH58+fxwcffKDefu7cOfX/nZ2dUa1aNdy9exeDBg16rSxNmjTBzZs34enpWarHSaXSQh9THpkAwNvbW2M+BoACtx0dHXH9+nWN+0JDQwsUHWXl7++PX375pdBtbm5uqFOnTqHbDAwMMGTIEGzcuBFGRkZ477331G9gra2t4ezsjIsXL6Jdu3YAAIVCgcuXL6uvQ1OnTh0YGRkhODgYtWrVApBfdF28eFFj8r6joyOGDBmCIUOG4M0338T06dOxbNky+Pr64scff0RSUlKhvSVNmjRBREREqb/3Lyrq2C9LTExEREQE1q9fjzfffBNA/mTyF/n4+GDLli3Izs5WF0Uvvt6fZ96zZw/c3d1faxWwS5cuQalUYvny5ZBK8wfA7Nq1q8AxSvIzcf36dfj7+5c6A1F5YFFCRERltn//fiQnJ2P48OEFekT69u2LDRs2qCd5Dx06FM2aNUPbtm2xdetW3LhxAx4eHur958+fj4kTJ8La2hpBQUHIyclBSEgIkpOTMWXKlFdmmTlzJlq1aoXx48fjo48+grm5OW7evIkjR45g9erVr3V+Zc0EABMmTEC7du2wYsUK9OrVC8ePH8dff/2lsZRxp06dsHTpUvz8889o3bo1fvnllwp5oxgYGIjZs2cjOTkZtra2pXrsRx99BB8fHwD5k/dfNGHCBHz55Zfw9PREvXr18O233yI5OVl9jubm5hgzZgymT58OOzs71KxZE1999RUyMzPVyxPPnTsXTZs2RYMGDZCTk4P9+/erjzdgwAB88cUX6N27N7788ku4urriypUrqFatGlq3bo25c+eiZ8+eqFmzJt555x1IpVKEhYXh+vXrWLhw4SvPrbhjv8zW1hb29vb44Ycf4Orqivv372PWrFka+wwcOBBz5szBiBEjMHv2bMTExBQocMaNG4f169djwIABmDFjBuzs7HDnzh3s2LEDP/744yt7CD09PZGbm4tvv/0WvXr1QnBwML777juNfUr6M3Hq1KkCHyoQVRbOKSEiojLbsGEDOnfuXOgQrb59+yIkJARXr15F//798emnn2LGjBlo2rQp7t27hzFjxmjs/9FHH+HHH3/Exo0b0ahRI7Rv3x6bNm1C7dq1S5TF19cXJ0+exO3bt/Hmm2/C398fc+fO1RhWVlplzQQAbdu2xXfffYcVK1bAz88PBw8exMcff6wxrCgwMFD9/DRv3hxpaWkavUrlpVGjRmjSpEmBT9RLwsvLC23atEG9evUKDGebOXMmBgwYgA8++ACtW7dWz1148RwXL16Mvn37YvDgwWjSpAnu3LmDQ4cOqYsjIyMjzJ49G76+vmjXrh1kMpl6mVojIyMcPnwYTk5O6N69Oxo1aoTFixer37gHBgZi//79OHz4MJo3b45WrVrh66+/VvfKvEpxx36ZVCrFjh07cOnSJTRs2BAff/wxli5dqrGPhYUF/vjjD1y7dg3+/v6YM2dOgWFa1apVQ3BwMBQKBbp27YpGjRph8uTJsLGxUfd8FMfPzw8rVqzAkiVL0LBhQ2zduhVffvmlxj4l+Zl49OgRzpw5o15JjaiySVQvD14lIiKiSjFixAjcunVLyDKsf/75J6ZPn47r16+X6M3vcyqVCl5eXhg7duwre4mUSiV8fHzQr1+/crkKPVWcmTNnIjk5+ZXXuCGqKBy+RUREVEmWLVuGLl26wNzcHH/99Rc2b95c7IUhK1KPHj0QGRmJR48ewc3NrUSPefr0KXbs2IG4uLhCP1G/d+8eDh8+jPbt2yMnJwerV69GdHQ0Bg4cWN7xqZw5OTmVeCgiUUVgTwkREVEl6devH06cOIG0tDR4eHhgwoQJGD16tOhYJSaRSODg4ICVK1cWWmg8ePAA7733Hq5fvw6VSoWGDRti8eLF6onvRERFYVFCRERERERCcaI7EREREREJxaKEiIiIiIiEYlFCRERERERCcfUtIiItlp2rQFKGHEkZciRn/vNvhhxJmblIzZQjT6mCVCKBVAJIpZJ//y+RQPLC/6WS/EnKRgZS2JsbwdHSGE6WJnC0NIaDhREMZPyMioiIxGFRQkQkSHauAnfi0xEZn4bIJ+l4lJKlLj6SM3KRlCFHVq6iwnNIJYCtWX6h8mKx4vTP7Wo2pvBytoCViWGFZyEiIv3E1beIiCpYljy/+Lj9JA2R8emI/Offh8mZUOrQb2BXaxN4OVuirpMF6rpYwtvZEt4uljAxlImORkREOo5FCRFROUrLzsWF6CRcjEnG7SdpuP0kDY9SslBVf9MaSCXwdLJAo+rWaFTDGg2rW6O+qxULFSIiKhUWJUREZZAlVyDkXhLORCXiTFQirj9KhUKXuj8qgIFUgobVrdGuriPa13VAYzdbyKQS0bGIiEiLsSghIioFeZ4Sl+8n42xUIs5GJSL0QQrkCqXoWFrNysQAbT0d0K6uI9rVdUR1G1PRkYiISMuwKCEieoWIuDQcDX+CM1EJuHQvGdm5LELKwsPRHO28HNG+riNaedjD1IhDvYiI9B2LEiKiQjxIysS+sMfYF/oYEU/SRMepsowMpGjubouO3k54q3E1OFmaiI5EREQCsCghIvpHQnoO/rwai72hj3D5foroOHpHJpWgnZcD3mnqhs71nWBswB4UIiJ9waKEiPRaek4eDl6Pw97QRzgTlaj3k9S1hbWpIXr5uaJvkxrwr2krOg4REVUwFiVEpHfkeUocv/UEe0Mf4/iteOTkcY6INqvjaI6+TWvgP/414GLN4V1ERFURixIi0hspmXL8cu4eNp+9h6dpOaLjUClJJUBbTwe807QGAhu48FooRERVCIsSIqry7idmYsPpu9h96SEy5QrRcagc2JoZYkgbd3zYpjaszQxFxyEiojJiUUJEVdale8n48dRdHLoRB04VqZrMjWQY1KoWPnqjNpysOLSLiEhXsSghoipFqVTh8M04/PC/u1xBS48YGUjxTtMaGN2uDmram4mOQ0REpcSihIiqhCy5ArsvPcBPp6MRk5gpOg4JIpNK0NPXFWM7eMLbxVJ0HCIiKiEWJUSk03LyFNh8JgbrTkQhOTNXdBzSEhIJEFDPCWM7eqIJlxQmItJ6LEqISCcplSr8duURVhy5jUcpWaLjkBZr7WGPGUHevN4JEZEWY1FCRDrn74h4LPnrFm7FpYmOQjpCIgH+418DM7t5w8mSE+KJiLQNixIi0hm34p5hwf6bCL6TKDoK6ShLYwNMCPDEh21rw1AmFR2HiIj+waKEiLReSqYcyw/fxrYL96Hg2r5UDjwczTG3Z3108HYSHYWIiMCihIi0mEKpwi/n7uHro7eRwknsVAEC6jlhbq/6qGVvLjoKEZFeY1FCRFopJCYJc367jognnDdCFcvIQIrhb9TGhE6eMDMyEB2HiEgvsSghIq2Sk6fA8sO38eOpu7wKO1UqFysTzOpWD739q4uOQkSkd1iUEJHWuP4oFVN2heL2k3TRUUiPBdRzwpJ3fOFgYSw6ChGR3mBRQkTC5SmUWPN3FFb/HYlcBX8lkXgOFkb46h1fdKrnLDoKEZFeYFFCRELdiU/DlF1huPowVXQUogIGt6qFOT18YGIoEx2FiKhKY1FCREIolSpsOB2NZYcjkJOnFB2HqEieThZY+V5jNKhmLToKEVGVxaKEiCrdg6RMTN0dhgvRSaKjEJWIkUyKqV3rYsSbHpBKJaLjEBFVOSxKiKhSbb9wHwv330SGXCE6ClGptfawx4r+fnC1NhUdhYioSmFRQkSVQp6nxJzfrmH3pYeioxCVibWpIRb1aYievtVERyEiqjJYlBBRhXualoPRv1zCpXvJoqMQlZsBLdzw+dsNYSiTio5CRKTzWJQQUYW69jAVI7eEIDY1W3QUonLXysMO373fFDZmRqKjEBHpNBYlRFRh9oY+wsw9V5Gdy9W1qOqq7WCOn4Y2R20Hc9FRiIh0FosSIip3SqUKSw9HYN2JKNFRiCqFjZkh1g1qitZ17EVHISLSSSxKiKhcpWXnYvKOUBy7FS86ClGlMpRJsKh3I/Rr7iY6ChGRzmFRQkTlJiYhAx/9HII78emioxAJM6q9B2YF1YNEwuuZEBGVFIsSIioXpyMTMG7bZaRm5YqOQiRcYANnfNPfH6ZGMtFRiIh0AosSIiqzv67FYuKOK8hV8NcJ0XMNq1thw5DmcLYyER2FiEjrsSghojLZG/oIU3eFIU/JXyVEL3OxMsHGD5vDx9VKdBQiIq3GooSIXtueSw8x/b9hYD1CVDQ7cyNsG9ES9VxYmBARFYWXoSWi17Ljwn0WJEQlkJQhx6D153H7SZroKEREWotFCRGV2pazMZj92zUWJEQllJghx8D15xDJwoSIqFAcvkVEpbLhdDQW7L8pOgaRTnKwMMaOkS3h6WQpOgoRkVZhUUJEJbbuRBSWHLwlOgaRTnO0NMb2Ea3g6WQhOgoRkdZgUUJEJbLyaCS+PnpbdAyiKsHR0hg7RrZCHUcWJkREAIsSIiqB5Ycj8O3xO6JjEFUpTv8UJh4sTIiIONGdiIq3MTiaBQlRBYhPy8GA9ecQnZAhOgoRkXAsSoioSEduPuGkdqIK9ORZDgb8cA73EzNFRyEiEopFCREV6trDVEzacYXL/hJVsLhn2Ri68QJSMuWioxARCcOihIgKeJSShWGbLyJTrhAdhUgv3E3IwKgtlyDPU4qOQkQkBIsSItKQlp2LYRsv4mlajugoRHrlfHQSZv16VXQMIiIhWJQQkVqeQomxWy8jgledJhLi18uPsOpYpOgYRESVjkUJEanN+e06TkUmiI5BpNdWHLmNvaGPRMcgIqpULEqICACw5u872BnyQHQMIgIwc89VXH+UKjoGEVGlYVFCRNgX9hjLDkeIjkFE/8jOVWLkzyFISOfcLiLSD7yiO5GeC32Qgn7fn+WqP1VE6rndSDm5GZZN34Jd55Ea21QqFeJ3z0N29CU49pkDs7qtC21DpchDyqktyIoKQV5qHKTG5jCp5Qeb9kNhYGmfv09eLhIPrkJm5DnIzG1h13UsTN0b/5vj/B4onj2FXZfRFXau+qCFux22jmgJQxk/QySiqo2/5Yj0WFp2LiZuv8KCpIrIib2NtNCDMHR0L3R7WsheQPLqdlR5OZDHRcG6zXtwHbISjr3/D7lJj/D01wX/thV2EPK4O3B5fxks/IKQ8MdSPP+MKzclDulhh2DT7oPyOC29diEmCZ//wQuYElHVx6KESI99+vt13E/ilaSrAqU8Cwl/LIN90ARITSwKbJc/uYtnF36DQ7fJr2xLamwO5/cWwtznTRja14Bx9Xqw6zIa8rg7yHsWDwDITXwAU8+WMHKsBcsmPaDMTIUy6xkAIOnwWth2GAqpsVm5nqO+2nLuHnZevC86BhFRhWJRQqSnfr38EL+HPhYdg8pJ0pF1MK3TXGMI1XPK3Gwk/LEUdl3HQGZh+1rtK3MyAUggNc4veIycaiPn4U0oc3OQHX0ZMgs7SE2tkH7jb0gMjGBWt00ZzoZeNm/fTUQnZIiOQURUYViUEOmhe4kZmLv3hugYVE4ybp6EPC4Ktu2HFLo9+diPMK7uAzOvVq/VvipPjpQTG2FWv52698OiURcYOtXG4w1jkXp2FxzenglldjpST2+FXedRSP7fFjz6fgSe7PwUeWlcZrqssnIVmLwzFHkKDrUkoqqJRQmRnslVKDFx+xWk5+SJjkLlIO/ZUyQdWw+HXtMgMTAqsD0z8jyy74fBNmDEa7WvUuTh6d7FAAD7ruPU90tkBrDvOgY1Rm+A65CvYVKjAZKPb4Bl016QP7mLrMizcP3wWxhXq4fkoz+83smRhrAHKVjzd5ToGEREFYKrbxHpmcV/3cJ3J/nGpqrIvH0WT39bBEhe+IxJpQQgASQSWPp3R9rlPwGJRHO7RArjGvXhMnBxkW0/L0jyUuLgPOALyEytitw3+95VJJ/cCJf3lyH5758gkcpg23EY5E/v4cm2WXCbtL0czpYMpBL8OrYNfGvYiI5CRFSuDEQHIKLKE3wnAd//jwVJVWJSyw+uw1Zr3Jd4YCUM7WvAqmVfyEytYdE4SGN77E/jYdvpI5h6tiiyXXVBkvwYzgO+LLYgUeXJkXRkXX5vjVQGqJT5dREAKBVQqTjkqLzkKVWYvDMUBya+CRNDmeg4RETlhsO3iPREUoYcH+8MBftGqxapsRmMHN01viSGxpCaWMLI0R0yC9sC2wHAwMoRhjYu6nYerR+NzNtnAPxTkPz+JeRxd+DQaxqgVEKRngxFejJUitwCGVLO7ICpRzMYOdcBABhXr4/M22cgj49G2uX9MKnuU/FPhB65+zQDXx4IFx2DiKhcsaeESE9M3x2G+DReHZoKl5f08J8VtgBFeiKy7pwHAMRunKixn/OAL2BS01d9W/40Bpm3TsF16Lfq+8zqtUX2g2uI2zoThvbV4dBreiWcgX75+dw9BPg4o11dR9FRiIjKBeeUEOmBzWdi8Nk+rrZFVJU4Wxnj0OR2sDEruMABEZGu4fAtoiouNjULSw7eEh2DiMrZk2c5+OT366JjEBGVCxYlRFXcwj/DkSlXiI5BRBVg/9VY7A19JDoGEVGZsSghqsKC7yTgz6uxomMQUQWau/cGkjLkomMQEZUJixKiKipXocTcvRzaQVTVpWblYsWRCNExiIjKhEUJURX10+loRD3NEB2DiCrB9gsPEBGXJjoGEdFrY1FCVAXFpWZj1bFI0TGIqJIolCos2H9TdAwiotfGooSoClr4501kcHI7kV45fScBR28+ER2DiOi1sCghqmLO3EnAfk5uJ9JLiw6EI1ehFB2DiKjUWJQQVSG5CiUvkkikx6ITMrD5TIzoGEREpcaihKgK2Rgcjcj4dNExiEiglcciuUQwEekcFiVEVUT8s2ysOnZHdAwiEiwtOw/LD3OJYCLSLSxKiKqItSeikJ6TJzoGEWmBHRcf4FbcM9ExiIhKjEUJURXwNC0H2y/cFx2DiLQElwgmIl3DooSoClh/6i5y8rjiDhH9K/hOIv6OiBcdg4ioRFiUEOm4pAw5fjl3T3QMItJC605EiY5ARFQiLEqIdNyG03eRyQslElEhLkQn4fL9ZNExiIheiUUJkQ5LzcrFz2fYS0JERfv+JHtLiEj7sSgh0mGbgmOQxhW3iKgYR24+wd2nvH4REWk3FiVEOio9Jw8bz0SLjkFEWk6pAn74313RMYiIisWihEhHbTl7DymZuaJjEJEO+PXKI8SnZYuOQURUJBYlRDooS67AhtP85JOISkaep8RPp2NExyAiKhKLEiIdtO3CfSSky0XHICIdsvX8PaRzDhoRaSkWJUQ6Jk+hxI+n2EtCRKWTlp2Hbee5Wh8RaScWJUQ65titeMSmcmw4EZXeT6djIM9Tio5BRFQAixIiHbP9wn3REYhIR8U9y8bvoY9ExyAiKoBFCZEOeZicif/dfio6BhHpsK3n+cEGEWkfFiVEOmTnxQdQqkSnICJdFvYghRdTJCKtw6KESEcolCrsCnkgOgYRVQG/X+EQLiLSLixKiHREetRZDLO9BlOZQnQUItJxv4U+gkrFblci0h4sSoh0hPXldRj1ZB5uWH2MP7z+RFeHJNGRiEhHPUjKQsi9ZNExiIjUJCp+VEKk/bKSgWV1AYXmBRMzHXxxxLgLFj9qhNhsI0HhiEgXDWxZE1/0aSQ6BhERABYlRLrh4o/An1OL3KwyMMVDl074OetN/PjYDSqVpBLDEZEusjY1xIU5ATA2kImOQkTEooRIJ6wPAB6FlGjXPCs3XLQOwtL4ZricalnBwYhIl333fhMENXQVHYOIiEUJkdZLigZWNS71w1SQINWlNfZKOmHZg7pIyzMo/2xEpNMCGzjj+8HNRMcgIuJEdyKtF3HgtR4mgQo2cWcwJHYhrppPwCGv39HHOb6cwxGRLvv71lOkZMpfvSMRUQVjUUKk7SL+KnMTkpxUeD/Yha9TJ+NWtQX4zvM8PMyyyyEcEekyuUKJ/VdjRccgIuLwLSKtlpUCLK0DKPPKvWmVzAhPXDpgu7wd1j6qjVwlJ8cT6aNmtWzx3zFtRMcgIj3HooRIm137L7BneIUfRmHuglC7IHyd2BKnk6wr/HhEpD0kEiBkTmfYWxiLjkJEeozDt4i02WvOJyktWUYcmj7YhF8yx+BazRVY4nEV9ka5lXJsIhJLpQKCoxJFxyAiPceihEhbKXKBO0cr/bCW8SHo/3gxQkzG4rjnbgx0fVzpGYiocgVHJoiOQER6jsO3iLTV3ZPAz2+JTgEAkNvUQbBlEL6K9Ud4upnoOERUzqrbmCJ4VifRMYhIj7GnhEhblcOqW+XFKCUKHR+swQHFKITU/gGzat2GqUwhOhYRlZNHKVmITsgQHYOI9BiLEiJtdVt7ipLnJCoFHGJPYPSTebhh9TH+8PoTXRySRMcionJw+g6HcBGROBy+RaSNEiKB1bpzleUMBz8cMe6CJY8aIjbbSHQcInoNQQ1c8N3gpqJjEJGeMhAdgIgKcf+s6ASlYp4Qht4Iw9sGpnjoGYCfs97Aj4/doFLx2idEuuJMVAKUShWkUv7cElHl4/AtIm304LzoBK9FkpcFt4f7MSdxFiIdZ2Gb1wk0sU4XHYuISuBZdh6uPkoVHYOI9BSLEiJt9OCC6ARlZvDsAdo8+AF75KNxxX0N5tUOh6VB+V+ZnojKTzDnlRCRIJxTQqRtMpOArzwAVL0fTaWJDW47BmLds9bY+8RJdBwiekkrDzvsGNladAwi0kMsSoi0ze1DwLZ+olNUuGz7+vjbtAuWPPJDTJaJ6DhEBMDIQIqwuV1haiQTHYWI9AyHbxFpm/vnRCeoFCaJN9Ht4Ur8LR2Ns3U2YWLNuzCU8jMSIpHkeUpceZAsOgYR6SEWJUTapgrMJykNiUIO10eHMSX+E9yym4Y9XkfQ1paTbYlEuRWbJjoCEekhFiVE2kSRBzy+LDqFMLL0WDR9sBFbs8bgas2vsdjjGuyNckXHItIrEXEsSoio8vE6JUTaJO4qkJspOoVWsIq/iPdwEf1NLHC3Zlf8mNEG22OriY5FVOXdesKihIgqH3tKiLTJwxDRCbSORJ6OOg9/xZfJ03DbZS5+8gpGPQsWbkQVJfJJGrgGDhFVNvaUEGmTp7dEJ9BqRil30CnlDjpKDZBQ+03sVnbAtw89kKXgSkFE5SVTrsD9pEzUsjcXHYWI9Ah7Soi0ScJt0Ql0gkSZB8fYvzH2yWe4Yf0x9nkdQIB9kuhYRFXGLc4rIaJKxqKESJsk3hGdQOdIMxPg++AXbMgYjxs1luDrOpfhYiwXHYtIp3GyOxFVNg7fItIWOWlAWqzoFDrNPCEMfRCG3oameODWGT9nvYENj2tApZKIjkakU1iUEFFlY08JkbZIiBSdoMqQ5GWh5sM/8EniTEQ6zsY2rxNobJUuOhaRzrgV90x0BCLSMyxKiLQFh25VCINn99HmwQ/4LXc0rrivwWfu4TA3UIiORaTVYhIzkZ3LnxMiqjwsSoi0BSe5VyiJSgnbuGB8GLcA1ywm4KDXXrzlFC86FpFWUihVuBPP3kUiqjwsSoi0BYdvVRppdgrqPdiJVc8mI7z6IqzzvAB302zRsYi0CueVEFFl4kR3Im3B4VtCmCbeQDfcQJDMCHF1OmKbvB3WPqwFhYqf2ZB+e5icJToCEekR/tUl0gYqFZAYJTqFXpMo5HB9dAhTn87BbfsZ+K/XEbS2TRUdi0iYhPQc0RGISI+wKCHSBplJQB4/ldQWsvTHaPZgI7ZljcXVWt/gC49rsDXMEx2LqFIlZrAoIaLKw+FbRNogM0F0AiqEBCpYPbmAgbiAAaYWiHIPxI/pbbAj1lV0NKIKl5DGi5ASUeVhTwmRNshgUaLtJPJ0eD7Yg8XJU3HbZS5+8gpGXXP2blHVlcCeEiKqROwpIdIG7CnRKUYpd9Ap5Q46Sg2Q4NEOuxTtsepBHeQo+TkPVR0JaSxKiKjy8C8okTbIeCo6Ab0GiTIPjo+PY9yTzxBu8zH2ev2FAPsk0bGIysWz7DzI85SiYxCRnmBRQqQNMhJFJ6AykmY+hd+DLdiQMR433L7CijpX4GLMMfmk2zjZnYgqC4dvEWkDDt+qUsyfhuI/CEUfIzPcd+uMzVltsfFxDahUEtHRiEolMV0OV2tT0TGISA+wp4RIG3Cie5Ukyc1ErYf7MDdxJm47/h+2ep1EY6t00bGISuwpr1VCRJWEPSVE2oA9JVWe4bN7aPvse7SRSJHs3ga/STpixYO6yMiTiY5GVKTEdA5BJKLKwaKESBtwTonekKiUsIs7jeE4jQ8tbBHhGIg1Ka2x/6mj6GhEBfCq7kRUWTh8i0gbZKeKTkACSLOT4fNgB1anTUJ49UVY53kBNU2zRcciUkvKYE8JEVUO9pQQaQNlnugEJJhp4g10ww0EyYwRW6cjtsrb4buHNaFQ8bMjEic7VyE6AhHpCf61I9IGKv7hp3wSRQ6qPTqI6U//DxEOM7Hb6yha27InjcTIU6pERyAiPcGihEgbKFmUUEEGaY/Q/MFP2JY1FmG1VuKL2tdga8heNao8CgWLEiKqHBy+RaQN2FNCxZBABesn5zEQ5zHAzBJRTl3xQ1pb7IpzER2NqjiFikUJEVUO9pQQaQOlUnQC0hGSnDR4PtiDr1Km4LbrZ9jgdQZ1zbNEx6IqSsHhW0RUSdhTQqQN2FNCr8EoORIByZHoJDXAU4922JXXAd8+9ECOkp83UfngnBIiqiwsSoi0AeeUUBlIlHlwenwc43EcQx1r4v1aPlCArykqOyfn1gD8RccgIj3AooRIG6g4fIvKh0XqfThI6+J86m3RUagKqO/gKToCEekJ9vETaQMO36Jy1C1XdAKqKmQSmegIRKQnWJQQaQP2lFA56hx9CQZSdoRT2cmkLEqIqHKwKCHSBoZmohNQFWKdmYxWVhx2Q2XHnhIiqiwsSoi0gbGV6ARUxXTL5pBAKjuphG8TiKhy8LcNkTYwYVFC5atT9EUYSY1ExyAdZ2bAXlwiqhwsSoi0AXtKqJxZZD9DWw7hojKyMbERHYGI9ASLEiJtwJ4SqgDdsrJFRyAdZ2tsKzoCEekJFiVE2oA9JVQB2t+9CFOZiegYpMPYU0JElYVFCZE2YE8JVQAzeQbetKojOgbpMPaUEFFlYVFCpA3YU0IVpFt6uugIpMPYU0JElYVFCZE2MLEWnYCqqDfvXoQ5V1Ci18SeEiKqLCxKiLQBe0qoghjnZaODpYfoGKSDpBIprI35gQkRVQ4WJUTawNxBdAKqwro9SxUdgXSQlZEVL55IRJWGv22ItIGtu+gEVIW1ib4IS0ML0TFIx9gY24iOQER6hEUJkTZgUUIVyFAhR4BFbdExSMfYmnA+CRFVHhYlRNrA3AEw4ifZVHG6pSSKjkA6xsGUw0qJqPKwKCHSFja1RCegKqxFTAhsjThpmUqulhV/JxFR5WFRQqQtbPkGgCqOgTIPnc1rio5BOqS2NYf8EVHlYVFCpC04r4QqWLekeNERSIfUtmJRQkSVh0UJkbbg8C2qYE3vXYKjiZ3oGKQj2FNCRJWJRQmRtmBPCVUwqUqJLibVRccgHeBk6gQLLr5BRJWIRQmRtuCcEqoE3RIei45AOoC9JERU2ViUEGkLW3eAV0+mCub3IBQupo6iY5CWc7d2Fx2BiPQM3wERaQtDU8DOQ3QKquIkUCHQ2EV0DNJy7CkhosrGooRIm7j4ik5AeqBb/H3REUjLsSghosrGooRIm7iyKKGK1+DRNbiZsbeEiuZhzV5bIqpcLEqItAl7SqiSBBpyXgkVzszADM5mzqJjEJGeYVFCpE1c/UQnID0RFHdXdATSUo0cGkEikYiOQUR6hkUJkTYxdwBsaopOQXrAOy4ctc15zRIqqIlzE9ERiEgPsSgh0jbVm4lOQHoiSGYrOgJpIRYlRCQCixIibVOjuegEpCeCYm+LjkBaxkBiAF8Hzm0josrHooRI29RgTwlVDo/4O6hrweGC9K96dvVgZmgmOgYR6SEWJUTaxtUPkBmJTkF6IkhqKToCaREO3SIiUViUEGkbA2OgRgvRKUhPBD28KToCaREWJUQkCosSIm3kGSA6AekJt8R7aGDFq3cTIIEETZxYlBCRGCxKiLQRixKqREEqU9ERSAvUtq4NWxOuyEZEYrAoIdJGLr6AuZPoFKQngu5fhwS8WJ6+83fyFx2BiPQYixIibSSRAHU6iU5BesIl5SH8rDxExyDBmjo3FR2BiPQYixIibcUhXFSJghRc8U2fSSVStKnWRnQMItJjLEqItFWdTgCH1FAl6Xo/DFIJ/yToq8aOjWFvai86BhHpMf4FItJW5g751ywhqgSOz+LQ1KqO6BgkSJdaXURHICI9x6KESJtxCBdVoqBc/knQV51rdRYdgYj0HP8CEWkzT75RoMrTJeYyDCQGomNQJWtg3wAu5i6iYxCRnmNRQqTN3FoCFs6iU5CesM1IRAtrDuHSN+wlISJtwI/EiLSZVAY07AucWys6CemJoBwVzogO8YKMiAwkHEhA1r0s5KXkoeaEmrBqaqXenpeah7hdcUi/kQ5FpgLmdc3h+r4rjF2Mi2034VACkv5OQm5iLmSWMlg3s4bzO86QGuV/VpdyJgVx/42DMlsJ2zdt4TrAVf1Y+VM5YpbFoM68OpCZyirmxCtR55osSohIPPaUEGm7Ru+KTkB6JCD6IgylhqJjqClzlDCpaYJqg6sV2KZSqXBv1T3In8pRc2JNeM73hKGDIWKWxkCZoyyyzZSzKXiy+wmc3naC1xdeqD6sOlIvpOLJnicAgLy0PDza+Aiu/V3hPs0dKWdS8Cz0mfrxj7c8hvO7zlWiIKljXQfu1u6iYxARsSgh0nrVmwD2XqJTkJ6wykpFGytP0THULH0t4dzXWaN35Dn5EzmyorJQbUg1mHmYwdjVGNU+qAalXImUcylFtpl5JxNmXmawaW0DI0cjWDa0hHVLa2Tdzcpv96kcMlMZrFtaw8zDDOY+5sh5nAMASDmXAolMAutm1hVyvpWNQ7eISFuwKCHSBb79RCcgPRKYJRcdoURUuSoAgMTw3+v5SKQSSAwlyLydWeTjzDzNkBWThcy7+fvI4+VIv5oOC18LAICxszGUcmX+kLH0PGRFZ8HEzQSKDAXif42H6/uuRbata1iUEJG24JwSIl3Q6B3g70WiU5Ce6BQdAmM3F+QockRHKZaxqzEM7Q3xZPcTVB9aHRJjCRIPJSIvKQ95qXlFPs6mtQ0U6QpEL4qGCipAAdh1tINTLycAgMxchhojauDh+odQyVWwaWMDy0aWeLjhIewC7JCbkIv7K+9DpVDBqbcTrJvrZq9JDYsaqGdXT3QMIiIALEqIdIOdB1CjOfDwougkpAfMc9LwptUbOJp8Q3SUYkkMJKg5oSYebXiE8HHhgBSwqG+R3+OhKvpx6eHpePrHU7h+4AozDzPI4+WI3RqL+L3xcHo7vzCxamqlMWQs41YGch7moNr71XB75m24jXaDgbUBoj6Pgrm3OQysdO/PaW/P3qIjEBGp6d5vUSJ95dufRQlVmsD0TBwVHaIETN1N4bnAE4pMBVR5KhhY5RcKpu6mRT4m/rd42LSxgV17OwCAiZsJlDlKPNr0CI69HCGRSjT2V+Yq8fjnx6gxsgbk8XKoFCqY1zMHABi7GCMzKhNW/gXnvGgzA6kB+tbtKzoGEZEa55QQ6YoG/wGk/ByBKkf7mIswNSj6jb22kZnJYGBlgJy4HGRFZ8GyiWWR+ypzlAX/+hXz1/DpvqewaGQBU3dTqJQq4IWFvVR5mrd1RUe3jnAwdRAdg4hIjUUJka4wt+cV3qnSmMoz0cFS/IUUFdkKZN3LQta9f1bGSpAj614W5In5k/FTL6QiPTwd8ng5nl1+hpilMbBqYgXLhv8WJQ9/eIi43XHq25aNLZF0PAkp51IgfypH+vV0xP8aD8vGlgV6SbIfZSP1Qiqc/5N/EVNjV2NAAiSdTEJaaBpyYnNg6qE7xdtz/b37i45ARKSBH7sS6ZJmw4HbB0WnID0RmPYMfwnOkBWdhZglMerbcdvziwubtjaoMaIG8lLzELsjFopUBQxsDGDTxgaObztqtCFPlAMv1BpObzlBIpEg/td45CbnwsDSAJaN85cefpFKpcLjTY/hMsAFUuP8z/CkRlJU/6g6YrfEQpWrgutgVxjaas91XUrC3codLV1bio5BRKRBolKpipkOSERaRaUCVjcHEiNFJyk36y7KsS5EjpiU/DEwDZxkmNvOCN28/n2jd/ZBHuYcz8H5RwrIJEBjFxkOvW8GU0NJUc1izQU5lp7JQVy6Cn4uUnzbzRQtqv97sbsph7KxKVQOcyMJFgeYYJDvv8fbfSMXP1/NxR8DzCrgjHWHXGaMDnXqIC03XXQUKkfTm03HBw0+EB2DiEgDh28R6RKJBGg5SnSKclXDSoLFnY1xaaQ5Qkaao5O7DG/vyMKNeAWA/IIkaGsmutYxwIWPzHFxhDnGtzCCtOh6BDuv52LK4Wx81t4Yl0eZw89ZhsBfMhCfkV/4/BGRi23XcnF4sDm+6myCj/7IQkJm/rbUbBXmHM/Bmu4mFX7u2s5IkYOOFrVFx6ByZCIzwdueb4uOQURUAIsSIl3TeCBgYiM6Rbnp5W2I7l6G8LKXoa69DIsCTGBhBJx7mF+UfHwoBxNbGGHWG8Zo4CSDt4MM/RoYwtig6KpkxbkcjGhiiA/9jVDfUYbveprAzFCCn67kAgDCE5To4C5Ds2oyDGhkCCtjCaKT8zuNZxzJxphmhqhpzV+PABCYmiQ6ApWjru5dYW2sm9dVIaKqjX91iXSNkTnQdIjoFBVCoVRhx/VcZOQCrd1kiM9Q4vwjBZzMpWizIQPOy9LQflMGTt8v+sJ4coUKlx4r0dnj3ylzUokEnT0McPafQsfPWYaQxwokZ6lw6bECWbkqeNpJcfp+Hi7HKTCxpVGFn6uuaB0dAmsj3VrulorGCe5EpK1YlBDpohYjq9TywNeeKGDxxTMYL0zD6P1Z+K2/Keo7ynA3OX9I1byT+T0fBweZoYmLDAE/ZyIyUVFoWwmZKihUgLO5Zk+Ks7kEcen57QV6GuB9X0M0X5+OoXuzsLm3KcyNgDF/ZuO7HqZYF5IL79XpaPtThnoYmb4yVOais3kt0TGoHPjY+cDX0Vd0DCKiQrEoIdJF1jUAn16iU5QbbwcpQkdb4PxH5hjTzAhDfs/GzacKKP9ZhmNU0/yhWP6uMnwdZAJve6l6KNbrmtfBBHcmWuLaGAv08THEl6fk6FzbAIYyYOH/cnD6QzN85G+ID37PKocz1G1ByU9FR6ByMKDeANERiIiKxKKESFe1Gic6QbkxkkngaSdF02oyfNnZBH7OUqw8J4erRf6vqPqOmr+qfByluP+s8CvWOZhJIJMATzI0FxZ8kqGCi0Xhv/JuJSjwy7VcLOhkjBMxeWhXSwZHcyn6NTDE5Vgl0nL0e5HC5jGXYGdsKzoGlUF1i+roVafqfJBBRFUPixIiXeXWHKjeTHSKCqFUATkKwN1GgmqWEkQkaBYgtxOVqFXERHQjmQRNq0lx7O6/806UKhWO3c1D6xqyAvurVCqM2p+NFV2NYWEkgUIJ5P5zuOf/KvS7JoFMpUAXsxqiY1AZjPIdBYMqNOSTiKoeFiVEuqztJNEJymz20Wz8714eYlKUuPZEgdlHs3EiRoFBjQwhkUgwvY0RVl2Q4783c3EnSYlPj2fjVoISw/3/nYwe8HMGVl+Qq29PaWWM9ZdzsTlUjvCnCozZn42MXBU+bFzwInc/Xs6Fo5kEvbzzt7WtaYDj0Xk49zAPX5/NQX1HKWxMill/WE90S4h79U6klWpY1GAvCRFpPX5sQqTLfHoBro2B2FDRSV5bfIYKH/yWhdh0FayNJfB1luLQ+2boUif/19PkVsbIzgM+PpSNpCwV/JxlODLYDHXs/v1MJSpJqb7OCAD0b2iIp5kqzD2Rf/HExi5SHBxkBueXhm89SVdi0akcnBlurr6vRXUZprY2Ro9tWXAyl2Bzb9MKfgZ0Q5P7l+Hk44/47ATRUaiURvqOZC8JEWk9XtGdSNfdOQr80ld0CtIDS/x74JeUa6JjUCm4WbphX+99LEqISOtx+BaRrvPsDNR6Q3QK0gPdnj4UHYFKib0kRKQrWJQQVQUBc0UnID3g+zAM1c2cRcegEqppWRO9PDiXhIh0A4sSoqqgZkugbpDoFKQHuho5iY5AJTTSdyRk0oIrzhERaSMWJURVRadPAXCVKKpY3eKiRUegEqhlVQs9PXqKjkFEVGIsSoiqCpeGQENOeKeK5RN7E7XMq4mOQa8wyncUe0mISKewKCGqSjr+H8BJrVTBAg3sRUegYjR2bMxeEiLSOSxKiKoS+zpAkyGiU1AV1y32jugIVASZRIZPWn0CiYRDOYlIt7AoIapqAj4FzBxEp6AqzPNJBDwt3ETHoEL09+4Pbztv0TGIiEqNRQlRVWNqCwQuEp2CqrhAqbXoCPQSB1MHjPcfLzoGEdFrYVFCVBX5vQe4vyk6BVVh3R7dEh2BXjKl6RRYGlmKjkFE9FpYlBBVVT2/BmRGolNQFVUr4S58LGuJjkH/aOrcFL3q8EKJRKS7WJQQVVUOXkDbyaJTUBUWCAvREQiAgcQAc1rOER2DiKhMWJQQVWVvTgXsPESnoCoq6OEN0REIwECfgfCy9RIdg4ioTFiUEFVlhiZAj+WiU1AVVT3pPnytWPSK5GTqhLGNx4qOQURUZixKiKq6Op14pXeqMIFKE9ER9NqMFjNgbmguOgYRUZmxKCHSB4FfAqZ2olNQFRR4/yok4IX6ROhWuxsC3QNFxyAiKhcsSoj0gaUz8PYa0SmoCnJOfQx/6zqiY+gdZzNnfNLqE9ExiIjKDYsSIn1RrzvQ/CPRKagKCsozEB1Br0ggwaI3FsHKyEp0FCKicsOihEifdF0EOPqITkFVTNeYK5BJZKJj6I1BPoPQ0rWl6BhEROWKRQmRPjE0Ad75CTDg5GQqP/bpT9GMQ7gqRV3bupjcdLLoGERE5Y5FCZG+ca4PdF0oOgVVMUFy0QmqPlMDUyxtvxTGMmPRUYiIyh2LEiJ91GIE4N1ddAqqQrpEX4KBlHNLKtLsFrPhYV2x14WRSCT4/fffi9x+4sQJSCQSpKSkVGgOKtrQoUPRu3fvMrcjl8vh6emJM2fOlD2UDnF3d8c333yjvv2q17w+mDdvHho3blxu7R08eBCNGzeGUqks1eNYlBDpq7fXAJauolNQFWGdmYxWVp6iY1RZ3Wt3Rx+vPmVqIy4uDhMmTICHhweMjY3h5uaGXr164dixYyVuo02bNoiNjYW1tXWZsjxX3m+G9MHKlSuxadOmMrfz3XffoXbt2mjTpo36PolEov6ytrZG27Ztcfz48TIfS5vFxsaiW7duwo5f2YV+YUXYtGnTSvV74FWCgoJgaGiIrVu3lupxLEqI9JWZHdDne0DCXwNUPoKyFaIjVElulm6Y23pumdqIiYlB06ZNcfz4cSxduhTXrl3DwYMH0bFjR4wbN67E7RgZGcHFxQUSSeVemyY3N7dSj6fNrK2tYWNjU6Y2VCoVVq9ejeHDhxfYtnHjRsTGxiI4OBgODg7o2bMn7t69W6bjaTMXFxcYG1eNIZGv+3NiYWEBe3v7cs0ydOhQrFq1qlSP4bsRIn3m0R4IKNubHaLnAqIvwkhqJDpGlWJhaIFvO31b5qu2jx07FhKJBBcuXEDfvn1Rt25dNGjQAFOmTMG5c+c09k1ISECfPn1gZmYGLy8v7Nu3T73t5U91N23aBBsbGxw6dAg+Pj6wsLBAUFAQYmNjNR7TokULmJubw8bGBm3btsW9e/ewadMmzJ8/H2FhYepP55/3AEgkEqxbtw5vvfUWzM3NsWjRIigUCgwfPhy1a9eGqakpvL29sXLlSo3sz4c2zZ8/H46OjrCyssLo0aMhlxc96en5Ofz+++/w8vKCiYkJAgMD8eDBA4399u7diyZNmsDExAQeHh6YP38+8vLy1NslEgl+/PHHIp87ANi3b5/6GB07dsTmzZs1ns/Ceo6++eYbuLu7FzjH5zp06ICJEydixowZsLOzg4uLC+bNm1fk+QLApUuXEBUVhR49ehTYZmNjAxcXFzRs2BDr1q1DVlYWjhw5gp9//hn29vbIycnR2L93794YPHiw+vbChQvh5OQES0tLfPTRR5g1a5bGOSmVSnz++eeoUaMGjI2N0bhxYxw8eFC9XS6XY/z48XB1dYWJiQlq1aqFL7/8Ur09JSUFo0aNgrOzM0xMTNCwYUPs379fvf306dN48803YWpqCjc3N0ycOBEZGRlFPhcv9hy86tgvu3jxIrp06QIHBwdYW1ujffv2uHz5coH2i3pdxMTEoGPHjgAAW1tbSCQSDB06FED+EKg33ngDNjY2sLe3R8+ePREVFaVuNyYmBhKJBDt37kT79u1hYmKi7pn46aef0KBBAxgbG8PV1RXjx48HAPXrqE+fPpBIJOrbhb3uimoDAFasWIFGjRrB3Nwcbm5uGDt2LNLT0zUe36tXL4SEhGhkfhUWJUT67o2PAb+BolNQFWCR/QxtrTmEq7zIJDIsbb8UdWzKtrJZUlISDh48iHHjxsHcvGBx8/Kn7vPnz0e/fv1w9epVdO/eHYMGDUJSUlKR7WdmZmLZsmXYsmUL/ve//+H+/fuYNm0aACAvLw+9e/dG+/btcfXqVZw9exYjR46ERCJB//79MXXqVDRo0ACxsbGIjY1F//791e3OmzcPffr0wbVr1zBs2DAolUrUqFEDu3fvxs2bNzF37lz83//9H3bt2qWR59ixYwgPD8eJEyewfft2/Prrr5g/f36xz1FmZiYWLVqEn3/+GcHBwUhJScF7772n3n7q1Cl88MEHmDRpEm7evInvv/8emzZtwqJFi0r83EVHR+Odd95B7969ERYWhlGjRmHOnDnF5iqpzZs3w9zcHOfPn8dXX32Fzz//HEeOHCly/1OnTqFu3bqwtLQstl1TU1MA+W/W3333XSgUCo1CKz4+Hn/++SeGDRsGANi6dSsWLVqEJUuW4NKlS6hZsybWrVun0ebKlSuxfPlyLFu2DFevXkVgYCDeeustREZGAgBWrVqFffv2YdeuXYiIiMDWrVvVb56VSiW6deuG4OBg/PLLL7h58yYWL14MmSx/SfKoqCgEBQWhb9++uHr1Knbu3InTp09rvKEuTnHHLkxaWhqGDBmC06dP49y5c/Dy8kL37t2RlpamsV9Rrws3Nzfs2bMHABAREYHY2Fh1oZ2RkYEpU6YgJCQEx44dg1QqRZ8+fQrM05g1axYmTZqE8PBwBAYGYt26dRg3bhxGjhyJa9euYd++ffD0zP+9fPHiRQD/9oY9v/2y4toAAKlUilWrVuHGjRvYvHkzjh8/jhkzZmi0UbNmTTg7O+PUqVMleObzcVYiEQG9VgJJd4EH5169L1ExgjKz8bfoEFXE9ObT8Ub1N8rczp07d6BSqVCvXr0S7T906FAMGDAAAPDFF19g1apVuHDhAoKCggrdPzc3F9999x3q1MkvnsaPH4/PP/8cAPDs2TOkpqaiZ8+e6u0+Pv9eK8nCwgIGBgZwcXEp0O7AgQPx4Ycfatz3YnFRu3ZtnD17Frt27UK/fv3U9xsZGeGnn36CmZkZGjRogM8//xzTp0/HggULIJUW/llsbm4uVq9ejZYt86//snnzZvj4+ODChQto0aIF5s+fj1mzZmHIkCEAAA8PDyxYsAAzZszAZ599VqLn7vvvv4e3tzeWLl0KAPD29sb169cLFDavw9fXV53Dy8sLq1evxrFjx9ClS5dC97937x6qVatWbJuZmZn45JNPIJPJ0L59e5iammLgwIHYuHEj3n33XQDAL7/8gpo1a6JDhw4AgG+//RbDhw9Xf9/mzp2Lw4cPa3yKvmzZMsycOVNd9C1ZsgR///03vvnmG6xZswb379+Hl5cX3njjDUgkEtSqVUv92KNHj+LChQsIDw9H3bp1AeR/L5778ssvMWjQIEyePFn9XKxatQrt27fHunXrYGJS/HL4xR27MJ06ddK4/cMPP8DGxgYnT55Ez5491fcX97qws7MDADg5OWl8QNC3b1+Ntn/66Sc4Ojri5s2baNiwofr+yZMn4z//+Y/69sKFCzF16lRMmjRJfV/z5s0BAI6OjgD+7Q0rSnFtPD/mc+7u7li4cCFGjx6NtWvXarRTrVo13Lt3r8jjvIw9JUQEGBgB720FrGuKTkI6rsPdizCV8To4ZdWvbj8M8hlULm2pVKpS7e/r66v+v7m5OaysrBAfH1/k/mZmZuqCAwBcXV3V+9vZ2WHo0KEIDAxEr169sHLlSo2hXcVp1qxZgfvWrFmDpk2bwtHRERYWFvjhhx9w//59jX38/PxgZmamvt26dWukp6cXGI71IgMDA403XfXq1YONjQ3Cw8MBAGFhYfj8889hYWGh/hoxYgRiY2ORmZmpflxxz11ERITGMQCgRYsWJXkqXunF4wKa34PCZGVlFfkGfcCAAbCwsIClpSX27NmDDRs2qNsfMWIEDh8+jEePHgHIH/o2dOhQ9RyjiIiIAuf04u1nz57h8ePHaNu2rcY+bdu2VT/XQ4cORWhoKLy9vTFx4kQcPnxYvV9oaChq1KihLkheFhYWhk2bNml8nwIDA6FUKhEdHV3k8/FccccuzJMnTzBixAh4eXnB2toaVlZWSE9PL/CaLO3PFABERkZiwIAB8PDwgJWVlbrH5uW2X/w5iY+Px+PHjxEQEPDKcy1KSdo4evQoAgICUL16dVhaWmLw4MFITEzU+FkA8nvaXr6vOCxKiCifuQMwcAdgVHx3PlFxzOQZeNOKF1Isi1aurTC75exya8/LywsSiQS3bt0q0f6GhoYatyUSSbFLexa2/4uF0MaNG3H27Fm0adMGO3fuRN26dQvMYynMy0PNduzYgWnTpmH48OE4fPgwQkND8eGHHxY7X6S8pKenY/78+QgNDVV/Xbt2DZGRkRpv7kv73L1MKpUWKCJLMnm5tMd1cHBAcnJyodu+/vprhIaGIi4uDnFxcereIQDw9/eHn58ffv75Z1y6dAk3btxQz4EoL02aNEF0dDQWLFiArKws9OvXD++88w6Af4eTFSU9PR2jRo3S+D6FhYUhMjJSo3B+nWMXZsiQIQgNDcXKlStx5swZhIaGwt7evsBr8nVeF7169UJSUhLWr1+P8+fP4/z58wBQoO0Xf05e9fyUxKvaiImJQc+ePeHr64s9e/bg0qVLWLNmTaHZkpKS1L0zJcGihIj+5dwA6PsjV+SiMgl6acIjlZy7lTuWd1hertd8sbOzQ2BgINasWVPohN/KWIrU398fs2fPxpkzZ9CwYUNs27YNQP5QK4WiZKu2BQcHo02bNhg7diz8/f3h6elZ6CTasLAwZGVlqW+fO3cOFhYWcHNzK7LtvLw8hISEqG9HREQgJSVFPdSsSZMmiIiIgKenZ4GvooaEvczb21vjGAAKjOl3dHREXFycRmESGhpaovZLw9/fH7du3Sq0F83FxQWenp5Fvpn86KOPsGnTJmzcuBGdO3fWeF69vb0LnNOLt62srFCtWjUEBwdr7BMcHIz69etr7Ne/f3+sX78eO3fuxJ49e5CUlARfX188fPgQt2/fLjRbkyZNcPPmzUK/T0ZGJVuEo6hjFyY4OBgTJ05E9+7d1ZPCExISSnSc557nevHnIDExEREREfjkk08QEBAAHx+fIovIF1laWsLd3b3Y5X0NDQ2L/Zl7VRuXLl2CUqnE8uXL0apVK9StWxePHz8usF92djaioqLg7+//ytzP8Z0HEWnyDgI6Fz8plKg47e5ehLmB2at3JA3WxtZYE7AGVkZW5d72mjVroFAo0KJFC+zZsweRkZEIDw/HqlWr0Lp163I/3nPR0dGYPXs2zp49i3v37uHw4cOIjIxUv9l3d3dHdHQ0QkNDkZCQUGBlpxd5eXkhJCQEhw4dwu3bt/Hpp58WOlFXLpdj+PDhuHnzJg4cOIDPPvsM48ePL7Z4MDQ0xIQJE3D+/HlcunQJQ4cORatWrdRDj+bOnYuff/4Z8+fPx40bNxAeHo4dO3bgk08+KfFzMWrUKNy6dQszZ87E7du3sWvXLo3VxoD8lbSePn2Kr776ClFRUVizZg3++uuvEh+jpDp27Ij09HTcuHGj1I8dOHAgHj58iPXr16snuD83YcIEbNiwAZs3b0ZkZCQWLlyIq1evaiwhPX36dCxZsgQ7d+5EREQEZs2ahdDQUPX8hRUrVmD79u24desWbt++jd27d8PFxQU2NjZo37492rVrh759++LIkSOIjo7GX3/9pV69a+bMmThz5gzGjx+P0NBQREZGYu/evSWe6F7csQvj5eWFLVu2IDw8HOfPn8egQYNK3VtRq1YtSCQS7N+/H0+fPkV6ejpsbW1hb2+PH374AXfu3MHx48cxZcqUErU3b948LF++HKtWrUJkZCQuX76Mb7/9Vr39ecERFxdXZKFTXBuenp7Izc3Ft99+i7t372LLli347rvvCrRx7tw5GBsbl+r3C4sSIiqo7UTA/33RKUhHGedlo4NlxV55vKoxkBrg6w5fo6ZVxczr8vDwwOXLl9GxY0dMnToVDRs2RJcuXXDs2LECqyOVJzMzM9y6dUu9DPHIkSMxbtw4jBo1CkD+ZN6goCB07NgRjo6O2L59e5FtjRo1Cv/5z3/Qv39/tGzZEomJiRg7dmyB/QICAuDl5YV27dqhf//+eOutt165RK6ZmRlmzpyJgQMHom3btrCwsMDOnTvV2wMDA7F//34cPnwYzZs3R6tWrfD111+/ciL0i2rXro3//ve/+PXXX+Hr64t169apV996fp0MHx8frF27FmvWrIGfnx8uXLigXsmsPNnb26NPnz6lvrgdkH+dlL59+8LCwqLAleUHDRqE2bNnY9q0aeqhUEOHDtUY4jZx4kRMmTIFU6dORaNGjXDw4EH1UslA/if1X331FZo1a4bmzZsjJiYGBw4cUBeVe/bsQfPmzTFgwADUr18fM2bMUH/y7+vri5MnT+L27dt488034e/vj7lz575yUv9zrzr2yzZs2IDk5GQ0adIEgwcPxsSJE+Hk5FSq57N69erqhRScnZ3VBfSOHTtw6dIlNGzYEB9//LF6gYRXGTJkCL755husXbsWDRo0QM+ePdUrmwHA8uXLceTIEbi5uRXZi1FcG35+flixYgWWLFmChg0bYuvWrYUum7x9+3YMGjRIY37Xq0hUpZ0BR0T6QZEL7BgIRBY/0Y+oMCc822KCouiJxaTp8zafl/mK7ZQ/UTklJaXAFauLs2nTJkyePLnSrqj9okWLFuG7774rdhJ+Rbl69Sq6dOmCqKgoWFhYlOqxAQEBaNCgQYkujtelSxe4uLhgy5YtrxuVdExCQoJ6uGLt2rVL/DguCUxEhZMZAv1+Bn55B7h3WnQa0jFtoy/Cso4n0nI5v+RVZrWYxYJET6xduxbNmzeHvb09goODsXTp0hIPLSpvvr6+WLJkCaKjo9GoUaMSPSY5ORknTpzAiRMnCiz/CuQvI/zdd98hMDAQMpkM27dvx9GjR4u9ZgpVPTExMVi7dm2pChKARQkRFcfQNH9Frs1vAY8vv3p/on8YKuQIsKiN35OviY6i1aY1m1ZuS/+S9ns+zyIpKQk1a9bE1KlTMXt2+a20VlqlXTnL398fycnJWLJkCby9vQtsl0gkOHDgABYtWoTs7Gx4e3tjz5496Ny5czklJl3QrFmzQpf0fhUO3yKiV8tMAjb1AOJvik5COiTYoyVGq0p2TQp99HHTjzGs4bBX70hEpAc40Z2IXs3MDvhgH+BQ8JMxoqK0jLkEWyNr0TG00kT/iSxIiIhewKKEiErGwhEY8gdg7yU6CekIA2UeOptXzGpSumys31iM8B0hOgYRkVZhUUJEJWfpnF+Y2PGK3VQyQUnxoiNolZG+IzGm8RjRMYiItA6LEiIqHStXYOh+wN5TdBLSAc3uXYKjiZ3oGFphWMNhmOA/QXQMIiKtxKJEB8XExEAikSA0NLTMbW3YsAFdu3YteygdsmnTJo2rs86bNw+NGzcWlqcyHTx4EI0bN4ZSqSxbQ1bVgGGHgepNyycYVVlSlRJdTGuIjiHckPpD8HHTj0XHICLSWqUuSuLi4jBp0iR4enrCxMQEzs7OaNu2LdatW4fMzMxyDdehQwdMnjy5XNusCtzc3BAbG4uGDRuWqZ3s7Gx8+umn+Oyzz9T3zZs3DxKJBBKJBAYGBnB3d8fHH3+M9PSqe62BadOm4dixY6JjVIqgoCAYGhq+1lV8CzC3B4bsB7z0q6il0gt6+kh0BGEkkODjph9jWvPyvyo3EVFVUqqi5O7du/D398fhw4fxxRdf4MqVKzh79ixmzJiB/fv34+jRoxWVk14gk8ng4uICA4OyXWbmv//9L6ysrNC2bVuN+xs0aIDY2FjExMRgyZIl+OGHHzB16tQyHUubWVhYwN7eXnSMSjN06NASXYW3RIzMgPe2A415nQUqWuMHoXAxdRQdo9IZSY3wVbuvuMoWEVEJlKooGTt2LAwMDBASEoJ+/frBx8cHHh4eePvtt/Hnn3+iV69e6n1TUlLw0UcfwdHREVZWVujUqRPCwsLU258PmdmyZQvc3d1hbW2N9957D2lpaQDy3zidPHkSK1euVH9yHxMTAwA4efIkWrRoAWNjY7i6umLWrFnIy8tTt52Tk4OJEyfCyckJJiYmeOONN3Dx4sViz83d3R0LFizAgAEDYG5ujurVq2PNmjUa+5T1nAAgLS0NgwYNgrm5OVxdXfH1118X6BGSSCT4/fffNY5tY2ODTZs2ASg4fOvEiROQSCQ4duwYmjVrBjMzM7Rp0wYRERHFnvOOHTs0vmfPGRgYwMXFBTVq1ED//v0xaNAg7Nu3DyqVCp6enli2bJnG/qGhoZBIJLhz5w4A4NatW3jjjTdgYmKC+vXr4+jRowXO6dq1a+jUqRNMTU1hb2+PkSNHavTGnDhxAi1atIC5uTlsbGzQtm1b3Lt3T739jz/+QPPmzWFiYgIHBwf06fPv1ZBzcnIwbdo0VK9eHebm5mjZsiVOnDhR5PPw8vCtVx37Rc+/Fzt27ECbNm1gYmKChg0b4uTJk+p9FAoFhg8fjtq1a8PU1BTe3t5YuXKlRjtDhw5F7969MX/+fPXra/To0ZDL5ep93N3d8c0332g8rnHjxpg3b5769ooVK9CoUSOYm5vDzc0NY8eOLdDL1atXL4SEhCAqKqrI56RUZAZA77XAG1PKpz2qciRQIdDYVXSMSmVtbI31XdcjqHaQ6ChERDqhxEVJYmIiDh8+jHHjxsHc3LzQfSQSifr/7777LuLj4/HXX3/h0qVLaNKkCQICApCUlKTeJyoqCr///jv279+P/fv34+TJk1i8eDEAYOXKlWjdujVGjBiB2NhYxMbGws3NDY8ePUL37t3RvHlzhIWFYd26ddiwYQMWLlyobnfGjBnYs2cPNm/ejMuXL8PT0xOBgYEaxy7M0qVL4efnhytXrmDWrFmYNGkSjhw5Um7nBABTpkxBcHAw9u3bhyNHjuDUqVO4fLl8rpQ9Z84cLF++HCEhITAwMMCwYcV/Onf69OkSXXHT1NQUcrkcEokEw4YNw8aNGzW2b9y4Ee3atYOnpycUCgV69+4NMzMznD9/Hj/88APmzJmjsX9GRgYCAwNha2uLixcvYvfu3Th69CjGjx8PAMjLy0Pv3r3Rvn17XL16FWfPnsXIkSPVr68///wTffr0Qffu3XHlyhUcO3YMLVq0ULc/fvx4nD17Fjt27MDVq1fx7rvvIigoCJGRka8811cduyjTp0/H1KlTceXKFbRu3Rq9evVCYmIiAECpVKJGjRrYvXs3bt68iblz5+L//u//sGvXLo02jh07hvDwcJw4cQLbt2/Hr7/+ivnz578y84ukUilWrVqFGzduYPPmzTh+/DhmzJihsU/NmjXh7OyMU6dOlartV+r8GdBtKSDhVDUqKCi+8MK+KqphUQO/dPsFTZybiI5CRKQzSjz+586dO1CpVPD21rx4moODA7KzswEA48aNw5IlS3D69GlcuHAB8fHxMDY2BgAsW7YMv//+O/773/9i5MiRAPLfrG3atAmWlpYAgMGDB+PYsWNYtGgRrK2tYWRkBDMzM7i4uKiPt3btWri5uWH16tWQSCSoV68eHj9+jJkzZ2Lu3LnIysrCunXrsGnTJnTr1g0AsH79ehw5cgQbNmzA9OnTizzHtm3bYtasWQCAunXrIjg4GF9//TW6dOlSLueUlpaGzZs3Y9u2bQgICACQ/4a+WrVqJf02FGvRokVo3749AGDWrFno0aMHsrOzYWJiUmDflJQUpKamvvLYly5dwrZt29CpUycA+Z/oz507FxcuXECLFi2Qm5uLbdu2qXtPjhw5gqioKJw4cUL9fVu0aBG6dOmibnPbtm3Izs7Gzz//rC5wV69ejV69emHJkiUwNDREamoqevbsiTp18pee9fHx0TjP9957T+MNu5+fHwDg/v372LhxI+7fv68+t2nTpuHgwYPYuHEjvvjii2LP99mzZ8Ueuyjjx49H3759AQDr1q3DwYMHsWHDBsyYMQOGhoYaWWvXro2zZ89i165d6Nevn/p+IyMj/PTTTzAzM0ODBg3w+eefY/r06ViwYAGk0pK90X+xx83d3R0LFy7E6NGjsXbtWo39qlWrVmTvT5m0HJl/PZNfRwGKnPJvn3RWw0fX4NagBR5kxomOUqF8HXyxqtMq2Jvqz5BQIqLyUOaPNC9cuIDQ0FA0aNAAOTn5b0LCwsKQnp4Oe3t7WFhYqL+io6M1hoy4u7ur37wDgKurK+Lji1/TPjw8HK1bt9b45Lpt27ZIT0/Hw4cPERUVhdzcXI15EoaGhmjRogXCw8OLbbt169YFbj9/THmc0927d5Gbm6vxqb61tXWBQu91+fr6ahwXQJHPZ1ZWFgAUWrBcu3YNFhYWMDU1RYsWLdC6dWusXr0aQP6b2R49euCnn34CkD+MKicnB++++y4AICIiAm5ubhqF5IvnC+R/D/38/DR63Nq2bQulUomIiAjY2dlh6NChCAwMRK9evbBy5UrExsaq9w0NDVUXdYVlVygUqFu3rsb36eTJkyUarvSqYxflxdeOgYEBmjVrpvF6W7NmDZo2bQpHR0dYWFjghx9+wP379zXa8PPzg5mZmUab6enpePDgwSuP/9zRo0cREBCA6tWrw9LSEoMHD0ZiYmKBRShMTU3LfWEKtQZ9gPf3AKZcBpY0BRpV7Xklndw6YUPgBhYkRESvocQ9JZ6enpBIJAXmKXh4eADIf5PzXHp6OlxdXQsdx//iUqyGhoYa2yQSSdmXKq0glXlOEokEKpVK477c3NxXPu7FYz8v2oo6tr29PSQSCZKTkwts8/b2xr59+2BgYIBq1arByMhIY/tHH32EwYMH4+uvv8bGjRvRv39/jTfT5WHjxo2YOHEiDh48iJ07d+KTTz7BkSNH0KpVK43X2svS09Mhk8lw6dIlyGQyjW0WFhZlPvbr2LFjB6ZNm4bly5ejdevWsLS0xNKlS3H+/PlStSOVSot9XcTExKBnz54YM2YMFi1aBDs7O5w+fRrDhw+HXC7X+B4lJSXB0bEC3yDWfhMYdRLYORiIDa2445BOCYq9ix+L/vHVae/7vI/pzadDyuGLRESvpcS/Pe3t7dGlSxesXr0aGRkZxe7bpEkTxMXFwcDAAJ6enhpfDg4OJQ5nZGQEhUKhcZ+Pjw/Onj2r8eYsODgYlpaWqFGjBurUqQMjIyMEBwert+fm5uLixYuoX79+scc7d+5cgdvPh+6Uxzl5eHjA0NBQY9J9amoqbt++rbGfo6OjxqfzkZGR5f6ptpGREerXr4+bN28Wus3T0xPu7u4FChIA6N69O8zNzdXDlF6cu+Lt7Y0HDx7gyZMn6vteXmTAx8cHYWFhGq+j4OBgSKVSjV4jf39/zJ49G2fOnEHDhg2xbds2APk9QkUt4evv7w+FQoH4+PgC36cXe29epahjF+XF105eXh4uXbqkfu0EBwejTZs2GDt2LPz9/eHp6Vlor01YWJi6B+t5mxYWFnBzcwNQ8HXx7NkzREdHq29funQJSqUSy5cvR6tWrVC3bl08fvy4wHGys7MRFRUFf3//Ej4br8mmJjD8MNDkg4o9DukM77hw1DavLjpGuTKSGmFOyzmY2WImCxIiojIo1W/QtWvXIi8vD82aNcPOnTsRHh6OiIgI/PLLL7h165b6k+nOnTujdevW6N27Nw4fPoyYmBicOXMGc+bMQUhISImP5+7ujvPnzyMmJgYJCQlQKpUYO3YsHjx4gAkTJuDWrVvYu3cvPvvsM0yZMgVSqRTm5uYYM2YMpk+fjoMHD+LmzZsYMWIEMjMzMXz48GKPFxwcjK+++gq3b9/GmjVrsHv3bkyaNKnczsnS0hJDhgzB9OnT8ffff+PGjRsYPnw4pFKpxnC0Tp06YfXq1bhy5QpCQkIwevToAj0w5SEwMBCnT58u9eNkMhmGDh2K2bNnw8vLS2PoUpcuXVCnTh0MGTIEV69eRXBwMD755BMA//beDBo0CCYmJhgyZAiuX7+Ov//+GxMmTMDgwYPh7OyM6OhozJ49G2fPnsW9e/dw+PBhREZGqt/kf/bZZ9i+fTs+++wzhIeH49q1a1iyZAmA/LlAgwYNwgcffIBff/0V0dHRuHDhAr788kv8+eefrzy3Vx27KGvWrMFvv/2GW7duYdy4cUhOTlYXa15eXggJCcGhQ4dw+/ZtfPrpp4WuBieXyzF8+HDcvHkTBw4cwGeffYbx48er55N06tQJW7ZswalTp3Dt2jUMGTJEozfI09MTubm5+Pbbb3H37l1s2bIF3333XYHjnDt3DsbGxgWGK1YIA2PgrW+Bt1YDBgWHCpL+CTKoOsP6alrWxJbuW/BevfdERyEi0nmlKkrq1KmDK1euoHPnzpg9ezb8/PzQrFkzfPvtt5g2bRoWLFgAIP/N54EDB9CuXTt8+OGHqFu3Lt577z3cu3cPzs7OJT7etGnTIJPJUL9+fTg6OuL+/fuoXr06Dhw4gAsXLsDPzw+jR4/G8OHD1W98AWDx4sXo27cvBg8ejCZNmuDOnTs4dOgQbG1tiz3e1KlTERISAn9/fyxcuBArVqxAYGBguZ7TihUr0Lp1a/Ts2ROdO3dG27Zt4ePjozG3Y/ny5XBzc8Obb76JgQMHYtq0aeU+PAoAhg8fjgMHDiA1NfW1HiuXy/Hhhx9q3C+TyfD7778jPT0dzZs3x0cffaRefev5OZqZmeHQoUNISkpC8+bN8c477yAgIEA9b8XMzAy3bt1C3759UbduXYwcORLjxo3DqFGjAORfVHP37t3Yt28fGjdujE6dOuHChQvqDBs3bsQHH3yAqVOnwtvbG71798bFixdRs2bNV57Xq45dlMWLF2Px4sXw8/PD6dOnsW/fPnUP2qhRo/Cf//wH/fv3R8uWLZGYmIixY8cWaCMgIABeXl5o164d+vfvj7feektjud/Zs2ejffv26NmzJ3r06IHevXurJ+MD+XNSVqxYgSVLlqBhw4bYunUrvvzyywLH2b59OwYNGlQhr6kiNRkMDDuU33tCei3o8e1X76QDurl3w65eu1DfvvgeeCIiKhmJ6uVB6nrK3d0dkydPrvQryGdkZKB69epYvnz5K3tyKsK7776LJk2aYPbs2aV63KlTpxAQEIAHDx68sigLDg7GG2+8gTt37mi8ia4KYmJiULt2bVy5ckXjWielNXToUKSkpBS4Pk15S0hIgLe3N0JCQlC7du0KPVahMpOAX0cCd468el+qsvo2egO30++/ekctZCIzwcwWM/FO3XdERyEiqlLKdklwKrUrV67g1q1baNGiBVJTU/H5558DAN5++20heZYuXYo//vijxPvn5OTg6dOnmDdvHt59991CC5LffvsNFhYW8PLywp07dzBp0iS0bdu2yhUkuigmJgZr164VU5AAgJkdMHAX8L+vgJNLAJV2LmxBFStIagVd7C+pbV0by9ovQ13buqKjEBFVOZyVJ8CyZcvg5+eHzp07IyMjA6dOnSrVAgDlyd3dHRMmTCjx/tu3b0etWrWQkpKCr776qtB90tLSMG7cONSrVw9Dhw5F8+bNsXfv3vKKTGXQrFkz9O/fX2wIqRToMAsY8gdg6y42CwkR9LDgAhva7q06b2FHjx0sSIiIKgiHbxGROPIM4Mhc4OIGAPxVpE/e82uPG8+iX72jYKYGppjTcg7e9hTTm01EpC/YU0JE4hiZAz2WA0P2cRK8nglSVeJCC6+pmXMz7Oq5iwUJEVElYE8JEWmHnHTg8CfApY2ik1AliLV1Q6CNFCot7CGzNrbG1KZT0duzt8Zy7UREVHFYlBCRdok6DuybCKQ+EJ2EKthgv44IfVbwQqIida/dHTOaz4C9qb3oKEREeoXDt4hIu9TpBIw5AzQZAoCfUldlQUpj0RHUaljUwHedv8OSdktYkBARCcCeEiLSXg9DgIOzgIcXRSehCvDUygWdHUygFLg0tIHEAIMbDMZYv7EwMTB59QOIiKhCsCghIu2mUgHXdgNH5wHPHolOQ+VsWOMAXEyNFHLsRg6N8Fnrz+Bt5y3k+ERE9C9ePJGItJtEAvj2A+r1BIJX5n/lZYlOReUkKE+Gyu4HczJ1wujGo9HXqy+kEo5iJiLSBuwpISLdkvoQOPIZcP2/opNQOUgyd0CAsxXyVHkVfiwbYxsMbzgc79V7j0O1iIi0DIsSItJNDy4Af80EHl8WnYTKaJR/F5xJiaiw9s0MzDC4/mAMbTAUFkYWFXYcIiJ6fSxKiEh3qVRAxAHgf8tYnOiw3+p3xtys2+XerpHUCP28+2GE7wjYmdiVe/tERFR+WJQQUdVw51h+cXL/jOgkVEqppjboWM0eucrccmlPJpHhrTpvYYzfGLhauJZLm0REVLFYlBBR1RITDJxaln8RRtIZ4/0DcTIlvExtGEgM0KVWF4xpPAa1rWuXUzIiIqoMXH2LiKoW97b5X48u5fecRPwFgJ+9aLvA7FycfM3H2hrb4p2676C/d384mzuXay4iIqoc7CkhoqrtyQ0geBVw83cgL1t0GipChrEl2ru5IEeRU+LHeNt6Y5DPIHT36A5jmfZcHZ6IiEqPRQkR6YesZCBsB3BpE/D0lug0VIiPm3TD0eQbxe4jk8jQ0a0jBvkMQjOXZpWUjIiIKhqLEiLSP/fP5RcnN37nhRi1yEHv9pgujy50m5WRFfrW7YsB3gM4eZ2IqApiUUJE+isrGQjb+U/vSdkmWVPZZRmZoX0tN2T9UyjKJDK0qtYKPWr3QOdanWFqYCo4IRERVRQWJUREAHD/PHBtF3DrTyAtVnQavTWjSXc8kknR3aM7gtyDYG9qLzoSERFVAhYlREQvUqmAhyHArT+A8D+ApLuiE+mHav5A/beR26APDG3dRachIqJKxqKEiKg4T24A4fvzC5Qn10SnqTqkBkD1ZoBPT8DnLcC2luhEREQkEIsSIqKSSo7JL07uHM0f7sVJ8qXj6AN4dAA82gO12gImVqITERGRlmBRQkT0OvLkwKMQIPoUEHMKeHiR10F5mVWNf4uQ2u0BS17YkIiICseihIioPOTJgdjQ/OWGH5zP/8p4KjpV5TEwAZx8AJdG+fNDarcH7OuITkVERDqCRQkRUUVJuQ/E38pfbvj5v09vA7kZopOVjaltfvHh4vvPVyPAoS4gMxCdjIiIdBSLEiKiyqRSASn3gKcRQHx4/tXln0bkL0OcHg+oFKIT5jNzAKxraH7Z1ckvQGzcRKcjIqIqhkUJEZG2UCqBzEQgPQ5IfwKkPcn/9/lX2hMgJw1QyAFFDqDIBfL++VeRk3+/SqnZpoEpYGQGGJkDRhb5/xqa/ft/I3PAwvmF4sMNsK4OGPJChUREVHlYlBARVSVKRX6holLmFx9SqehEREREr8SihIiIiIiIhOJHaEREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERERERCsSghIiIiIiKhWJQQEREREZFQLEqIiIiIiEgoFiVERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERERERCsSghIiIiIiKhWJQQEREREZFQLEqIiIiIiEgoFiVERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREL9P6MtWHxUCA+9AAAAAElFTkSuQmCC", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAyUAAAGFCAYAAADjF1xYAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAaqdJREFUeJzt3XdYU2f/BvA7CXvvoaKIIOIAcY/WhQquVl9brVqr1bpn3f5srVZttY5W62hrrVrrfm2rtdZdfRUnKrgQEcEJIlN2IMnvD2pqZAgynoTcn+vi0uScPOc+IUC+ecaRqFQqFYiIiIiIiASRig5ARERERET6jUUJEREREREJxaKEiIiIiIiEYlFCRERERERCsSghIiIiIiKhWJQQEREREZFQLEqIiIiIiEgoFiVERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERCzZs3D40bNy7x/jExMZBIJAgNDQUAnDhxAhKJBCkpKRWST9t06NABkydPLnM7iYmJcHJyQkxMTJnb0iUSiQS///47gIKvpcrwOsd8+Xvu7u6Ob775plxztWrVCnv27CnXNolKg0UJERGVq7Nnz0Imk6FHjx6Vcrw2bdogNjYW1tbWr93Gpk2bIJFIIJFIIJVKUaNGDXz44YeIj48vx6Tl49dff8WCBQvK3M6iRYvw9ttvw93dHcC/b5aff9nb26Nr1664cuVKmY+lrdzc3BAbG4uGDRuKjlIqFy9exMiRI8u1zU8++QSzZs2CUqks13aJSopFCRERlasNGzZgwoQJ+N///ofHjx9X+PGMjIzg4uICiURSpnasrKwQGxuLhw8fYv369fjrr78wePDgckpZfuzs7GBpaVmmNjIzM7FhwwYMHz68wLajR48iNjYWhw4dQnp6Orp161Zle6FkMhlcXFxgYGAgOkqpODo6wszMrFzb7NatG9LS0vDXX3+Va7tEJcWihIiIyk16ejp27tyJMWPGoEePHti0aVOBfRYv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EVUlSkV+oaJS5hcfUqnoRERERK/EooSIiIiIiITiR2hERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERERERCsSghIiIiIiKhWJQQEREREZFQLEqIiIiIiEgoFiVERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERERERC/T+jLVh8VAgPvQAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -747,7 +753,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -801,7 +807,26 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "# Drop any rows that has missing (NA) values\n", + "df = df.dropna()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Part of preparing data for a machine learning task is splitting it into subsets for training and testing to ensure that the solution is not overfitting. By default, BQML will automatically manage splitting the data for you. However, BQML also supports manually splitting out your training data.\n", + "\n", + "Performing a manual data split can be done with `bigframes.ml.model_selection.train_test_split` like so:" + ] + }, + { + "cell_type": "code", + "execution_count": 18, "metadata": {}, "outputs": [ { @@ -809,39 +834,30 @@ "output_type": "stream", "text": [ "\n", - " X shape: (334, 6)\n", - " y shape: (334, 1)\n", + " df_train shape: (267, 7)\n", + " df_test shape: (67, 7)\n", "\n" ] } ], "source": [ - "# Drop any rows that has missing (NA) values\n", - "df = df.dropna()\n", + "from bigframes.ml.model_selection import train_test_split\n", "\n", - "# Isolate input features and output variable into DataFrames\n", - "X = df[['island', 'culmen_length_mm', 'culmen_depth_mm', 'flipper_length_mm', 'sex', 'species']]\n", - "y = df[['body_mass_g']]\n", "\n", - "# Print the shapes of features and label\n", + "# This will split df into test and training sets, with 20% of the rows in the test set,\n", + "# and the rest in the training set\n", + "df_train, df_test = train_test_split(df, test_size=0.2)\n", + "\n", + "# Show the shape of the data after the split\n", "print(f\"\"\"\n", - " X shape: {X.shape}\n", - " y shape: {y.shape}\n", + " df_train shape: {df_train.shape}\n", + " df_test shape: {df_test.shape}\n", "\"\"\")" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Part of preparing data for a machine learning task is splitting it into subsets for training and testing to ensure that the solution is not overfitting. By default, BQML will automatically manage splitting the data for you. However, BQML also supports manually splitting out your training data.\n", - "\n", - "Performing a manual data split can be done with `bigframes.ml.model_selection.train_test_split` like so:" - ] - }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 19, "metadata": {}, "outputs": [ { @@ -850,7 +866,7 @@ "text": [ "\n", " X_train shape: (267, 6)\n", - " X_test shape: (67, 6)\n", + " X_test shape: (67, 7)\n", " y_train shape: (267, 1)\n", " y_test shape: (67, 1)\n", "\n" @@ -858,13 +874,31 @@ } ], "source": [ - "from bigframes.ml.model_selection import train_test_split\n", + "# Isolate input features and output variable into DataFrames\n", + "X_train = df_train[[\n", + " 'island',\n", + " 'culmen_length_mm',\n", + " 'culmen_depth_mm',\n", + " 'flipper_length_mm',\n", + " 'sex',\n", + " 'species',\n", + "]]\n", + "y_train = df_train[['body_mass_g']]\n", "\n", - "# This will split X and y into test and training sets, with 20% of the rows in the test set,\n", - "# and the rest in the training set\n", - "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)\n", + "X_test = df_test[[\n", + " 'island',\n", + " 'culmen_length_mm',\n", + " 'culmen_depth_mm',\n", + " 'flipper_length_mm',\n", + " 'sex',\n", + " 'species',\n", + " # Include the actual body_mass_g so that we can compare with the predicted\n", + " # without a join.\n", + " 'body_mass_g'\n", + "]]\n", + "y_test = df_test[['body_mass_g']]\n", "\n", - "# Show the shape of the data after the split\n", + "# Print the shapes of features and label\n", "print(f\"\"\"\n", " X_train shape: {X_train.shape}\n", " X_test shape: {X_test.shape}\n", @@ -885,7 +919,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 20, "metadata": {}, "outputs": [ { @@ -901,7 +935,7 @@ " ('linreg', LinearRegression(fit_intercept=False))])" ] }, - "execution_count": 18, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -939,7 +973,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 21, "metadata": {}, "outputs": [ { @@ -970,80 +1004,86 @@ "
\n", " \n", " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", + " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", + " \n", " \n", " \n", "
\n", " \n", - " \"Colab Run in Colab\n", + " \"Colab Run in Colab\n", " \n", " \n", " \n", - " \"GitHub\n", + " \"GitHub\n", " View on GitHub\n", " \n", "
0## BigQuery: A serverless data warehouse for l...[{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob...BigQuery is a serverless, highly scalable, and...<NA>What is BigQuery?
1## BQML: BigQuery Machine Learning\n", - "\n", - "BQML (BigQ...[{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob...BQML stands for **BigQuery Machine Learning**....<NA>What is BQML?
2I'll do my best to provide a comprehensive and...[{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob...BigQuery DataFrames is a Python client library...<NA>What is BigQuery DataFrame?
\n", " \n", - " \"Colab Run in Colab\n", + " \"Colab Run in Colab\n", " \n", " \n", " \n", - " \"GitHub\n", + " \"GitHub\n", " View on GitHub\n", " \n", "
198Gentoo penguin (Pygoscelis papua)Biscoe43.313.4209.04400.00Adelie Penguin (Pygoscelis adeliae)Dream36.618.4184.03475.0FEMALE
2351Adelie Penguin (Pygoscelis adeliae)Torgersen35.119.4193.04200.0Dream39.819.1184.04650.0MALE
317Chinstrap penguin (Pygoscelis antarctica)2Adelie Penguin (Pygoscelis adeliae)Dream45.418.7188.03525.0FEMALE40.918.9184.03900.0MALE
1173Chinstrap penguin (Pygoscelis antarctica)Dream48.517.5191.03400.0MALE46.517.9192.03500.0FEMALE
159Chinstrap penguin (Pygoscelis antarctica)4Adelie Penguin (Pygoscelis adeliae)Dream45.619.4194.03525.037.316.8192.03000.0FEMALE
flipper_length_mmsexspeciesbody_mass_g
37-18640.71825603271.548077Biscoe44.515.7217.0.Gentoo penguin (Pygoscelis papua)37.918.6172.0FEMALEAdelie Penguin (Pygoscelis adeliae)3150.0
2453109.962252Dream33.116.1178.013224.661209Biscoe37.716.0183.0FEMALEAdelie Penguin (Pygoscelis adeliae)3075.0
2673372.443434Torgersen41.117.6182.023395.403541Biscoe34.518.1187.0FEMALEAdelie Penguin (Pygoscelis adeliae)2900.0
2803341.376012Torgersen36.617.8185.0FEMALE33943.436439Biscoe40.118.9188.0MALEAdelie Penguin (Pygoscelis adeliae)4300.0
403310.17893743986.662895Biscoe37.617.0185.0FEMALE41.418.6191.0MALEAdelie Penguin (Pygoscelis adeliae)3700.0
\n", "" ], "text/plain": [ - " predicted_body_mass_g island culmen_length_mm culmen_depth_mm \\\n", - "37 -18640.718256 Biscoe 44.5 15.7 \n", - "245 3109.962252 Dream 33.1 16.1 \n", - "267 3372.443434 Torgersen 41.1 17.6 \n", - "280 3341.376012 Torgersen 36.6 17.8 \n", - "40 3310.178937 Biscoe 37.6 17.0 \n", + " predicted_body_mass_g island culmen_length_mm culmen_depth_mm \\\n", + "0 3271.548077 Biscoe 37.9 18.6 \n", + "1 3224.661209 Biscoe 37.7 16.0 \n", + "2 3395.403541 Biscoe 34.5 18.1 \n", + "3 3943.436439 Biscoe 40.1 18.9 \n", + "4 3986.662895 Biscoe 41.4 18.6 \n", "\n", - " flipper_length_mm sex species \n", - "37 217.0 . Gentoo penguin (Pygoscelis papua) \n", - "245 178.0 FEMALE Adelie Penguin (Pygoscelis adeliae) \n", - "267 182.0 FEMALE Adelie Penguin (Pygoscelis adeliae) \n", - "280 185.0 FEMALE Adelie Penguin (Pygoscelis adeliae) \n", - "40 185.0 FEMALE Adelie Penguin (Pygoscelis adeliae) " + " flipper_length_mm sex species body_mass_g \n", + "0 172.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 3150.0 \n", + "1 183.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 3075.0 \n", + "2 187.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 2900.0 \n", + "3 188.0 MALE Adelie Penguin (Pygoscelis adeliae) 4300.0 \n", + "4 191.0 MALE Adelie Penguin (Pygoscelis adeliae) 3700.0 " ] }, - "execution_count": 19, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } @@ -1070,7 +1110,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 22, "metadata": {}, "outputs": [ { @@ -1105,12 +1145,12 @@ " \n", " \n", " 0\n", - " 582.272638\n", - " 8337651.200465\n", - " 0.004989\n", - " 193.446297\n", - " -11.273389\n", - " -11.091156\n", + " 231.914252\n", + " 78873.600421\n", + " 0.005172\n", + " 178.724985\n", + " 0.890549\n", + " 0.890566\n", " \n", " \n", "\n", @@ -1118,22 +1158,22 @@ "[1 rows x 6 columns in total]" ], "text/plain": [ - " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - "0 582.272638 8337651.200465 0.004989 \n", + " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", + " 231.914252 78873.600421 0.005172 \n", "\n", - " median_absolute_error r2_score explained_variance \n", - "0 193.446297 -11.273389 -11.091156 \n", + " median_absolute_error r2_score explained_variance \n", + " 178.724985 0.890549 0.890566 \n", "\n", "[1 rows x 6 columns]" ] }, - "execution_count": 20, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "pipeline.score(X_test, y_test)" + "pipeline.score(X_test.drop(columns=[\"body_mass_g\"]), y_test)" ] }, { @@ -1145,16 +1185,16 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 23, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "-11.273389374372979" + "np.float64(0.8905492944632485)" ] }, - "execution_count": 21, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } @@ -1162,7 +1202,7 @@ "source": [ "from bigframes.ml.metrics import r2_score\n", "\n", - "r2_score(y_test, y_pred[\"predicted_body_mass_g\"])" + "r2_score(y_pred['body_mass_g'], y_pred[\"predicted_body_mass_g\"])" ] }, { @@ -1187,7 +1227,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 24, "metadata": {}, "outputs": [ { @@ -1241,17 +1281,17 @@ "[3 rows x 1 columns]" ] }, - "execution_count": 22, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# df = bpd.DataFrame(\n", - "# {\n", - "# \"prompt\": [\"What is BigQuery?\", \"What is BQML?\", \"What is BigQuery DataFrames?\"],\n", - "# })\n", - "# df" + "df = bpd.DataFrame(\n", + " {\n", + " \"prompt\": [\"What is BigQuery?\", \"What is BQML?\", \"What is BigQuery DataFrames?\"],\n", + " })\n", + "df" ] }, { @@ -1260,114 +1300,18 @@ "source": [ "### Generate responses\n", "\n", - "Here we will use the [`GeminiTextGenerator`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) LLM to answer the questions. Read the API documentation for all the model versions supported via the `model_name` param." + "Here we will use the [`GeminiTextGenerator`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) LLM to answer the questions. Read the [GeminiTextGenerator API documentation](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) for all the model versions supported via the `model_name` param." ] }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 25, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shobs/code/bigframes/bigframes/core/__init__.py:114: PreviewWarning: Interpreting JSON column(s) as pyarrow.large_string. This behavior may change in future versions.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ml_generate_text_llm_resultml_generate_text_rai_resultml_generate_text_statusprompt
0## BigQuery: Your Data Warehouse in the Cloud\n", - "...[{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob...What is BigQuery?
1## BQML - BigQuery Machine Learning\n", - "\n", - "BQML stan...[{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob...What is BQML?
2## BigQuery DataFrames\n", - "\n", - "BigQuery DataFrames is...[{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob...What is BigQuery DataFrames?
\n", - "

3 rows × 4 columns

\n", - "
[3 rows x 4 columns in total]" - ], - "text/plain": [ - " ml_generate_text_llm_result \\\n", - "0 ## BigQuery: Your Data Warehouse in the Cloud\n", - "... \n", - "1 ## BQML - BigQuery Machine Learning\n", - "\n", - "BQML stan... \n", - "2 ## BigQuery DataFrames\n", - "\n", - "BigQuery DataFrames is... \n", - "\n", - " ml_generate_text_rai_result ml_generate_text_status \\\n", - "0 [{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob... \n", - "1 [{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob... \n", - "2 [{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"prob... \n", - "\n", - " prompt \n", - "0 What is BigQuery? \n", - "1 What is BQML? \n", - "2 What is BigQuery DataFrames? \n", - "\n", - "[3 rows x 4 columns]" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# from bigframes.ml.llm import GeminiTextGenerator\n", "\n", - "# model = GeminiTextGenerator()\n", + "# model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")\n", "\n", "# pred = model.predict(df)\n", "# pred" @@ -1382,49 +1326,13 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 26, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "## BigQuery DataFrames\n", - "\n", - "BigQuery DataFrames is a Python library that allows you to interact with BigQuery data using the familiar Pandas API. This means you can use all the powerful tools and methods from the Pandas library to explore, analyze, and transform your BigQuery data, without needing to learn a new language or API.\n", - "\n", - "Here are some of the key benefits of using BigQuery DataFrames:\n", - "\n", - "* **Ease of use:** If you're already familiar with Pandas, you can start using BigQuery DataFrames with minimal learning curve.\n", - "* **Speed and efficiency:** BigQuery DataFrames leverages the power of BigQuery to perform complex operations on large datasets efficiently.\n", - "* **Flexibility:** You can use BigQuery DataFrames for a wide range of tasks, including data exploration, analysis, cleaning, and transformation.\n", - "* **Integration with other tools:** BigQuery DataFrames integrates seamlessly with other Google Cloud tools like Colab and Vertex AI, allowing you to build end-to-end data analysis pipelines.\n", - "\n", - "Here are some of the key features of BigQuery DataFrames:\n", - "\n", - "* **Support for most Pandas operations:** You can use most of the DataFrame methods you're familiar with, such as `groupby`, `filter`, `sort_values`, and `apply`.\n", - "* **Automatic schema inference:** BigQuery DataFrames automatically infers the schema of your data, so you don't need to manually specify it.\n", - "* **Efficient handling of large datasets:** BigQuery DataFrames pushes computations to BigQuery, which allows you to work with large datasets without running out of memory.\n", - "* **Support for both public and private datasets:** You can use BigQuery DataFrames to access both public and private datasets stored in BigQuery.\n", - "\n", - "## Getting Started with BigQuery DataFrames\n", - "\n", - "Getting started with BigQuery DataFrames is easy. You just need to install the library and configure your authentication. Once you're set up, you can start using it to interact with your BigQuery data.\n", - "\n", - "Here are some resources to help you get started:\n", - "\n", - "* **Documentation:** https://cloud.google.com/bigquery/docs/reference/libraries/bigquery-dataframe\n", - "* **Quickstart:** https://cloud.google.com/bigquery/docs/reference/libraries/bigquery-dataframe-python-quickstart\n", - "* **Tutorials:** https://cloud.google.com/bigquery/docs/tutorials/bq-dataframe-pandas-tutorial\n", - "\n", - "## Conclusion\n", - "\n", - "BigQuery DataFrames is a powerful tool that can help you get the most out of your BigQuery data. If you're looking for a way to easily analyze and transform your BigQuery data using the familiar Pandas API, then BigQuery DataFrames is a great option.\n" - ] - } - ], + "outputs": [], "source": [ - "# print(pred.loc[2][\"ml_generate_text_llm_result\"])" + "# import IPython.display\n", + "\n", + "# IPython.display.Markdown(pred.loc[2][\"ml_generate_text_llm_result\"])" ] }, { @@ -1443,7 +1351,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 27, "metadata": {}, "outputs": [], "source": [ @@ -1491,7 +1399,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.10.16" } }, "nbformat": 4, diff --git a/notebooks/getting_started/getting_started_bq_dataframes.ipynb b/notebooks/getting_started/getting_started_bq_dataframes.ipynb index c5deeef1c5..fa88cf65bb 100644 --- a/notebooks/getting_started/getting_started_bq_dataframes.ipynb +++ b/notebooks/getting_started/getting_started_bq_dataframes.ipynb @@ -29,18 +29,18 @@ "id": "JAPoU8Sm5E6e" }, "source": [ - "# Get started with BigQuery DataFrames\n", + "# BigQuery DataFrames Quickstart Guide\n", "\n", "\n", "\n", " \n", " \n", @@ -67,7 +67,7 @@ "source": [ "**_NOTE_**: This notebook has been tested in the following environment:\n", "\n", - "* Python version = 3.10" + "* Python version = 3.12" ] }, { @@ -78,31 +78,17 @@ "source": [ "## Overview\n", "\n", - "Use this notebook to get started with BigQuery DataFrames, including setup, installation, and basic tutorials.\n", - "\n", - "BigQuery DataFrames provides a Pythonic DataFrame and machine learning (ML) API powered by the BigQuery engine.\n", - "\n", - "* `bigframes.pandas` provides a pandas-like API for analytics.\n", - "* `bigframes.ml` provides a scikit-learn-like API for ML.\n", - "\n", - "Learn more about [BigQuery DataFrames](https://cloud.google.com/python/docs/reference/bigframes/latest)." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "d975e698c9a4" - }, - "source": [ - "### Objective\n", - "\n", - "In this tutorial, you learn how to install BigQuery DataFrames, load data into a BigQuery DataFrames DataFrame, and inspect and manipulate the data using pandas and a custom Python function, running at BigQuery scale.\n", + "In this guide, you learn how to install BigQuery DataFrames, load data into a BigQuery DataFrames DataFrame, and inspect and manipulate the data using pandas and a custom Python function, running at BigQuery scale.\n", "\n", "The steps include:\n", "\n", + "- Installing the BigQuery Dataframes package.\n", + "- Setting up the environment.\n", "- Creating a BigQuery DataFrames DataFrame: Access data from a local CSV to create a BigQuery DataFrames DataFrame.\n", "- Inspecting and manipulating data: Use pandas to perform data cleaning and preparation on the DataFrame.\n", - "- Deploying a custom function: Deploy a [remote function ](https://cloud.google.com/bigquery/docs/remote-functions)that runs a scalar Python function at BigQuery scale." + "- Deploying a custom function: Deploy a [remote function ](https://cloud.google.com/bigquery/docs/remote-functions)that runs a scalar Python function at BigQuery scale.\n", + "\n", + "You can learn more about [BigQuery DataFrames](https://cloud.google.com/python/docs/reference/bigframes/latest)." ] }, { @@ -160,110 +146,100 @@ "name": "stdout", "output_type": "stream", "text": [ - "Requirement already satisfied: bigframes in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (0.25.0)\n", - "Requirement already satisfied: cloudpickle>=2.0.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (3.0.0)\n", - "Requirement already satisfied: fsspec>=2023.3.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (2024.2.0)\n", - "Requirement already satisfied: gcsfs>=2023.3.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (2024.2.0)\n", - "Requirement already satisfied: geopandas>=0.12.2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (0.14.3)\n", - "Requirement already satisfied: google-auth<3.0dev,>=2.15.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (2.28.2)\n", - "Requirement already satisfied: google-cloud-bigquery>=3.10.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (3.19.0)\n", - "Requirement already satisfied: google-cloud-functions>=1.12.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (1.16.3)\n", - "Requirement already satisfied: google-cloud-bigquery-connection>=1.12.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (1.15.3)\n", - "Requirement already satisfied: google-cloud-iam>=2.12.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (2.14.3)\n", - "Requirement already satisfied: google-cloud-resource-manager>=1.10.3 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (1.12.3)\n", - "Requirement already satisfied: google-cloud-storage>=2.0.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (2.15.0)\n", - "Requirement already satisfied: ibis-framework<9.0.0dev,>=8.0.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (8.0.0)\n", - "Requirement already satisfied: pandas<2.1.4,>=1.5.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (2.1.3)\n", - "Requirement already satisfied: pydata-google-auth>=1.8.2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (1.8.2)\n", - "Requirement already satisfied: requests>=2.27.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (2.31.0)\n", - "Requirement already satisfied: scikit-learn>=1.2.2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (1.4.1.post1)\n", - "Requirement already satisfied: sqlalchemy<3.0dev,>=1.4 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (2.0.28)\n", - "Requirement already satisfied: sqlglot<=20.11,>=20.8.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (20.11.0)\n", - "Requirement already satisfied: tabulate>=0.9 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (0.9.0)\n", - "Requirement already satisfied: ipywidgets>=7.7.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (8.1.2)\n", - "Requirement already satisfied: humanize>=4.6.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (4.9.0)\n", - "Requirement already satisfied: matplotlib>=3.7.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from bigframes) (3.8.3)\n", - "Requirement already satisfied: aiohttp!=4.0.0a0,!=4.0.0a1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from gcsfs>=2023.3.0->bigframes) (3.9.3)\n", - "Requirement already satisfied: decorator>4.1.2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from gcsfs>=2023.3.0->bigframes) (5.1.1)\n", - "Requirement already satisfied: google-auth-oauthlib in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from gcsfs>=2023.3.0->bigframes) (1.2.0)\n", - "Requirement already satisfied: fiona>=1.8.21 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from geopandas>=0.12.2->bigframes) (1.9.6)\n", - "Requirement already satisfied: packaging in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from geopandas>=0.12.2->bigframes) (24.0)\n", - "Requirement already satisfied: pyproj>=3.3.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from geopandas>=0.12.2->bigframes) (3.6.1)\n", - "Requirement already satisfied: shapely>=1.8.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from geopandas>=0.12.2->bigframes) (2.0.3)\n", - "Requirement already satisfied: cachetools<6.0,>=2.0.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-auth<3.0dev,>=2.15.0->bigframes) (5.3.3)\n", - "Requirement already satisfied: pyasn1-modules>=0.2.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-auth<3.0dev,>=2.15.0->bigframes) (0.3.0)\n", - "Requirement already satisfied: rsa<5,>=3.1.4 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-auth<3.0dev,>=2.15.0->bigframes) (4.9)\n", - "Requirement already satisfied: google-api-core!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0dev,>=1.34.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0dev,>=1.34.1->google-cloud-bigquery>=3.10.0->google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (2.17.1)\n", - "Requirement already satisfied: google-cloud-core<3.0.0dev,>=1.6.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery>=3.10.0->google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (2.4.1)\n", - "Requirement already satisfied: google-resumable-media<3.0dev,>=0.6.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery>=3.10.0->google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (2.7.0)\n", - "Requirement already satisfied: python-dateutil<3.0dev,>=2.7.2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery>=3.10.0->google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (2.9.0.post0)\n", - "Requirement already satisfied: proto-plus<2.0.0dev,>=1.22.3 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery-connection>=1.12.0->bigframes) (1.23.0)\n", - "Requirement already satisfied: protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0dev,>=3.19.5 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery-connection>=1.12.0->bigframes) (4.25.3)\n", - "Requirement already satisfied: grpc-google-iam-v1<1.0.0dev,>=0.12.4 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery-connection>=1.12.0->bigframes) (0.13.0)\n", - "Requirement already satisfied: google-cloud-bigquery-storage<3.0.0dev,>=2.6.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (2.24.0)\n", - "Requirement already satisfied: grpcio<2.0dev,>=1.47.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (1.62.1)\n", - "Requirement already satisfied: pyarrow>=3.0.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (15.0.1)\n", - "Requirement already satisfied: db-dtypes<2.0.0dev,>=0.3.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (1.2.0)\n", - "Requirement already satisfied: google-crc32c<2.0dev,>=1.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-cloud-storage>=2.0.0->bigframes) (1.5.0)\n", - "Requirement already satisfied: atpublic<5,>=2.3 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (4.0)\n", - "Requirement already satisfied: bidict<1,>=0.22.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (0.23.1)\n", - "Requirement already satisfied: multipledispatch<2,>=0.6 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (1.0.0)\n", - "Requirement already satisfied: numpy<2,>=1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (1.26.4)\n", - "Requirement already satisfied: parsy<3,>=2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (2.1)\n", - "Requirement already satisfied: pyarrow-hotfix<1,>=0.4 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (0.6)\n", - "Requirement already satisfied: pytz>=2022.7 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (2024.1)\n", - "Requirement already satisfied: rich<14,>=12.4.4 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (13.7.1)\n", - "Requirement already satisfied: toolz<1,>=0.11 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (0.12.1)\n", - "Requirement already satisfied: typing-extensions<5,>=4.3.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (4.10.0)\n", - "Requirement already satisfied: comm>=0.1.3 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipywidgets>=7.7.1->bigframes) (0.2.2)\n", - "Requirement already satisfied: ipython>=6.1.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipywidgets>=7.7.1->bigframes) (8.22.2)\n", - "Requirement already satisfied: traitlets>=4.3.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipywidgets>=7.7.1->bigframes) (5.14.2)\n", - "Requirement already satisfied: widgetsnbextension~=4.0.10 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipywidgets>=7.7.1->bigframes) (4.0.10)\n", - "Requirement already satisfied: jupyterlab-widgets~=3.0.10 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipywidgets>=7.7.1->bigframes) (3.0.10)\n", - "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from matplotlib>=3.7.1->bigframes) (1.2.0)\n", - "Requirement already satisfied: cycler>=0.10 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from matplotlib>=3.7.1->bigframes) (0.12.1)\n", - "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from matplotlib>=3.7.1->bigframes) (4.49.0)\n", - "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from matplotlib>=3.7.1->bigframes) (1.4.5)\n", - "Requirement already satisfied: pillow>=8 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from matplotlib>=3.7.1->bigframes) (10.2.0)\n", - "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from matplotlib>=3.7.1->bigframes) (3.1.2)\n", - "Requirement already satisfied: tzdata>=2022.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from pandas<2.1.4,>=1.5.0->bigframes) (2024.1)\n", - "Requirement already satisfied: setuptools in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from pydata-google-auth>=1.8.2->bigframes) (69.2.0)\n", - "Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from requests>=2.27.1->bigframes) (3.3.2)\n", - "Requirement already satisfied: idna<4,>=2.5 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from requests>=2.27.1->bigframes) (3.6)\n", - "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from requests>=2.27.1->bigframes) (2.2.1)\n", - "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from requests>=2.27.1->bigframes) (2024.2.2)\n", - "Requirement already satisfied: scipy>=1.6.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from scikit-learn>=1.2.2->bigframes) (1.12.0)\n", - "Requirement already satisfied: joblib>=1.2.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from scikit-learn>=1.2.2->bigframes) (1.3.2)\n", - "Requirement already satisfied: threadpoolctl>=2.0.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from scikit-learn>=1.2.2->bigframes) (3.3.0)\n", - "Requirement already satisfied: greenlet!=0.4.17 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from sqlalchemy<3.0dev,>=1.4->bigframes) (3.0.3)\n", - "Requirement already satisfied: aiosignal>=1.1.2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs>=2023.3.0->bigframes) (1.3.1)\n", - "Requirement already satisfied: attrs>=17.3.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs>=2023.3.0->bigframes) (23.2.0)\n", - "Requirement already satisfied: frozenlist>=1.1.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs>=2023.3.0->bigframes) (1.4.1)\n", - "Requirement already satisfied: multidict<7.0,>=4.5 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs>=2023.3.0->bigframes) (6.0.5)\n", - "Requirement already satisfied: yarl<2.0,>=1.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs>=2023.3.0->bigframes) (1.9.4)\n", - "Requirement already satisfied: click~=8.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from fiona>=1.8.21->geopandas>=0.12.2->bigframes) (8.1.7)\n", - "Requirement already satisfied: click-plugins>=1.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from fiona>=1.8.21->geopandas>=0.12.2->bigframes) (1.1.1)\n", - "Requirement already satisfied: cligj>=0.5 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from fiona>=1.8.21->geopandas>=0.12.2->bigframes) (0.7.2)\n", - "Requirement already satisfied: six in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from fiona>=1.8.21->geopandas>=0.12.2->bigframes) (1.16.0)\n", - "Requirement already satisfied: googleapis-common-protos<2.0.dev0,>=1.56.2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-api-core!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0dev,>=1.34.1->google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0dev,>=1.34.1->google-cloud-bigquery>=3.10.0->google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (1.63.0)\n", - "Requirement already satisfied: grpcio-status<2.0.dev0,>=1.33.2 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0dev,>=1.34.1->google-cloud-bigquery>=3.10.0->google-cloud-bigquery[bqstorage,pandas]>=3.10.0->bigframes) (1.62.1)\n", - "Requirement already satisfied: requests-oauthlib>=0.7.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from google-auth-oauthlib->gcsfs>=2023.3.0->bigframes) (1.4.0)\n", - "Requirement already satisfied: jedi>=0.16 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.19.1)\n", - "Requirement already satisfied: matplotlib-inline in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.1.6)\n", - "Requirement already satisfied: prompt-toolkit<3.1.0,>=3.0.41 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (3.0.43)\n", - "Requirement already satisfied: pygments>=2.4.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (2.17.2)\n", - "Requirement already satisfied: stack-data in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.6.3)\n", - "Requirement already satisfied: pexpect>4.3 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (4.9.0)\n", - "Requirement already satisfied: pyasn1<0.6.0,>=0.4.6 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from pyasn1-modules>=0.2.1->google-auth<3.0dev,>=2.15.0->bigframes) (0.5.1)\n", - "Requirement already satisfied: markdown-it-py>=2.2.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from rich<14,>=12.4.4->ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (3.0.0)\n", - "Requirement already satisfied: parso<0.9.0,>=0.8.3 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from jedi>=0.16->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.8.3)\n", - "Requirement already satisfied: mdurl~=0.1 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from markdown-it-py>=2.2.0->rich<14,>=12.4.4->ibis-framework<9.0.0dev,>=8.0.0->ibis-framework[bigquery]<9.0.0dev,>=8.0.0->bigframes) (0.1.2)\n", - "Requirement already satisfied: ptyprocess>=0.5 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.7.0)\n", - "Requirement already satisfied: wcwidth in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from prompt-toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.2.13)\n", - "Requirement already satisfied: oauthlib>=3.0.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from requests-oauthlib>=0.7.0->google-auth-oauthlib->gcsfs>=2023.3.0->bigframes) (3.2.2)\n", - "Requirement already satisfied: executing>=1.2.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from stack-data->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (2.0.1)\n", - "Requirement already satisfied: asttokens>=2.1.0 in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from stack-data->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (2.4.1)\n", - "Requirement already satisfied: pure-eval in /usr/local/google/home/swast/envs/bigframes/lib/python3.11/site-packages (from stack-data->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.2.2)\n" + "Requirement already satisfied: bigframes in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (2.17.0)\n", + "Requirement already satisfied: cloudpickle>=2.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (3.1.1)\n", + "Requirement already satisfied: fsspec>=2023.3.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2025.9.0)\n", + "Requirement already satisfied: gcsfs!=2025.5.0,>=2023.3.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2025.9.0)\n", + "Requirement already satisfied: geopandas>=0.12.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.1.1)\n", + "Requirement already satisfied: google-auth<3.0,>=2.15.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.40.3)\n", + "Requirement already satisfied: google-cloud-bigquery>=3.36.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (3.36.0)\n", + "Requirement already satisfied: google-cloud-bigquery-storage<3.0.0,>=2.30.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.33.0)\n", + "Requirement already satisfied: google-cloud-functions>=1.12.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.20.4)\n", + "Requirement already satisfied: google-cloud-bigquery-connection>=1.12.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.18.3)\n", + "Requirement already satisfied: google-cloud-resource-manager>=1.10.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.14.2)\n", + "Requirement already satisfied: google-cloud-storage>=2.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (3.3.1)\n", + "Requirement already satisfied: grpc-google-iam-v1>=0.14.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (0.14.2)\n", + "Requirement already satisfied: numpy>=1.24.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.2.6)\n", + "Requirement already satisfied: pandas>=1.5.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.3.2)\n", + "Requirement already satisfied: pandas-gbq>=0.26.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (0.29.2)\n", + "Requirement already satisfied: pyarrow>=15.0.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (21.0.0)\n", + "Requirement already satisfied: pydata-google-auth>=1.8.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.9.1)\n", + "Requirement already satisfied: requests>=2.27.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.32.5)\n", + "Requirement already satisfied: shapely>=1.8.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.1.1)\n", + "Requirement already satisfied: sqlglot>=23.6.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (27.11.0)\n", + "Requirement already satisfied: tabulate>=0.9 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (0.9.0)\n", + "Requirement already satisfied: ipywidgets>=7.7.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (8.1.7)\n", + "Requirement already satisfied: humanize>=4.6.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (4.13.0)\n", + "Requirement already satisfied: matplotlib>=3.7.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (3.10.6)\n", + "Requirement already satisfied: db-dtypes>=1.4.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.4.3)\n", + "Requirement already satisfied: atpublic<6,>=2.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (5.1)\n", + "Requirement already satisfied: python-dateutil<3,>=2.8.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.9.0.post0)\n", + "Requirement already satisfied: pytz>=2022.7 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2025.2)\n", + "Requirement already satisfied: toolz<2,>=0.11 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.0.0)\n", + "Requirement already satisfied: typing-extensions<5,>=4.5.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (4.15.0)\n", + "Requirement already satisfied: rich<14,>=12.4.4 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (13.9.4)\n", + "Requirement already satisfied: cachetools<6.0,>=2.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-auth<3.0,>=2.15.0->bigframes) (5.5.2)\n", + "Requirement already satisfied: pyasn1-modules>=0.2.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-auth<3.0,>=2.15.0->bigframes) (0.4.2)\n", + "Requirement already satisfied: rsa<5,>=3.1.4 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-auth<3.0,>=2.15.0->bigframes) (4.9.1)\n", + "Requirement already satisfied: google-api-core!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (2.25.1)\n", + "Requirement already satisfied: proto-plus<2.0.0,>=1.22.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (1.26.1)\n", + "Requirement already satisfied: protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (6.32.0)\n", + "Requirement already satisfied: googleapis-common-protos<2.0.0,>=1.56.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-api-core!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (1.70.0)\n", + "Requirement already satisfied: grpcio<2.0.0,>=1.33.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (1.74.0)\n", + "Requirement already satisfied: grpcio-status<2.0.0,>=1.33.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (1.74.0)\n", + "Requirement already satisfied: six>=1.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from python-dateutil<3,>=2.8.2->bigframes) (1.17.0)\n", + "Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests>=2.27.1->bigframes) (3.4.3)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests>=2.27.1->bigframes) (3.10)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests>=2.27.1->bigframes) (2.5.0)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests>=2.27.1->bigframes) (2025.8.3)\n", + "Requirement already satisfied: markdown-it-py>=2.2.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from rich<14,>=12.4.4->bigframes) (4.0.0)\n", + "Requirement already satisfied: pygments<3.0.0,>=2.13.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from rich<14,>=12.4.4->bigframes) (2.19.2)\n", + "Requirement already satisfied: pyasn1>=0.1.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from rsa<5,>=3.1.4->google-auth<3.0,>=2.15.0->bigframes) (0.6.1)\n", + "Requirement already satisfied: packaging>=24.2.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from db-dtypes>=1.4.2->bigframes) (25.0)\n", + "Requirement already satisfied: aiohttp!=4.0.0a0,!=4.0.0a1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from gcsfs!=2025.5.0,>=2023.3.0->bigframes) (3.12.15)\n", + "Requirement already satisfied: decorator>4.1.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from gcsfs!=2025.5.0,>=2023.3.0->bigframes) (5.2.1)\n", + "Requirement already satisfied: google-auth-oauthlib in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from gcsfs!=2025.5.0,>=2023.3.0->bigframes) (1.2.2)\n", + "Requirement already satisfied: aiohappyeyeballs>=2.5.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (2.6.1)\n", + "Requirement already satisfied: aiosignal>=1.4.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (1.4.0)\n", + "Requirement already satisfied: async-timeout<6.0,>=4.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (5.0.1)\n", + "Requirement already satisfied: attrs>=17.3.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (25.3.0)\n", + "Requirement already satisfied: frozenlist>=1.1.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (1.7.0)\n", + "Requirement already satisfied: multidict<7.0,>=4.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (6.6.4)\n", + "Requirement already satisfied: propcache>=0.2.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (0.3.2)\n", + "Requirement already satisfied: yarl<2.0,>=1.17.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (1.20.1)\n", + "Requirement already satisfied: pyogrio>=0.7.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from geopandas>=0.12.2->bigframes) (0.11.1)\n", + "Requirement already satisfied: pyproj>=3.5.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from geopandas>=0.12.2->bigframes) (3.7.1)\n", + "Requirement already satisfied: google-cloud-core<3.0.0,>=2.4.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery>=3.36.0->google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (2.4.3)\n", + "Requirement already satisfied: google-resumable-media<3.0.0,>=2.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery>=3.36.0->google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (2.7.2)\n", + "Requirement already satisfied: google-crc32c<2.0dev,>=1.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-resumable-media<3.0.0,>=2.0.0->google-cloud-bigquery>=3.36.0->google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (1.7.1)\n", + "Requirement already satisfied: comm>=0.1.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (0.2.3)\n", + "Requirement already satisfied: ipython>=6.1.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (8.37.0)\n", + "Requirement already satisfied: traitlets>=4.3.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (5.14.3)\n", + "Requirement already satisfied: widgetsnbextension~=4.0.14 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (4.0.14)\n", + "Requirement already satisfied: jupyterlab_widgets~=3.0.15 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (3.0.15)\n", + "Requirement already satisfied: exceptiongroup in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (1.3.0)\n", + "Requirement already satisfied: jedi>=0.16 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.19.2)\n", + "Requirement already satisfied: matplotlib-inline in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.1.7)\n", + "Requirement already satisfied: pexpect>4.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (4.9.0)\n", + "Requirement already satisfied: prompt_toolkit<3.1.0,>=3.0.41 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (3.0.52)\n", + "Requirement already satisfied: stack_data in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.6.3)\n", + "Requirement already satisfied: wcwidth in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from prompt_toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.2.13)\n", + "Requirement already satisfied: parso<0.9.0,>=0.8.4 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from jedi>=0.16->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.8.5)\n", + "Requirement already satisfied: mdurl~=0.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from markdown-it-py>=2.2.0->rich<14,>=12.4.4->bigframes) (0.1.2)\n", + "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (1.3.2)\n", + "Requirement already satisfied: cycler>=0.10 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (0.12.1)\n", + "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (4.59.2)\n", + "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (1.4.9)\n", + "Requirement already satisfied: pillow>=8 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (11.3.0)\n", + "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (3.2.3)\n", + "Requirement already satisfied: tzdata>=2022.7 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from pandas>=1.5.3->bigframes) (2025.2)\n", + "Requirement already satisfied: setuptools in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from pandas-gbq>=0.26.1->bigframes) (65.5.0)\n", + "Requirement already satisfied: requests-oauthlib>=0.7.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-auth-oauthlib->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (2.0.0)\n", + "Requirement already satisfied: ptyprocess>=0.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.7.0)\n", + "Requirement already satisfied: oauthlib>=3.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests-oauthlib>=0.7.0->google-auth-oauthlib->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (3.3.1)\n", + "Requirement already satisfied: executing>=1.2.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from stack_data->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (2.2.1)\n", + "Requirement already satisfied: asttokens>=2.1.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from stack_data->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (3.0.0)\n", + "Requirement already satisfied: pure-eval in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from stack_data->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.2.3)\n" ] } ], @@ -303,7 +279,7 @@ "id": "BF1j6f9HApxa" }, "source": [ - "## Before you begin\n", + "## Environment setup\n", "\n", "Complete the tasks in this section to set up your environment." ] @@ -351,29 +327,13 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": { "id": "oM1iC_MfAts1" }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Updated property [core/project].\n", - "\n", - "\n", - "To take a quick anonymous survey, run:\n", - " $ gcloud survey\n", - "\n" - ] - } - ], + "outputs": [], "source": [ - "PROJECT_ID = \"\" # @param {type:\"string\"}\n", - "\n", - "# Set the project id\n", - "! gcloud config set project {PROJECT_ID}" + "PROJECT_ID = \"\" # @param {type:\"string\"}" ] }, { @@ -511,7 +471,13 @@ "# It defaults to the location of the first table or query\n", "# passed to read_gbq(). For APIs where a location can't be\n", "# auto-detected, the location defaults to the \"US\" location.\n", - "bpd.options.bigquery.location = REGION" + "bpd.options.bigquery.location = REGION\n", + "\n", + "# Note: By default BigQuery DataFrames emits out BigQuery job metadata via a\n", + "# progress bar. But in this notebook let's disable the progress bar to keep the\n", + "# experience less verbose. If you would like the default behavior, please\n", + "# comment out the following expression. \n", + "bpd.options.display.progress_bar = None" ] }, { @@ -558,7 +524,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": { "id": "Vyex9BQI-BNa" }, @@ -584,21 +550,123 @@ "source": [ "Uncomment and run the following cell to see pandas in action over your new BigQuery DataFrames DataFrame.\n", "\n", - "This code uses regex to filter the DataFrame to include only rows with Wikipedia page titles containing the word \"Google\", sums the total views by page title, and then returns the top 100 results." + "This code uses regex to filter the DataFrame to include only rows with Wikipedia page titles containing the word \"Google\", sums the total views by page title, and then returns the top 10 results." ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": { "id": "XfGq5apK-D_e" }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "
\n", " \n", - " \"Colab Run in Colab\n", + " \"Colab Run in Colab\n", " \n", " \n", " \n", - " \"GitHub\n", + " \"GitHub\n", " View on GitHub\n", " \n", "
\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
titleviews
21911Google1414560
27669Google_Chrome962482
28394Google_Earth383566
29184Google_Maps205089
27251Google_Android99450
33900Google_search97665
31825Google_chrome78399
30204Google_Street_View71580
40798Image:Google_Chrome.png60746
35222Googleplex53848
\n", + "

10 rows × 2 columns

\n", + "[10 rows x 2 columns in total]" + ], + "text/plain": [ + " title views\n", + "21911 Google 1414560\n", + "27669 Google_Chrome 962482\n", + "28394 Google_Earth 383566\n", + "29184 Google_Maps 205089\n", + "27251 Google_Android 99450\n", + "33900 Google_search 97665\n", + "31825 Google_chrome 78399\n", + "30204 Google_Street_View 71580\n", + "40798 Image:Google_Chrome.png 60746\n", + "35222 Googleplex 53848\n", + "\n", + "[10 rows x 2 columns]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# bq_df_sample[bq_df_sample.title.str.contains(r\"[Gg]oogle\")]\\\n", - "# .groupby(['title'], as_index=False)['views'].sum(numeric_only=True)\\\n", - "# .sort_values('views', ascending=False)\\\n", - "# .head(100)" + "# .groupby(['title'], as_index=False)['views'].sum(numeric_only=True)\\\n", + "# .sort_values('views', ascending=False)\\\n", + "# .head(10)" ] }, { @@ -719,20 +787,7 @@ "metadata": { "id": "EDAaIwHpQCDZ" }, - "outputs": [ - { - "data": { - "text/html": [ - "Load job d578c399-e2e5-4f6b-ba28-59d0686a91e7 is DONE. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# If order is not important, use the \"bigquery\" engine to\n", "# allow BigQuery DataFrames to read directly from GCS.\n", @@ -745,7 +800,7 @@ "id": "U-RVfNCu_h_h" }, "source": [ - "Take a look at the first few rows of the DataFrame:" + "Take a look at the rows randomly sampled from the DataFrame:" ] }, { @@ -755,42 +810,6 @@ "id": "_gPD0Zn1Stdb" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job f50a129b-4a51-4c21-b155-ab1e85c1403e is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job b0d65008-f9f1-4fec-8620-42f307390049 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job a83d72e8-0cb8-44e9-ad0b-6fe3726ed1e9 is DONE. 501 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -823,76 +842,73 @@ " \n", " \n", " \n", - " 0\n", + " 41\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 50.5\n", - " 15.9\n", - " 225\n", - " 5400\n", + " 49.8\n", + " 16.8\n", + " 230\n", + " 5700\n", " MALE\n", " \n", " \n", - " 1\n", + " 73\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 45.1\n", - " 14.5\n", + " 46.8\n", + " 16.1\n", " 215\n", - " 5000\n", - " FEMALE\n", + " 5500\n", + " MALE\n", " \n", " \n", - " 2\n", - " Adelie Penguin (Pygoscelis adeliae)\n", - " Torgersen\n", - " 41.4\n", - " 18.5\n", - " 202\n", - " 3875\n", + " 75\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 49.6\n", + " 16.0\n", + " 225\n", + " 5700\n", " MALE\n", " \n", " \n", - " 3\n", + " 93\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " Torgersen\n", - " 38.6\n", - " 17.0\n", - " 188\n", - " 2900\n", + " Biscoe\n", + " 35.5\n", + " 16.2\n", + " 195\n", + " 3350\n", " FEMALE\n", " \n", " \n", - " 4\n", - " Gentoo penguin (Pygoscelis papua)\n", - " Biscoe\n", - " 46.5\n", - " 14.8\n", - " 217\n", - " 5200\n", - " FEMALE\n", + " 299\n", + " Chinstrap penguin (Pygoscelis antarctica)\n", + " Dream\n", + " 52.0\n", + " 18.1\n", + " 201\n", + " 4050\n", + " MALE\n", " \n", " \n", "\n", - "

5 rows × 7 columns

\n", - "[5 rows x 7 columns in total]" + "" ], "text/plain": [ - " species island culmen_length_mm \\\n", - "0 Gentoo penguin (Pygoscelis papua) Biscoe 50.5 \n", - "1 Gentoo penguin (Pygoscelis papua) Biscoe 45.1 \n", - "2 Adelie Penguin (Pygoscelis adeliae) Torgersen 41.4 \n", - "3 Adelie Penguin (Pygoscelis adeliae) Torgersen 38.6 \n", - "4 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 \n", + " species island culmen_length_mm \\\n", + "41 Gentoo penguin (Pygoscelis papua) Biscoe 49.8 \n", + "73 Gentoo penguin (Pygoscelis papua) Biscoe 46.8 \n", + "75 Gentoo penguin (Pygoscelis papua) Biscoe 49.6 \n", + "93 Adelie Penguin (Pygoscelis adeliae) Biscoe 35.5 \n", + "299 Chinstrap penguin (Pygoscelis antarctica) Dream 52.0 \n", "\n", - " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "0 15.9 225 5400 MALE \n", - "1 14.5 215 5000 FEMALE \n", - "2 18.5 202 3875 MALE \n", - "3 17.0 188 2900 FEMALE \n", - "4 14.8 217 5200 FEMALE \n", - "\n", - "[5 rows x 7 columns]" + " culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "41 16.8 230 5700 MALE \n", + "73 16.1 215 5500 MALE \n", + "75 16.0 225 5700 MALE \n", + "93 16.2 195 3350 FEMALE \n", + "299 18.1 201 4050 MALE " ] }, "execution_count": 15, @@ -901,7 +917,7 @@ } ], "source": [ - "df_from_local.head()" + "df_from_local.peek()" ] }, { @@ -966,22 +982,10 @@ "id": "oP1NIAmUBjop" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job 49702108-948c-4a60-a66e-16a3ed6bc102 is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [ - "'swast-scratch.birds.penguins'" + "'bigframes-dev.birds.penguins'" ] }, "execution_count": 17, @@ -991,7 +995,7 @@ ], "source": [ "df_from_local.to_gbq(\n", - " PROJECT_ID + \".\" + DATASET_ID + \".penguins\",\n", + " f\"{PROJECT_ID}.{DATASET_ID}.penguins\",\n", " if_exists=\"replace\",\n", ")" ] @@ -1022,42 +1026,6 @@ "id": "IBuo-d6dWfsA" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job 05a6288d-3774-41d0-9884-6bbb5af28942 is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 45383ce0-0ca1-4c16-9832-739e9d325673 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 2f672140-ddc6-43b6-b79a-318f29bb9239 is DONE. 501 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -1090,76 +1058,73 @@ " \n", " \n", " \n", - " 0\n", + " 79\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 50.5\n", - " 15.9\n", - " 225\n", - " 5400\n", - " MALE\n", + " 43.3\n", + " 14.0\n", + " 208\n", + " 4575\n", + " FEMALE\n", " \n", " \n", - " 1\n", - " Gentoo penguin (Pygoscelis papua)\n", + " 118\n", + " Adelie Penguin (Pygoscelis adeliae)\n", " Biscoe\n", - " 45.1\n", - " 14.5\n", - " 215\n", - " 5000\n", - " FEMALE\n", + " 40.6\n", + " 18.6\n", + " 183\n", + " 3550\n", + " MALE\n", " \n", " \n", - " 2\n", + " 213\n", " Adelie Penguin (Pygoscelis adeliae)\n", " Torgersen\n", - " 41.4\n", - " 18.5\n", - " 202\n", - " 3875\n", + " 42.1\n", + " 19.1\n", + " 195\n", + " 4000\n", " MALE\n", " \n", " \n", - " 3\n", + " 315\n", " Adelie Penguin (Pygoscelis adeliae)\n", " Torgersen\n", - " 38.6\n", - " 17.0\n", - " 188\n", - " 2900\n", + " 38.7\n", + " 19.0\n", + " 195\n", + " 3450\n", " FEMALE\n", " \n", " \n", - " 4\n", - " Gentoo penguin (Pygoscelis papua)\n", - " Biscoe\n", - " 46.5\n", - " 14.8\n", - " 217\n", - " 5200\n", + " 338\n", + " Chinstrap penguin (Pygoscelis antarctica)\n", + " Dream\n", + " 40.9\n", + " 16.6\n", + " 187\n", + " 3200\n", " FEMALE\n", " \n", " \n", "\n", - "

5 rows × 7 columns

\n", - "[5 rows x 7 columns in total]" + "" ], "text/plain": [ - " species island culmen_length_mm \\\n", - "0 Gentoo penguin (Pygoscelis papua) Biscoe 50.5 \n", - "1 Gentoo penguin (Pygoscelis papua) Biscoe 45.1 \n", - "2 Adelie Penguin (Pygoscelis adeliae) Torgersen 41.4 \n", - "3 Adelie Penguin (Pygoscelis adeliae) Torgersen 38.6 \n", - "4 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 \n", + " species island culmen_length_mm \\\n", + "79 Gentoo penguin (Pygoscelis papua) Biscoe 43.3 \n", + "118 Adelie Penguin (Pygoscelis adeliae) Biscoe 40.6 \n", + "213 Adelie Penguin (Pygoscelis adeliae) Torgersen 42.1 \n", + "315 Adelie Penguin (Pygoscelis adeliae) Torgersen 38.7 \n", + "338 Chinstrap penguin (Pygoscelis antarctica) Dream 40.9 \n", "\n", - " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "0 15.9 225 5400 MALE \n", - "1 14.5 215 5000 FEMALE \n", - "2 18.5 202 3875 MALE \n", - "3 17.0 188 2900 FEMALE \n", - "4 14.8 217 5200 FEMALE \n", - "\n", - "[5 rows x 7 columns]" + " culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "79 14.0 208 4575 FEMALE \n", + "118 18.6 183 3550 MALE \n", + "213 19.1 195 4000 MALE \n", + "315 19.0 195 3450 FEMALE \n", + "338 16.6 187 3200 FEMALE " ] }, "execution_count": 18, @@ -1168,9 +1133,9 @@ } ], "source": [ - "query_or_table = f\"\"\"{PROJECT_ID}.{DATASET_ID}.penguins\"\"\"\n", + "query_or_table = f\"{PROJECT_ID}.{DATASET_ID}.penguins\"\n", "bq_df = bpd.read_gbq(query_or_table)\n", - "bq_df.head()" + "bq_df.peek()" ] }, { @@ -1209,55 +1174,19 @@ "id": "6i6HkFJZa8na" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job 5c454fa1-a01b-4e95-b947-6f02554a8461 is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 2cffe5c7-c0c6-4495-ad67-1f5fb55654fd is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 96b4dead-f526-4be3-b24d-5d7aec99eeeb is DONE. 240 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [ - "0 5400\n", - "1 5000\n", - "2 3875\n", - "3 2900\n", - "4 5200\n", - "5 3725\n", - "6 2975\n", - "7 4150\n", - "8 5300\n", - "9 4150\n", + "133 \n", + "279 3150\n", + "34 3400\n", + "96 3600\n", + "208 3950\n", + "18 3800\n", + "64 2850\n", + "310 3175\n", + "118 3550\n", + "2 3075\n", "Name: body_mass_g, dtype: Int64" ] }, @@ -1267,7 +1196,7 @@ } ], "source": [ - "bq_df[\"body_mass_g\"].head(10)" + "bq_df[\"body_mass_g\"].peek(10)" ] }, { @@ -1286,23 +1215,11 @@ "id": "YKwCW7Nsavap" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job 635d000c-14ca-4ecf-bc32-1527821cba28 is DONE. 2.7 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "name": "stdout", "output_type": "stream", "text": [ - "average_body_mass: 4201.754385964917\n" + "average_body_mass: 4201.754385964914\n" ] } ], @@ -1327,42 +1244,6 @@ "id": "4PyKMR61-Mjy" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job d22d8e48-26a0-4cfb-83fc-3e52b834f487 is DONE. 15.6 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 64fff5f3-7106-4003-9241-a9b09afed781 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job c3d566cc-bed1-4361-96ef-f06956982916 is DONE. 163 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -1425,7 +1306,7 @@ } ], "source": [ - "bq_df[[\"species\", \"body_mass_g\"]].groupby(by=bq_df[\"species\"]).mean(numeric_only=True).head()" + "bq_df[[\"species\", \"body_mass_g\"]].groupby(by=bq_df[\"species\"]).mean(numeric_only=True)" ] }, { @@ -1450,7 +1331,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 22, "metadata": {}, "outputs": [], "source": [ @@ -1468,7 +1349,7 @@ "id": "zjw8toUbHuRD" }, "source": [ - "Running the cell below creates a custom function using the `remote_function` method. This function categorizes a value into one of two buckets: >= 4000 or <4000.\n", + "Running the cell below creates a custom function using the `remote_function` method. This function categorizes a value into one of two buckets: >= 3500 or <3500.\n", "\n", "> Note: Creating a function requires a [BigQuery connection](https://cloud.google.com/bigquery/docs/remote-functions#create_a_remote_function). This code assumes a pre-created connection named `bigframes-default-connection`. If\n", "the connection is not already created, BigQuery DataFrames attempts to create one assuming the [necessary APIs\n", @@ -1479,17 +1360,17 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 23, "metadata": { "id": "rSWTOG-vb2Fc" }, "outputs": [], "source": [ - "@bpd.remote_function([float], str)\n", - "def get_bucket(num):\n", - " if not num: return \"NA\"\n", - " boundary = 4000\n", - " return \"at_or_above_4000\" if num >= boundary else \"below_4000\"" + "@bpd.remote_function(cloud_function_service_account=\"default\")\n", + "def get_bucket(num: float) -> str:\n", + " if not num: return \"NA\"\n", + " boundary = 3500\n", + " return \"at_or_above_3500\" if num >= boundary else \"below_3500\"" ] }, { @@ -1505,7 +1386,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 24, "metadata": { "id": "6ejPXoyEQpWE" }, @@ -1514,8 +1395,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Cloud Function Name projects/swast-scratch/locations/us-central1/functions/bigframes-71a76285da23f28be467ed16826f7276\n", - "Remote Function Name swast-scratch._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bigframes_71a76285da23f28be467ed16826f7276\n" + "Cloud Function Name projects/bigframes-dev/locations/us-central1/functions/bigframes-sessioncf7a5d-aa59468b9d6c757c1256e46c9f71ebe3\n", + "Remote Function Name bigframes-dev._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bigframes_sessioncf7a5d_aa59468b9d6c757c1256e46c9f71ebe3\n" ] } ], @@ -1537,59 +1418,11 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 25, "metadata": { "id": "NxSd9WZFcIji" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job 9925acd1-d1e7-4746-90d6-4ce8c2ca30a8 is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 2f10b5cd-80bb-4697-9c61-b7848ce15c81 is DONE. 39.6 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 29266b33-3945-44c0-943b-3d6365b9cc7a is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 19ecf156-8940-4c02-b20e-3e52e18c7239 is DONE. 396 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -1617,84 +1450,81 @@ " \n", " \n", " \n", - " 0\n", - " 5400\n", - " at_or_above_4000\n", + " 133\n", + " <NA>\n", + " NA\n", " \n", " \n", - " 1\n", - " 5000\n", - " at_or_above_4000\n", + " 279\n", + " 3150\n", + " below_3500\n", " \n", " \n", - " 2\n", - " 3875\n", - " below_4000\n", + " 34\n", + " 3400\n", + " below_3500\n", " \n", " \n", - " 3\n", - " 2900\n", - " below_4000\n", + " 96\n", + " 3600\n", + " at_or_above_3500\n", " \n", " \n", - " 4\n", - " 5200\n", - " at_or_above_4000\n", + " 208\n", + " 3950\n", + " at_or_above_3500\n", " \n", " \n", - " 5\n", - " 3725\n", - " below_4000\n", + " 18\n", + " 3800\n", + " at_or_above_3500\n", " \n", " \n", - " 6\n", - " 2975\n", - " below_4000\n", + " 64\n", + " 2850\n", + " below_3500\n", " \n", " \n", - " 7\n", - " 4150\n", - " at_or_above_4000\n", + " 310\n", + " 3175\n", + " below_3500\n", " \n", " \n", - " 8\n", - " 5300\n", - " at_or_above_4000\n", + " 118\n", + " 3550\n", + " at_or_above_3500\n", " \n", " \n", - " 9\n", - " 4150\n", - " at_or_above_4000\n", + " 2\n", + " 3075\n", + " below_3500\n", " \n", " \n", "\n", - "

10 rows × 2 columns

\n", - "[10 rows x 2 columns in total]" + "" ], "text/plain": [ - " body_mass_g body_mass_bucket\n", - "0 5400 at_or_above_4000\n", - "1 5000 at_or_above_4000\n", - "2 3875 below_4000\n", - "3 2900 below_4000\n", - "4 5200 at_or_above_4000\n", - "5 3725 below_4000\n", - "6 2975 below_4000\n", - "7 4150 at_or_above_4000\n", - "8 5300 at_or_above_4000\n", - "9 4150 at_or_above_4000\n", - "\n", - "[10 rows x 2 columns]" + " body_mass_g body_mass_bucket\n", + "133 NA\n", + "279 3150 below_3500\n", + "34 3400 below_3500\n", + "96 3600 at_or_above_3500\n", + "208 3950 at_or_above_3500\n", + "18 3800 at_or_above_3500\n", + "64 2850 below_3500\n", + "310 3175 below_3500\n", + "118 3550 at_or_above_3500\n", + "2 3075 below_3500" ] }, - "execution_count": 24, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } ], "source": [ "bq_df = bq_df.assign(body_mass_bucket=bq_df['body_mass_g'].apply(get_bucket))\n", - "bq_df[['body_mass_g', 'body_mass_bucket']].head(10)" + "bq_df[['body_mass_g', 'body_mass_bucket']].peek(10)" ] }, { @@ -1726,7 +1556,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 26, "metadata": {}, "outputs": [], "source": [ @@ -1736,7 +1566,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 27, "metadata": { "id": "sx_vKniMq9ZX" }, @@ -1753,7 +1583,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 28, "metadata": { "id": "_dTCXvCxtPw9" }, @@ -1769,7 +1599,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 29, "metadata": { "id": "EDAIIfcpwNOF" }, @@ -1781,7 +1611,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 30, "metadata": { "id": "QwumLUKmVpuH" }, @@ -1799,7 +1629,8 @@ "toc_visible": true }, "kernelspec": { - "display_name": "Python 3", + "display_name": "venv", + "language": "python", "name": "python3" }, "language_info": { @@ -1812,7 +1643,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.15" + "version": "3.10.16" } }, "nbformat": 4, diff --git a/notebooks/getting_started/ml_fundamentals_bq_dataframes.ipynb b/notebooks/getting_started/ml_fundamentals_bq_dataframes.ipynb index d95447f7e5..3370e94713 100644 --- a/notebooks/getting_started/ml_fundamentals_bq_dataframes.ipynb +++ b/notebooks/getting_started/ml_fundamentals_bq_dataframes.ipynb @@ -35,12 +35,12 @@ "\n", " \n", " \n", - " \"Colab Run in Colab\n", + " \"Colab Run in Colab\n", " \n", " \n", " \n", " \n", - " \"GitHub\n", + " \"GitHub\n", " View on GitHub\n", " \n", " \n", diff --git a/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb b/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb new file mode 100644 index 0000000000..87ef9f6e96 --- /dev/null +++ b/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb @@ -0,0 +1,1741 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "_cell_guid": "b1076dfc-b9ad-4769-8c92-a6c4dae69d19", + "_uuid": "8f2839f25d086af736a60e9eeb907d3b93b6e0e5" + }, + "source": [ + "# BigQuery DataFrames (BigFrames) AI Forecast\n", + "\n", + "This notebook is adapted from https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb to work in the Kaggle runtime. It introduces forecasting with GenAI Foundation Model with BigFrames AI.\n", + "\n", + "Install the bigframes package and upgrade other packages that are already included in Kaggle but have versions incompatible with bigframes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "trusted": true + }, + "outputs": [], + "source": [ + "%pip install --upgrade bigframes google-cloud-automl google-cloud-translate google-ai-generativelanguage tensorflow " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Important:** restart the kernel by going to \"Run -> Restart & clear cell outputs\" before continuing.\n", + "\n", + "Configure bigframes to use your GCP project. First, go to \"Add-ons -> Google Cloud SDK\" and click the \"Attach\" button. Then," + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:16:10.449828Z", + "iopub.status.busy": "2025-08-18T19:16:10.449563Z", + "iopub.status.idle": "2025-08-18T19:16:10.618943Z", + "shell.execute_reply": "2025-08-18T19:16:10.617631Z", + "shell.execute_reply.started": "2025-08-18T19:16:10.449803Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "from kaggle_secrets import UserSecretsClient\n", + "user_secrets = UserSecretsClient()\n", + "user_credential = user_secrets.get_gcloud_credential()\n", + "user_secrets.set_tensorflow_credential(user_credential)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:20:00.851870Z", + "iopub.status.busy": "2025-08-18T19:20:00.851472Z", + "iopub.status.idle": "2025-08-18T19:20:00.858175Z", + "shell.execute_reply": "2025-08-18T19:20:00.857098Z", + "shell.execute_reply.started": "2025-08-18T19:20:00.851842Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "PROJECT = \"swast-scratch\" # replace with your project\n", + "\n", + "\n", + "import bigframes.pandas as bpd\n", + "bpd.options.bigquery.project = PROJECT\n", + "bpd.options.bigquery.ordering_mode = \"partial\" # Optional: partial ordering mode can accelerate executions and save costs" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Create a BigFrames DataFrames from BigQuery public data." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:20:02.255184Z", + "iopub.status.busy": "2025-08-18T19:20:02.254706Z", + "iopub.status.idle": "2025-08-18T19:20:04.754064Z", + "shell.execute_reply": "2025-08-18T19:20:04.752940Z", + "shell.execute_reply.started": "2025-08-18T19:20:02.255149Z" + }, + "trusted": true + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.11/dist-packages/bigframes/core/log_adapter.py:175: TimeTravelCacheWarning: Reading cached table from 2025-08-18 19:19:20.590271+00:00 to avoid\n", + "incompatibilies with previous reads of this table. To read the latest\n", + "version, set `use_cache=False` or close the current session with\n", + "Session.close() or bigframes.pandas.close_session().\n", + " return method(*args, **kwargs)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
trip_idduration_secstart_datestart_station_namestart_station_idend_dateend_station_nameend_station_idbike_numberzip_code...c_subscription_typestart_station_latitudestart_station_longitudeend_station_latitudeend_station_longitudemember_birth_yearmember_genderbike_share_for_all_tripstart_station_geomend_station_geom
02018020921350835967882018-02-09 21:35:08+00:0010th Ave at E 15th St2222018-02-09 21:48:17+00:0010th Ave at E 15th St2223596<NA>...<NA>37.792714-122.2487837.792714-122.248781984MaleYesPOINT (-122.24878 37.79271)POINT (-122.24878 37.79271)
12017081523574224919652017-08-15 23:57:42+00:0010th St at Fallon St2012017-08-16 00:13:48+00:0010th Ave at E 15th St2222491<NA>...<NA>37.797673-122.26299737.792714-122.24878<NA><NA><NA>POINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
22018022816572536325602018-02-28 16:57:25+00:0010th St at Fallon St2012018-02-28 17:06:46+00:0010th Ave at E 15th St2223632<NA>...<NA>37.797673-122.26299737.792714-122.248781984MaleYesPOINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
32017111700460913374972017-11-17 00:46:09+00:0010th St at Fallon St2012017-11-17 00:54:26+00:0010th Ave at E 15th St2221337<NA>...<NA>37.797673-122.26299737.792714-122.24878<NA><NA><NA>POINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
42018022019132312575962018-02-20 19:13:23+00:0010th St at Fallon St2012018-02-20 19:23:19+00:0010th Ave at E 15th St2221257<NA>...<NA>37.797673-122.26299737.792714-122.248781984MaleYesPOINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
520170824232500127913412017-08-24 23:25:00+00:0010th St at Fallon St2012017-08-24 23:47:22+00:0010th Ave at E 15th St2221279<NA>...<NA>37.797673-122.26299737.792714-122.248781969Male<NA>POINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
62018011618004732914892018-01-16 18:00:47+00:0010th St at Fallon St2012018-01-16 18:08:56+00:0010th Ave at E 15th St2223291<NA>...<NA>37.797673-122.26299737.792714-122.248781984MaleYesPOINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
72018040815560118311052018-04-08 15:56:01+00:0013th St at Franklin St3382018-04-08 16:14:26+00:0010th Ave at E 15th St222183<NA>...<NA>37.803189-122.27057937.792714-122.248781987FemaleNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
82018031418570322046192018-03-14 18:57:03+00:0013th St at Franklin St3382018-03-14 19:07:23+00:0010th Ave at E 15th St2222204<NA>...<NA>37.803189-122.27057937.792714-122.248781982OtherNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
92017081920533114907432017-08-19 20:53:31+00:002nd Ave at E 18th St2002017-08-19 21:05:54+00:0010th Ave at E 15th St2221490<NA>...<NA>37.800214-122.2538137.792714-122.24878<NA><NA><NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
102017111818232819603532017-11-18 18:23:28+00:002nd Ave at E 18th St2002017-11-18 18:29:22+00:0010th Ave at E 15th St2221960<NA>...<NA>37.800214-122.2538137.792714-122.248781988Male<NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
112017081020445483912562017-08-10 20:44:54+00:002nd Ave at E 18th St2002017-08-10 21:05:50+00:0010th Ave at E 15th St222839<NA>...<NA>37.800214-122.2538137.792714-122.24878<NA><NA><NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
122018011716565535045002018-01-17 16:56:55+00:00El Embarcadero at Grand Ave1972018-01-17 17:05:16+00:0010th Ave at E 15th St2223504<NA>...<NA>37.808848-122.2496837.792714-122.248781987MaleNoPOINT (-122.24968 37.80885)POINT (-122.24878 37.79271)
132018011116131013058582018-01-11 16:13:10+00:00Frank H Ogawa Plaza72018-01-11 16:27:28+00:0010th Ave at E 15th St2221305<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
1420180224182655121512352018-02-24 18:26:55+00:00Frank H Ogawa Plaza72018-02-24 18:47:31+00:0010th Ave at E 15th St2221215<NA>...<NA>37.804562-122.27173837.792714-122.248781969MaleNoPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
152018030916214834508572018-03-09 16:21:48+00:00Frank H Ogawa Plaza72018-03-09 16:36:06+00:0010th Ave at E 15th St2223450<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
162018010219322327179142018-01-02 19:32:23+00:00Frank H Ogawa Plaza72018-01-02 19:47:38+00:0010th Ave at E 15th St2222717<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
172018031619102837515642018-03-16 19:10:28+00:00Frank H Ogawa Plaza72018-03-16 19:19:52+00:0010th Ave at E 15th St2223751<NA>...<NA>37.804562-122.27173837.792714-122.248781987MaleNoPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
18201712121524032278542017-12-12 15:24:03+00:00Frank H Ogawa Plaza72017-12-12 15:38:17+00:0010th Ave at E 15th St222227<NA>...<NA>37.804562-122.27173837.792714-122.248781984Male<NA>POINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
192018031314370337249172018-03-13 14:37:03+00:00Grand Ave at Webster St1812018-03-13 14:52:20+00:0010th Ave at E 15th St2223724<NA>...<NA>37.811377-122.26519237.792714-122.248781989MaleNoPOINT (-122.26519 37.81138)POINT (-122.24878 37.79271)
202017120617555934265192017-12-06 17:55:59+00:00Lake Merritt BART Station1632017-12-06 18:04:39+00:0010th Ave at E 15th St2223426<NA>...<NA>37.79732-122.2653237.792714-122.248781986Male<NA>POINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
21201804042100344513662018-04-04 21:00:34+00:00Lake Merritt BART Station1632018-04-04 21:06:41+00:0010th Ave at E 15th St222451<NA>...<NA>37.79732-122.2653237.792714-122.248781987MaleNoPOINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
222018012319071617876262018-01-23 19:07:16+00:00Lake Merritt BART Station1632018-01-23 19:17:43+00:0010th Ave at E 15th St2221787<NA>...<NA>37.79732-122.2653237.792714-122.248781987MaleNoPOINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
232017082710570611579732017-08-27 10:57:06+00:00Lake Merritt BART Station1632017-08-27 11:13:19+00:0010th Ave at E 15th St2221157<NA>...<NA>37.79732-122.2653237.792714-122.24878<NA><NA><NA>POINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
24201709071348372074114342017-09-07 13:48:37+00:00Lake Merritt BART Station1632017-09-07 16:59:12+00:0010th Ave at E 15th St2222074<NA>...<NA>37.79732-122.2653237.792714-122.24878<NA><NA><NA>POINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
\n", + "

25 rows × 21 columns

\n", + "
[1947417 rows x 21 columns in total]" + ], + "text/plain": [ + " trip_id duration_sec start_date \\\n", + "201802092135083596 788 2018-02-09 21:35:08+00:00 \n", + "201708152357422491 965 2017-08-15 23:57:42+00:00 \n", + "201802281657253632 560 2018-02-28 16:57:25+00:00 \n", + "201711170046091337 497 2017-11-17 00:46:09+00:00 \n", + "201802201913231257 596 2018-02-20 19:13:23+00:00 \n", + "201708242325001279 1341 2017-08-24 23:25:00+00:00 \n", + "201801161800473291 489 2018-01-16 18:00:47+00:00 \n", + " 20180408155601183 1105 2018-04-08 15:56:01+00:00 \n", + "201803141857032204 619 2018-03-14 18:57:03+00:00 \n", + "201708192053311490 743 2017-08-19 20:53:31+00:00 \n", + "201711181823281960 353 2017-11-18 18:23:28+00:00 \n", + " 20170810204454839 1256 2017-08-10 20:44:54+00:00 \n", + "201801171656553504 500 2018-01-17 16:56:55+00:00 \n", + "201801111613101305 858 2018-01-11 16:13:10+00:00 \n", + "201802241826551215 1235 2018-02-24 18:26:55+00:00 \n", + "201803091621483450 857 2018-03-09 16:21:48+00:00 \n", + "201801021932232717 914 2018-01-02 19:32:23+00:00 \n", + "201803161910283751 564 2018-03-16 19:10:28+00:00 \n", + " 20171212152403227 854 2017-12-12 15:24:03+00:00 \n", + "201803131437033724 917 2018-03-13 14:37:03+00:00 \n", + "201712061755593426 519 2017-12-06 17:55:59+00:00 \n", + " 20180404210034451 366 2018-04-04 21:00:34+00:00 \n", + "201801231907161787 626 2018-01-23 19:07:16+00:00 \n", + "201708271057061157 973 2017-08-27 10:57:06+00:00 \n", + "201709071348372074 11434 2017-09-07 13:48:37+00:00 \n", + "\n", + " start_station_name start_station_id end_date \\\n", + " 10th Ave at E 15th St 222 2018-02-09 21:48:17+00:00 \n", + " 10th St at Fallon St 201 2017-08-16 00:13:48+00:00 \n", + " 10th St at Fallon St 201 2018-02-28 17:06:46+00:00 \n", + " 10th St at Fallon St 201 2017-11-17 00:54:26+00:00 \n", + " 10th St at Fallon St 201 2018-02-20 19:23:19+00:00 \n", + " 10th St at Fallon St 201 2017-08-24 23:47:22+00:00 \n", + " 10th St at Fallon St 201 2018-01-16 18:08:56+00:00 \n", + " 13th St at Franklin St 338 2018-04-08 16:14:26+00:00 \n", + " 13th St at Franklin St 338 2018-03-14 19:07:23+00:00 \n", + " 2nd Ave at E 18th St 200 2017-08-19 21:05:54+00:00 \n", + " 2nd Ave at E 18th St 200 2017-11-18 18:29:22+00:00 \n", + " 2nd Ave at E 18th St 200 2017-08-10 21:05:50+00:00 \n", + "El Embarcadero at Grand Ave 197 2018-01-17 17:05:16+00:00 \n", + " Frank H Ogawa Plaza 7 2018-01-11 16:27:28+00:00 \n", + " Frank H Ogawa Plaza 7 2018-02-24 18:47:31+00:00 \n", + " Frank H Ogawa Plaza 7 2018-03-09 16:36:06+00:00 \n", + " Frank H Ogawa Plaza 7 2018-01-02 19:47:38+00:00 \n", + " Frank H Ogawa Plaza 7 2018-03-16 19:19:52+00:00 \n", + " Frank H Ogawa Plaza 7 2017-12-12 15:38:17+00:00 \n", + " Grand Ave at Webster St 181 2018-03-13 14:52:20+00:00 \n", + " Lake Merritt BART Station 163 2017-12-06 18:04:39+00:00 \n", + " Lake Merritt BART Station 163 2018-04-04 21:06:41+00:00 \n", + " Lake Merritt BART Station 163 2018-01-23 19:17:43+00:00 \n", + " Lake Merritt BART Station 163 2017-08-27 11:13:19+00:00 \n", + " Lake Merritt BART Station 163 2017-09-07 16:59:12+00:00 \n", + "\n", + " end_station_name end_station_id bike_number zip_code ... \\\n", + "10th Ave at E 15th St 222 3596 ... \n", + "10th Ave at E 15th St 222 2491 ... \n", + "10th Ave at E 15th St 222 3632 ... \n", + "10th Ave at E 15th St 222 1337 ... \n", + "10th Ave at E 15th St 222 1257 ... \n", + "10th Ave at E 15th St 222 1279 ... \n", + "10th Ave at E 15th St 222 3291 ... \n", + "10th Ave at E 15th St 222 183 ... \n", + "10th Ave at E 15th St 222 2204 ... \n", + "10th Ave at E 15th St 222 1490 ... \n", + "10th Ave at E 15th St 222 1960 ... \n", + "10th Ave at E 15th St 222 839 ... \n", + "10th Ave at E 15th St 222 3504 ... \n", + "10th Ave at E 15th St 222 1305 ... \n", + "10th Ave at E 15th St 222 1215 ... \n", + "10th Ave at E 15th St 222 3450 ... \n", + "10th Ave at E 15th St 222 2717 ... \n", + "10th Ave at E 15th St 222 3751 ... \n", + "10th Ave at E 15th St 222 227 ... \n", + "10th Ave at E 15th St 222 3724 ... \n", + "10th Ave at E 15th St 222 3426 ... \n", + "10th Ave at E 15th St 222 451 ... \n", + "10th Ave at E 15th St 222 1787 ... \n", + "10th Ave at E 15th St 222 1157 ... \n", + "10th Ave at E 15th St 222 2074 ... \n", + "\n", + "c_subscription_type start_station_latitude start_station_longitude \\\n", + " 37.792714 -122.24878 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.803189 -122.270579 \n", + " 37.803189 -122.270579 \n", + " 37.800214 -122.25381 \n", + " 37.800214 -122.25381 \n", + " 37.800214 -122.25381 \n", + " 37.808848 -122.24968 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.811377 -122.265192 \n", + " 37.79732 -122.26532 \n", + " 37.79732 -122.26532 \n", + " 37.79732 -122.26532 \n", + " 37.79732 -122.26532 \n", + " 37.79732 -122.26532 \n", + "\n", + " end_station_latitude end_station_longitude member_birth_year \\\n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1969 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1982 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1988 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1969 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1989 \n", + " 37.792714 -122.24878 1986 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 \n", + "\n", + " member_gender bike_share_for_all_trip start_station_geom \\\n", + " Male Yes POINT (-122.24878 37.79271) \n", + " POINT (-122.26300 37.79767) \n", + " Male Yes POINT (-122.26300 37.79767) \n", + " POINT (-122.26300 37.79767) \n", + " Male Yes POINT (-122.26300 37.79767) \n", + " Male POINT (-122.26300 37.79767) \n", + " Male Yes POINT (-122.26300 37.79767) \n", + " Female No POINT (-122.27058 37.80319) \n", + " Other No POINT (-122.27058 37.80319) \n", + " POINT (-122.25381 37.80021) \n", + " Male POINT (-122.25381 37.80021) \n", + " POINT (-122.25381 37.80021) \n", + " Male No POINT (-122.24968 37.80885) \n", + " Male Yes POINT (-122.27174 37.80456) \n", + " Male No POINT (-122.27174 37.80456) \n", + " Male Yes POINT (-122.27174 37.80456) \n", + " Male Yes POINT (-122.27174 37.80456) \n", + " Male No POINT (-122.27174 37.80456) \n", + " Male POINT (-122.27174 37.80456) \n", + " Male No POINT (-122.26519 37.81138) \n", + " Male POINT (-122.26532 37.79732) \n", + " Male No POINT (-122.26532 37.79732) \n", + " Male No POINT (-122.26532 37.79732) \n", + " POINT (-122.26532 37.79732) \n", + " POINT (-122.26532 37.79732) \n", + "\n", + " end_station_geom \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "...\n", + "\n", + "[1947417 rows x 21 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.san_francisco_bikeshare.bikeshare_trips\")\n", + "df" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Preprocess Data\n", + "\n", + "Only take the `start_date` after 2018 and the \"Subscriber\" category as input. `start_date` are truncated to each hour." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:20:44.398876Z", + "iopub.status.busy": "2025-08-18T19:20:44.397712Z", + "iopub.status.idle": "2025-08-18T19:20:44.421504Z", + "shell.execute_reply": "2025-08-18T19:20:44.420509Z", + "shell.execute_reply.started": "2025-08-18T19:20:44.398742Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "df = df[df[\"start_date\"] >= \"2018-01-01\"]\n", + "df = df[df[\"subscriber_type\"] == \"Subscriber\"]\n", + "df[\"trip_hour\"] = df[\"start_date\"].dt.floor(\"h\")\n", + "df = df[[\"trip_hour\", \"trip_id\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Group and count each hour's num of trips." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:20:57.500413Z", + "iopub.status.busy": "2025-08-18T19:20:57.499571Z", + "iopub.status.idle": "2025-08-18T19:21:02.999663Z", + "shell.execute_reply": "2025-08-18T19:21:02.998792Z", + "shell.execute_reply.started": "2025-08-18T19:20:57.500376Z" + }, + "trusted": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job e3df71d2-9248-491a-8e5f-4bb5bfedb686 is DONE. 58.7 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
trip_hournum_trips
02018-01-01 00:00:00+00:0020
12018-01-01 01:00:00+00:0025
22018-01-01 02:00:00+00:0013
32018-01-01 03:00:00+00:0011
42018-01-01 05:00:00+00:004
52018-01-01 06:00:00+00:008
62018-01-01 07:00:00+00:008
72018-01-01 08:00:00+00:0020
82018-01-01 09:00:00+00:0030
92018-01-01 10:00:00+00:0041
102018-01-01 11:00:00+00:0045
112018-01-01 12:00:00+00:0054
122018-01-01 13:00:00+00:0057
132018-01-01 14:00:00+00:0068
142018-01-01 15:00:00+00:0086
152018-01-01 16:00:00+00:0072
162018-01-01 17:00:00+00:0072
172018-01-01 18:00:00+00:0047
182018-01-01 19:00:00+00:0032
192018-01-01 20:00:00+00:0034
202018-01-01 21:00:00+00:0027
212018-01-01 22:00:00+00:0015
222018-01-01 23:00:00+00:006
232018-01-02 00:00:00+00:002
242018-01-02 01:00:00+00:001
\n", + "

25 rows × 2 columns

\n", + "
[2842 rows x 2 columns in total]" + ], + "text/plain": [ + " trip_hour num_trips\n", + "2018-01-01 00:00:00+00:00 20\n", + "2018-01-01 01:00:00+00:00 25\n", + "2018-01-01 02:00:00+00:00 13\n", + "2018-01-01 03:00:00+00:00 11\n", + "2018-01-01 05:00:00+00:00 4\n", + "2018-01-01 06:00:00+00:00 8\n", + "2018-01-01 07:00:00+00:00 8\n", + "2018-01-01 08:00:00+00:00 20\n", + "2018-01-01 09:00:00+00:00 30\n", + "2018-01-01 10:00:00+00:00 41\n", + "2018-01-01 11:00:00+00:00 45\n", + "2018-01-01 12:00:00+00:00 54\n", + "2018-01-01 13:00:00+00:00 57\n", + "2018-01-01 14:00:00+00:00 68\n", + "2018-01-01 15:00:00+00:00 86\n", + "2018-01-01 16:00:00+00:00 72\n", + "2018-01-01 17:00:00+00:00 72\n", + "2018-01-01 18:00:00+00:00 47\n", + "2018-01-01 19:00:00+00:00 32\n", + "2018-01-01 20:00:00+00:00 34\n", + "2018-01-01 21:00:00+00:00 27\n", + "2018-01-01 22:00:00+00:00 15\n", + "2018-01-01 23:00:00+00:00 6\n", + "2018-01-02 00:00:00+00:00 2\n", + "2018-01-02 01:00:00+00:00 1\n", + "...\n", + "\n", + "[2842 rows x 2 columns]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_grouped = df.groupby(\"trip_hour\").count()\n", + "df_grouped = df_grouped.reset_index().rename(columns={\"trip_id\": \"num_trips\"})\n", + "df_grouped" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Make forecastings for next 1 week with DataFrames.ai.forecast API" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:22:58.944068Z", + "iopub.status.busy": "2025-08-18T19:22:58.943589Z", + "iopub.status.idle": "2025-08-18T19:23:11.364356Z", + "shell.execute_reply": "2025-08-18T19:23:11.363152Z", + "shell.execute_reply.started": "2025-08-18T19:22:58.944036Z" + }, + "trusted": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 3f1225a8-b80b-4dfa-a7cf-94b93e7c18c2 is DONE. 68.2 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
forecast_timestampforecast_valueconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundai_forecast_status
02018-04-24 12:00:00+00:00144.5777280.95120.01921169.136247
12018-04-25 00:00:00+00:0054.2155150.9546.839461.591631
22018-04-26 05:00:00+00:008.1405330.95-14.61327230.894339
32018-04-26 14:00:00+00:00198.7449490.95174.982268222.50763
42018-04-27 02:00:00+00:009.918060.95-26.74994846.586069
52018-04-29 03:00:00+00:0032.0633390.95-35.73097899.857656
62018-04-27 04:00:00+00:0025.7571110.958.17803743.336184
72018-04-30 06:00:00+00:0089.8084560.9515.214961164.401952
82018-04-30 02:00:00+00:00-10.5841750.95-60.77202439.603674
92018-04-30 05:00:00+00:0018.1181110.95-40.90213377.138355
102018-04-24 07:00:00+00:00359.0369570.95250.880334467.193579
112018-04-25 10:00:00+00:00227.2720490.95170.918819283.625279
122018-04-27 15:00:00+00:00208.6313630.95188.977435228.285291
132018-04-25 13:00:00+00:00159.7999110.95150.066363169.53346
142018-04-26 12:00:00+00:00190.2269440.95177.898865202.555023
152018-04-24 04:00:00+00:0011.1623380.95-18.58104140.905717
162018-04-24 14:00:00+00:00136.708160.95134.165413139.250907
172018-04-28 21:00:00+00:0065.3088990.9563.00091567.616883
182018-04-29 20:00:00+00:0071.7888490.95-2.49023146.067928
192018-04-30 15:00:00+00:00142.5609440.9541.495553243.626334
202018-04-26 18:00:00+00:00533.7838130.95412.068752655.498875
212018-04-28 03:00:00+00:0025.3797610.9522.56575228.193769
222018-04-30 12:00:00+00:00158.3133850.9579.466457237.160313
232018-04-25 07:00:00+00:00358.7565920.95276.305603441.207581
242018-04-27 22:00:00+00:00103.5890960.9594.45235112.725842
\n", + "

25 rows × 6 columns

\n", + "
[168 rows x 6 columns in total]" + ], + "text/plain": [ + " forecast_timestamp forecast_value confidence_level \\\n", + "2018-04-24 12:00:00+00:00 144.577728 0.95 \n", + "2018-04-25 00:00:00+00:00 54.215515 0.95 \n", + "2018-04-26 05:00:00+00:00 8.140533 0.95 \n", + "2018-04-26 14:00:00+00:00 198.744949 0.95 \n", + "2018-04-27 02:00:00+00:00 9.91806 0.95 \n", + "2018-04-29 03:00:00+00:00 32.063339 0.95 \n", + "2018-04-27 04:00:00+00:00 25.757111 0.95 \n", + "2018-04-30 06:00:00+00:00 89.808456 0.95 \n", + "2018-04-30 02:00:00+00:00 -10.584175 0.95 \n", + "2018-04-30 05:00:00+00:00 18.118111 0.95 \n", + "2018-04-24 07:00:00+00:00 359.036957 0.95 \n", + "2018-04-25 10:00:00+00:00 227.272049 0.95 \n", + "2018-04-27 15:00:00+00:00 208.631363 0.95 \n", + "2018-04-25 13:00:00+00:00 159.799911 0.95 \n", + "2018-04-26 12:00:00+00:00 190.226944 0.95 \n", + "2018-04-24 04:00:00+00:00 11.162338 0.95 \n", + "2018-04-24 14:00:00+00:00 136.70816 0.95 \n", + "2018-04-28 21:00:00+00:00 65.308899 0.95 \n", + "2018-04-29 20:00:00+00:00 71.788849 0.95 \n", + "2018-04-30 15:00:00+00:00 142.560944 0.95 \n", + "2018-04-26 18:00:00+00:00 533.783813 0.95 \n", + "2018-04-28 03:00:00+00:00 25.379761 0.95 \n", + "2018-04-30 12:00:00+00:00 158.313385 0.95 \n", + "2018-04-25 07:00:00+00:00 358.756592 0.95 \n", + "2018-04-27 22:00:00+00:00 103.589096 0.95 \n", + "\n", + " prediction_interval_lower_bound prediction_interval_upper_bound \\\n", + " 120.01921 169.136247 \n", + " 46.8394 61.591631 \n", + " -14.613272 30.894339 \n", + " 174.982268 222.50763 \n", + " -26.749948 46.586069 \n", + " -35.730978 99.857656 \n", + " 8.178037 43.336184 \n", + " 15.214961 164.401952 \n", + " -60.772024 39.603674 \n", + " -40.902133 77.138355 \n", + " 250.880334 467.193579 \n", + " 170.918819 283.625279 \n", + " 188.977435 228.285291 \n", + " 150.066363 169.53346 \n", + " 177.898865 202.555023 \n", + " -18.581041 40.905717 \n", + " 134.165413 139.250907 \n", + " 63.000915 67.616883 \n", + " -2.49023 146.067928 \n", + " 41.495553 243.626334 \n", + " 412.068752 655.498875 \n", + " 22.565752 28.193769 \n", + " 79.466457 237.160313 \n", + " 276.305603 441.207581 \n", + " 94.45235 112.725842 \n", + "\n", + "ai_forecast_status \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "...\n", + "\n", + "[168 rows x 6 columns]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Using all the data except the last week (2842-168) for training. And predict the last week (168).\n", + "result = df_grouped.head(2842-168).ai.forecast(timestamp_column=\"trip_hour\", data_column=\"num_trips\", horizon=168) \n", + "result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 4. Process the raw result and draw a line plot along with the training data" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:27:08.306367Z", + "iopub.status.busy": "2025-08-18T19:27:08.305886Z", + "iopub.status.idle": "2025-08-18T19:27:08.318514Z", + "shell.execute_reply": "2025-08-18T19:27:08.317016Z", + "shell.execute_reply.started": "2025-08-18T19:27:08.306336Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "result = result.sort_values(\"forecast_timestamp\")\n", + "result = result[[\"forecast_timestamp\", \"forecast_value\"]]\n", + "result = result.rename(columns={\"forecast_timestamp\": \"trip_hour\", \"forecast_value\": \"num_trips_forecast\"})\n", + "df_all = bpd.concat([df_grouped, result])\n", + "df_all = df_all.tail(672) # 4 weeks" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Plot a line chart and compare with the actual result." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:27:19.461528Z", + "iopub.status.busy": "2025-08-18T19:27:19.461164Z", + "iopub.status.idle": "2025-08-18T19:27:20.737558Z", + "shell.execute_reply": "2025-08-18T19:27:20.736422Z", + "shell.execute_reply.started": "2025-08-18T19:27:19.461497Z" + }, + "trusted": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABREAAAKnCAYAAAARNgr5AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8pXeV/AAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOz9e7wlV13mjz+rau99ujtN5zZJd6IhBIkDgQAx+IU2jjAQE0JEgSCKjEM0oz95BRnIgMoYSQgIyA9QhKCoEHAQncEvMBi5JEQCSEK4XwQHFIgdzI0Rkk6TdJ+9q9b3j6pVtdbatc/pWmudtfap87xfr37tc+ldp2rvXatqPev5fB4hpZQghBBCCCGEEEIIIYSQBWSpd4AQQgghhBBCCCGEELLcUEQkhBBCCCGEEEIIIYSsCUVEQgghhBBCCCGEEELImlBEJIQQQgghhBBCCCGErAlFREIIIYQQQgghhBBCyJpQRCSEEEIIIYQQQgghhKwJRURCCCGEEEIIIYQQQsiaUEQkhBBCCCGEEEIIIYSsySj1DrhSliVuvfVW3O9+94MQIvXuEEIIIYQQQgghhBCyqZBS4p577sGJJ56ILFvba7hpRcRbb70VJ510UurdIIQQQgghhBBCCCFkU3PLLbfgB3/wB9f8P5tWRLzf/e4HoDrIXbt2Jd4bQgghhBBCCCGEEEI2F/v378dJJ53U6GxrsWlFRFXCvGvXLoqIhBBCCCGEEEIIIYQ4cjitAhmsQgghhBBCCCGEEEIIWROKiIQQQgghhBBCCCGEkDWhiEgIIYQQQgghhBBCCFmTTdsT8XCQUmI2m6EoitS7Qog3eZ5jNBodVp8CQgghhBBCCCGEkJAMVkRcXV3FbbfdhnvvvTf1rhASjB07duCEE07AZDJJvSuEEEIIIYQQQgjZQgxSRCzLEt/61reQ5zlOPPFETCYTurfIpkZKidXVVXznO9/Bt771LZx66qnIMnYjIIQQQgghhBBCSBwGKSKurq6iLEucdNJJ2LFjR+rdISQI27dvx3g8xr/8y79gdXUV27ZtS71LhBBCCCGEEEII2SIM2spEpxYZGvxME0IIIYQQQgghJAVUJAghhBBCCCGEEEIIIWtCEZEQQgghhBBCCCGEELImFBFJUC6//HI88pGPTL0bhBBCCCGEEEIIISQgFBHJujzucY/D85///MP6vy984Qtx3XXXbewOEUIIIYQQQgghhJCoDDKdmcRHSomiKLBz507s3Lkz9e4QQgghhBBCCCGEkIBsGSeilBL3rs6S/JNSHvZ+Pu5xj8Pznvc8/MZv/AaOOeYY7NmzB5dffjkA4Oabb4YQAl/4whea/3/XXXdBCIHrr78eAHD99ddDCIEPfehDOOOMM7B9+3Y8/vGPx5133okPfOADeMhDHoJdu3bhF37hF3Dvvfeuuz8XXnghPvrRj+L1r389hBAQQuDmm29u/s4HPvABnHnmmVhZWcHf//3fz5UzX3jhhXjKU56Cl770pTjuuOOwa9cu/Nqv/RpWV1eb//PXf/3XOP3007F9+3Yce+yxOPvss/H973//sF8zQgghhBBCCCGEELKxbBkn4n3TAqe95ENJ/vZXrzgXOyaH/1K//e1vxyWXXIKbbroJN954Iy688EKcddZZOPXUUw97G5dffjne+MY3YseOHXjGM56BZzzjGVhZWcE73/lOHDhwAE996lPxhje8Ab/5m7+55nZe//rX4+tf/zoe9rCH4YorrgAAHHfccbj55psBAL/1W7+F17zmNXjgAx+Io48+uhEzda677jps27YN119/PW6++Wb80i/9Eo499lj87u/+Lm677TY885nPxKtf/Wo89alPxT333IOPf/zjvYRXQgghhBBCCCGEELKxbBkRcTPx8Ic/HJdddhkA4NRTT8Ub3/hGXHfddb1ExJe//OU466yzAAAXXXQRXvziF+Mb3/gGHvjABwIAnv70p+MjH/nIuiLikUceiclkgh07dmDPnj1zv7/iiivwkz/5k2tuYzKZ4K1vfSt27NiBhz70objiiivwohe9CC972ctw2223YTab4WlPexpOPvlkAMDpp59+2MdJCCGEEEIIIYQQQjaeLSMibh/n+OoV5yb72314+MMfbnx/wgkn4M4773Texu7du7Fjx45GQFQ/+9SnPtVrm1086lGPWvf/POIRj8COHTua7/fu3YsDBw7glltuwSMe8Qg84QlPwOmnn45zzz0X55xzDp7+9Kfj6KOP9t43QgghhBBCCCGEEBKGLSMiCiF6lRSnZDweG98LIVCWJbKsamGpl/pOp9N1tyGEWLhNX4444giv5+d5jmuvvRY33HADrrnmGrzhDW/Ab//2b+Omm27CKaec4r1/hBBCCCGEEEIIIcSfLROsMgSOO+44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BAA8/elPx0c+8pF1RcQjjzwSk8kEO3bswJ49e+Z+f8UVV+Anf/In19zGZDLBW9/6VuzYsQMPfehDccUVV+BFL3oRXvayl+G2227DbDbD0572NJx88skAgNNPP33d49u5cyeOOuooADD26zWveQ1+8zd/Ez//8z8PAPi93/s9fOQjH8Ef/MEf4Morr2z+3/Of/3w87WlPa76/7LLL8NrXvrb52SmnnIKvfvWrePOb34xnP/vZeOc734nvfOc7+PSnP41jjjkGAPCgBz2oef4jHvEIPOIRj2i+f9nLXob3vOc9eN/73ofnPve52LdvH4444gj81E/9FO53v/vh5JNPxhlnnHFYrzEhhBBCCCFDgD0RCSEpoRPRn60jIo53VI7AVH+7Bw9/+MON70844QTceeedztvYvXs3duzY0QiI6mef+tSnem2zi0c96lHr/p9HPOIR2LGjfQ327t2LAwcO4JZbbsEjHvEIPOEJT8Dpp5+Oc889F+eccw6e/vSn4+ijj+69L/v378ett97aiKeKs846C1/84hcX7vf3v/99fOMb38BFF12EX/mVX2l+PpvNcOSRRwIAvvCFL+CMM85oBESbAwcO4PLLL8ff/u3fNsLofffdh3379gEAfvInfxInn3wyHvjAB+KJT3winvjEJ+KpT32q8boQQgghhBAyZOhEJISkRBU8UkR0Z+uIiEL0KilOiR3yIYRAWZbIsqqFpV7qO51O192GEGLhNn054gi/1zTPc1x77bW44YYbcM011+ANb3gDfvu3fxs33XQTTjnlFO/9W4S+3wcOHAAA/Omf/ike/ehHz+0fAGzfvn3N7b3whS/Etddei9e85jV40IMehO3bt+PpT3960/vxfve7Hz73uc/h+uuvxzXXXIOXvOQluPzyy/HpT3+6cVQSQgghhBAyZGTjROQEnhASn7JU4U7VYkaeuQe0blW2TrDKAFB98vQegHrIykYxmUxQFIXz87/4xS/ivvvua77/5Cc/iZ07d+Kkk04CUAmaZ511Fl760pfi85//PCaTCd7znvf0/ju7du3CiSeeiE984hPGzz/xiU/gtNNOW/i83bt348QTT8Q3v/lNPOhBDzL+KSHz4Q9/OL7whS/gu9/9buc2PvGJT+DCCy/EU5/6VJx++unYs2fPXDjKaDTC2WefjVe/+tX40pe+hJtvvhl/93d/B8D/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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "df_all = df_all.set_index(\"trip_hour\")\n", + "df_all.plot.line(figsize=(16, 8))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "trusted": true + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kaggle": { + "accelerator": "none", + "dataSources": [ + { + "databundleVersionId": 13391012, + "sourceId": 110281, + "sourceType": "competition" + } + ], + "dockerImageVersionId": 31089, + "isGpuEnabled": false, + "isInternetEnabled": true, + "language": "python", + "sourceType": "notebook" + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/kaggle/describe-product-images-with-bigframes-multimodal.ipynb b/notebooks/kaggle/describe-product-images-with-bigframes-multimodal.ipynb new file mode 100644 index 0000000000..1c2e2b53a8 --- /dev/null +++ b/notebooks/kaggle/describe-product-images-with-bigframes-multimodal.ipynb @@ -0,0 +1 @@ +{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":110281,"databundleVersionId":13391012,"sourceType":"competition"}],"dockerImageVersionId":31089,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Describe product images with BigFrames multimodal DataFrames\n\nBased on notebook at https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/multimodal/multimodal_dataframe.ipynb\n\nThis notebook is introducing BigFrames Multimodal features:\n\n1. Create Multimodal DataFrame\n2. Combine unstructured data with structured data\n3. Conduct image transformations\n4. Use LLM models to ask questions and generate embeddings on images\n5. PDF chunking function\n\nInstall the bigframes package and upgrade other packages that are already included in Kaggle but have versions incompatible with bigframes.","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19"}},{"cell_type":"code","source":"%pip install --upgrade bigframes google-cloud-automl google-cloud-translate google-ai-generativelanguage tensorflow ","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Important:** restart the kernel by going to \"Run -> Restart & clear cell outputs\" before continuing.\n\nConfigure bigframes to use your GCP project. First, go to \"Add-ons -> Google Cloud SDK\" and click the \"Attach\" button. Then,","metadata":{}},{"cell_type":"code","source":"from kaggle_secrets import UserSecretsClient\nuser_secrets = UserSecretsClient()\nuser_credential = user_secrets.get_gcloud_credential()\nuser_secrets.set_tensorflow_credential(user_credential)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:17:14.872905Z","iopub.execute_input":"2025-08-18T20:17:14.873201Z","iopub.status.idle":"2025-08-18T20:17:14.946971Z","shell.execute_reply.started":"2025-08-18T20:17:14.873171Z","shell.execute_reply":"2025-08-18T20:17:14.945996Z"}},"outputs":[],"execution_count":2},{"cell_type":"code","source":"PROJECT = \"bigframes-dev\" # replace with your project. \n# Refer to https://cloud.google.com/bigquery/docs/multimodal-data-dataframes-tutorial#required_roles for your required permissions\n\nOUTPUT_BUCKET = \"bigframes_blob_test\" # replace with your GCS bucket. \n# The connection (or bigframes-default-connection of the project) must have read/write permission to the bucket. \n# Refer to https://cloud.google.com/bigquery/docs/multimodal-data-dataframes-tutorial#grant-permissions for setting up connection service account permissions.\n# In this Notebook it uses bigframes-default-connection by default. You can also bring in your own connections in each method.\n\nimport bigframes\n# Setup project\nbigframes.options.bigquery.project = PROJECT\n\n# Display options\nbigframes.options.display.blob_display_width = 300\nbigframes.options.display.progress_bar = None\n\nimport bigframes.pandas as bpd","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:17:25.573874Z","iopub.execute_input":"2025-08-18T20:17:25.574192Z","iopub.status.idle":"2025-08-18T20:17:45.102002Z","shell.execute_reply.started":"2025-08-18T20:17:25.574168Z","shell.execute_reply":"2025-08-18T20:17:45.101140Z"}},"outputs":[],"execution_count":3},{"cell_type":"code","source":"# Create blob columns from wildcard path.\ndf_image = bpd.from_glob_path(\n \"gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/images/*\", name=\"image\"\n)\n# Other ways are: from string uri column\n# df = bpd.DataFrame({\"uri\": [\"gs:///\", \"gs:///\"]})\n# df[\"blob_col\"] = df[\"uri\"].str.to_blob()\n\n# From an existing object table\n# df = bpd.read_gbq_object_table(\"\", name=\"blob_col\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:17:45.103249Z","iopub.execute_input":"2025-08-18T20:17:45.103530Z","iopub.status.idle":"2025-08-18T20:17:47.424586Z","shell.execute_reply.started":"2025-08-18T20:17:45.103499Z","shell.execute_reply":"2025-08-18T20:17:47.423762Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/core/global_session.py:103: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n _global_session = bigframes.session.connect(\n","output_type":"stream"},{"name":"stdout","text":"Please ensure you have selected a BigQuery account in the Notebook Add-ons menu.\n","output_type":"stream"}],"execution_count":4},{"cell_type":"code","source":"# Take only the 5 images to deal with. Preview the content of the Mutimodal DataFrame\ndf_image = df_image.head(5)\ndf_image","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:17:47.425578Z","iopub.execute_input":"2025-08-18T20:17:47.425873Z","iopub.status.idle":"2025-08-18T20:18:07.919961Z","shell.execute_reply.started":"2025-08-18T20:17:47.425844Z","shell.execute_reply":"2025-08-18T20:18:07.918942Z"}},"outputs":[{"execution_count":5,"output_type":"execute_result","data":{"text/plain":" image\n0 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n1 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n2 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n3 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n4 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n\n[5 rows x 1 columns]","text/html":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
image
0
1
2
3
4
\n

5 rows × 1 columns

\n
[5 rows x 1 columns in total]"},"metadata":{}}],"execution_count":5},{"cell_type":"markdown","source":"# 2. Combine unstructured data with structured data\n\nNow you can put more information into the table to describe the files. Such as author info from inputs, or other metadata from the gcs object itself.","metadata":{}},{"cell_type":"code","source":"# Combine unstructured data with structured data\ndf_image[\"author\"] = [\"alice\", \"bob\", \"bob\", \"alice\", \"bob\"] # type: ignore\ndf_image[\"content_type\"] = df_image[\"image\"].blob.content_type()\ndf_image[\"size\"] = df_image[\"image\"].blob.size()\ndf_image[\"updated\"] = df_image[\"image\"].blob.updated()\ndf_image","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:18:07.921884Z","iopub.execute_input":"2025-08-18T20:18:07.922593Z","iopub.status.idle":"2025-08-18T20:18:35.549725Z","shell.execute_reply.started":"2025-08-18T20:18:07.922551Z","shell.execute_reply":"2025-08-18T20:18:35.548942Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\nversion. Use `json_query` instead.\n warnings.warn(bfe.format_message(msg), category=UserWarning)\n/usr/local/lib/python3.11/dist-packages/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\nversion. Use `json_query` instead.\n warnings.warn(bfe.format_message(msg), category=UserWarning)\n/usr/local/lib/python3.11/dist-packages/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\nversion. Use `json_query` instead.\n warnings.warn(bfe.format_message(msg), category=UserWarning)\n","output_type":"stream"},{"execution_count":6,"output_type":"execute_result","data":{"text/plain":" image author content_type \\\n0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n2 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n3 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n4 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n\n size updated \n0 1591240 2025-03-20 17:45:04+00:00 \n1 1182951 2025-03-20 17:45:02+00:00 \n2 1520884 2025-03-20 17:44:55+00:00 \n3 1235401 2025-03-20 17:45:19+00:00 \n4 1591923 2025-03-20 17:44:47+00:00 \n\n[5 rows x 5 columns]","text/html":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
imageauthorcontent_typesizeupdated
0aliceimage/png15912402025-03-20 17:45:04+00:00
1bobimage/png11829512025-03-20 17:45:02+00:00
2bobimage/png15208842025-03-20 17:44:55+00:00
3aliceimage/png12354012025-03-20 17:45:19+00:00
4bobimage/png15919232025-03-20 17:44:47+00:00
\n

5 rows × 5 columns

\n
[5 rows x 5 columns in total]"},"metadata":{}}],"execution_count":6},{"cell_type":"markdown","source":"Then you can filter the rows based on the structured data. And for different content types, you can display them respectively or together.","metadata":{}},{"cell_type":"code","source":"# filter images and display, you can also display audio and video types\ndf_image[df_image[\"author\"] == \"alice\"][\"image\"].blob.display()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:18:55.299993Z","iopub.execute_input":"2025-08-18T20:18:55.300314Z","iopub.status.idle":"2025-08-18T20:19:09.154492Z","shell.execute_reply.started":"2025-08-18T20:18:55.300289Z","shell.execute_reply":"2025-08-18T20:19:09.153315Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\nversion. Use `json_query` instead.\n warnings.warn(bfe.format_message(msg), category=UserWarning)\n","output_type":"stream"},{"output_type":"display_data","data":{"text/html":"","text/plain":""},"metadata":{}},{"output_type":"display_data","data":{"text/html":"","text/plain":""},"metadata":{}}],"execution_count":7},{"cell_type":"markdown","source":"# 3. Conduct image transformations\n\nBigFrames Multimodal DataFrame provides image(and other) transformation functions. Such as image_blur, image_resize and image_normalize. The output can be saved to GCS folders or to BQ as bytes.","metadata":{}},{"cell_type":"code","source":"df_image[\"blurred\"] = df_image[\"image\"].blob.image_blur(\n (20, 20), dst=f\"gs://{OUTPUT_BUCKET}/image_blur_transformed/\", engine=\"opencv\"\n)\ndf_image[\"resized\"] = df_image[\"image\"].blob.image_resize(\n (300, 200), dst=f\"gs://{OUTPUT_BUCKET}/image_resize_transformed/\", engine=\"opencv\"\n)\ndf_image[\"normalized\"] = df_image[\"image\"].blob.image_normalize(\n alpha=50.0,\n beta=150.0,\n norm_type=\"minmax\",\n dst=f\"gs://{OUTPUT_BUCKET}/image_normalize_transformed/\",\n engine=\"opencv\",\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:19:22.950277Z","iopub.execute_input":"2025-08-18T20:19:22.950652Z","iopub.status.idle":"2025-08-18T20:31:51.799997Z","shell.execute_reply.started":"2025-08-18T20:19:22.950625Z","shell.execute_reply":"2025-08-18T20:31:51.798840Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/core/log_adapter.py:175: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n return method(*args, **kwargs)\n/usr/local/lib/python3.11/dist-packages/bigframes/core/log_adapter.py:175: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n return method(*args, **kwargs)\n/usr/local/lib/python3.11/dist-packages/bigframes/core/log_adapter.py:175: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n return method(*args, **kwargs)\n","output_type":"stream"}],"execution_count":8},{"cell_type":"code","source":"# You can also chain functions together\ndf_image[\"blur_resized\"] = df_image[\"blurred\"].blob.image_resize((300, 200), dst=f\"gs://{OUTPUT_BUCKET}/image_blur_resize_transformed/\", engine=\"opencv\")\ndf_image","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:31:51.802219Z","iopub.execute_input":"2025-08-18T20:31:51.802745Z","iopub.status.idle":"2025-08-18T20:36:13.953258Z","shell.execute_reply.started":"2025-08-18T20:31:51.802700Z","shell.execute_reply":"2025-08-18T20:36:13.951930Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/core/log_adapter.py:175: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n return method(*args, **kwargs)\n","output_type":"stream"},{"execution_count":9,"output_type":"execute_result","data":{"text/plain":" image author content_type \\\n0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n2 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n3 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n4 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n\n size updated \\\n0 1591240 2025-03-20 17:45:04+00:00 \n1 1182951 2025-03-20 17:45:02+00:00 \n2 1520884 2025-03-20 17:44:55+00:00 \n3 1235401 2025-03-20 17:45:19+00:00 \n4 1591923 2025-03-20 17:44:47+00:00 \n\n blurred \\\n0 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n1 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n2 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n3 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n4 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n\n resized \\\n0 {'uri': 'gs://bigframes_blob_test/image_resize... \n1 {'uri': 'gs://bigframes_blob_test/image_resize... \n2 {'uri': 'gs://bigframes_blob_test/image_resize... \n3 {'uri': 'gs://bigframes_blob_test/image_resize... \n4 {'uri': 'gs://bigframes_blob_test/image_resize... \n\n normalized \\\n0 {'uri': 'gs://bigframes_blob_test/image_normal... \n1 {'uri': 'gs://bigframes_blob_test/image_normal... \n2 {'uri': 'gs://bigframes_blob_test/image_normal... \n3 {'uri': 'gs://bigframes_blob_test/image_normal... \n4 {'uri': 'gs://bigframes_blob_test/image_normal... \n\n blur_resized \n0 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n1 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n2 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n3 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n4 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n\n[5 rows x 9 columns]","text/html":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
imageauthorcontent_typesizeupdatedblurredresizednormalizedblur_resized
0aliceimage/png15912402025-03-20 17:45:04+00:00
1bobimage/png11829512025-03-20 17:45:02+00:00
2bobimage/png15208842025-03-20 17:44:55+00:00
3aliceimage/png12354012025-03-20 17:45:19+00:00
4bobimage/png15919232025-03-20 17:44:47+00:00
\n

5 rows × 9 columns

\n
[5 rows x 9 columns in total]"},"metadata":{}}],"execution_count":9},{"cell_type":"markdown","source":"# 4. Use LLM models to ask questions and generate embeddings on images","metadata":{}},{"cell_type":"code","source":"from bigframes.ml import llm\ngemini = llm.GeminiTextGenerator()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:36:13.954340Z","iopub.execute_input":"2025-08-18T20:36:13.954686Z","iopub.status.idle":"2025-08-18T20:36:43.225449Z","shell.execute_reply.started":"2025-08-18T20:36:13.954661Z","shell.execute_reply":"2025-08-18T20:36:43.224579Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/core/log_adapter.py:175: FutureWarning: Since upgrading the default model can cause unintended breakages, the\ndefault model will be removed in BigFrames 3.0. Please supply an\nexplicit model to avoid this message.\n return method(*args, **kwargs)\n","output_type":"stream"}],"execution_count":10},{"cell_type":"code","source":"# Ask the same question on the images\ndf_image = df_image.head(2)\nanswer = gemini.predict(df_image, prompt=[\"what item is it?\", df_image[\"image\"]])\nanswer[[\"ml_generate_text_llm_result\", \"image\"]]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:36:43.227457Z","iopub.execute_input":"2025-08-18T20:36:43.227798Z","iopub.status.idle":"2025-08-18T20:37:25.238649Z","shell.execute_reply.started":"2025-08-18T20:36:43.227764Z","shell.execute_reply":"2025-08-18T20:37:25.237623Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/core/array_value.py:108: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n`db_dtypes` is a preview feature and subject to change.\n warnings.warn(msg, bfe.PreviewWarning)\n","output_type":"stream"},{"execution_count":11,"output_type":"execute_result","data":{"text/plain":" ml_generate_text_llm_result \\\n0 The item is a tin of K9 Guard Dog Paw Balm. \n1 The item is a bottle of K9 Guard Dog Hot Spot ... \n\n image \n0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n\n[2 rows x 2 columns]","text/html":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
ml_generate_text_llm_resultimage
0The item is a tin of K9 Guard Dog Paw Balm.
1The item is a bottle of K9 Guard Dog Hot Spot Spray.
\n

2 rows × 2 columns

\n
[2 rows x 2 columns in total]"},"metadata":{}}],"execution_count":11},{"cell_type":"code","source":"# Ask different questions\ndf_image[\"question\"] = [\"what item is it?\", \"what color is the picture?\"]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:37:25.239607Z","iopub.execute_input":"2025-08-18T20:37:25.239875Z","iopub.status.idle":"2025-08-18T20:37:25.263034Z","shell.execute_reply.started":"2025-08-18T20:37:25.239847Z","shell.execute_reply":"2025-08-18T20:37:25.262002Z"}},"outputs":[],"execution_count":12},{"cell_type":"code","source":"answer_alt = gemini.predict(df_image, prompt=[df_image[\"question\"], df_image[\"image\"]])\nanswer_alt[[\"ml_generate_text_llm_result\", \"image\"]]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:37:25.264072Z","iopub.execute_input":"2025-08-18T20:37:25.264585Z","iopub.status.idle":"2025-08-18T20:38:10.129667Z","shell.execute_reply.started":"2025-08-18T20:37:25.264518Z","shell.execute_reply":"2025-08-18T20:38:10.128677Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/core/array_value.py:108: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n`db_dtypes` is a preview feature and subject to change.\n warnings.warn(msg, bfe.PreviewWarning)\n","output_type":"stream"},{"execution_count":13,"output_type":"execute_result","data":{"text/plain":" ml_generate_text_llm_result \\\n0 The item is a tin of K9 Guard Dog Paw Balm. \n1 The picture has colors such as white, gray, an... \n\n image \n0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n\n[2 rows x 2 columns]","text/html":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
ml_generate_text_llm_resultimage
0The item is a tin of K9 Guard Dog Paw Balm.
1The picture has colors such as white, gray, and a light blue (cyan).
\n

2 rows × 2 columns

\n
[2 rows x 2 columns in total]"},"metadata":{}}],"execution_count":13},{"cell_type":"code","source":"# Generate embeddings.\nembed_model = llm.MultimodalEmbeddingGenerator()\nembeddings = embed_model.predict(df_image[\"image\"])\nembeddings","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T20:38:10.130617Z","iopub.execute_input":"2025-08-18T20:38:10.130851Z","iopub.status.idle":"2025-08-18T20:39:04.790416Z","shell.execute_reply.started":"2025-08-18T20:38:10.130833Z","shell.execute_reply":"2025-08-18T20:39:04.789398Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/core/log_adapter.py:175: FutureWarning: Since upgrading the default model can cause unintended breakages, the\ndefault model will be removed in BigFrames 3.0. Please supply an\nexplicit model to avoid this message.\n return method(*args, **kwargs)\n/usr/local/lib/python3.11/dist-packages/bigframes/core/array_value.py:108: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n`db_dtypes` is a preview feature and subject to change.\n warnings.warn(msg, bfe.PreviewWarning)\n","output_type":"stream"},{"execution_count":14,"output_type":"execute_result","data":{"text/plain":" ml_generate_embedding_result \\\n0 [ 0.00638822 0.01666385 0.00451817 ... -0.02... \n1 [ 0.00973672 0.02148364 0.00244308 ... 0.00... \n\n ml_generate_embedding_status ml_generate_embedding_start_sec \\\n0 \n1 \n\n ml_generate_embedding_end_sec \\\n0 \n1 \n\n content \n0 {\"access_urls\":{\"expiry_time\":\"2025-08-19T02:3... \n1 {\"access_urls\":{\"expiry_time\":\"2025-08-19T02:3... \n\n[2 rows x 5 columns]","text/html":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
ml_generate_embedding_resultml_generate_embedding_statusml_generate_embedding_start_secml_generate_embedding_end_seccontent
0[ 0.00638822 0.01666385 0.00451817 ... -0.02...<NA><NA>{\"access_urls\":{\"expiry_time\":\"2025-08-19T02:3...
1[ 0.00973672 0.02148364 0.00244308 ... 0.00...<NA><NA>{\"access_urls\":{\"expiry_time\":\"2025-08-19T02:3...
\n

2 rows × 5 columns

\n
[2 rows x 5 columns in total]"},"metadata":{}}],"execution_count":14},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]} diff --git a/notebooks/kaggle/vector-search-with-bigframes-over-national-jukebox.ipynb b/notebooks/kaggle/vector-search-with-bigframes-over-national-jukebox.ipynb new file mode 100644 index 0000000000..fe2d567d1b --- /dev/null +++ b/notebooks/kaggle/vector-search-with-bigframes-over-national-jukebox.ipynb @@ -0,0 +1,1137 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "194%" + } + } + } + }, + "editable": true, + "slideshow": { + "slide_type": "subslide" + }, + "tags": [] + }, + "source": [ + "# Creating a searchable index of the National Jukebox\n", + "\n", + "_Extracting text from audio and indexing it with BigQuery DataFrames_\n", + "\n", + "* Tim Swena (formerly, Swast)\n", + "* swast@google.com\n", + "* https://vis.social/@timswast on Mastodon\n", + "\n", + "This notebook lives in\n", + "\n", + "* https://github.com/tswast/code-snippets\n", + "* at https://github.com/tswast/code-snippets/blob/main/2025/national-jukebox/transcribe_songs.ipynb\n", + "\n", + "To follow along, you'll need a Google Cloud project\n", + "\n", + "* Go to https://cloud.google.com/free to start a free trial." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "z-index": "0", + "zoom": "216%" + } + } + } + }, + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "The National Jukebox is a project of the USA Library of Congress to provide access to thousands of acoustic sound recordings from the very earliest days of the commercial record industry.\n", + "\n", + "* Learn more at https://www.loc.gov/collections/national-jukebox/about-this-collection/\n", + "\n", + "\"recording" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "z-index": "0", + "zoom": "181%" + } + } + } + }, + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "\n", + "To search the National Jukebox, we combine powerful features of BigQuery:\n", + "\n", + "\"audio\n", + "\n", + "1. Integrations with multi-modal AI models to extract information from unstructured data, in this case audio files.\n", + "\n", + " https://cloud.google.com/bigquery/docs/multimodal-data-dataframes-tutorial\n", + " \n", + "2. Vector search to find similar text using embedding models.\n", + "\n", + " https://cloud.google.com/bigquery/docs/vector-index-text-search-tutorial\n", + "\n", + "3. BigQuery DataFrames to use Python instead of SQL.\n", + "\n", + " https://cloud.google.com/bigquery/docs/bigquery-dataframes-introduction" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "275%" + } + } + } + }, + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "## Getting started with BigQuery DataFrames (bigframes)\n", + "\n", + "Install the bigframes package." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "214%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T15:53:02.494188Z", + "iopub.status.busy": "2025-08-14T15:53:02.493469Z", + "iopub.status.idle": "2025-08-14T15:53:08.492291Z", + "shell.execute_reply": "2025-08-14T15:53:08.491183Z", + "shell.execute_reply.started": "2025-08-14T15:53:02.494152Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "%pip install --upgrade bigframes google-cloud-automl google-cloud-translate google-ai-generativelanguage tensorflow " + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "z-index": "4", + "zoom": "236%" + } + } + } + } + }, + "source": [ + "**Important:** restart the kernel by going to \"Run -> Restart & clear cell outputs\" before continuing.\n", + "\n", + "Configure bigframes to use your GCP project. First, go to \"Add-ons -> Google Cloud SDK\" and click the \"Attach\" button. Then," + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-14T15:53:08.494636Z", + "iopub.status.busy": "2025-08-14T15:53:08.494313Z", + "iopub.status.idle": "2025-08-14T15:53:08.609706Z", + "shell.execute_reply": "2025-08-14T15:53:08.608705Z", + "shell.execute_reply.started": "2025-08-14T15:53:08.494604Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "from kaggle_secrets import UserSecretsClient\n", + "user_secrets = UserSecretsClient()\n", + "user_credential = user_secrets.get_gcloud_credential()\n", + "user_secrets.set_tensorflow_credential(user_credential)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "193%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T15:53:08.610982Z", + "iopub.status.busy": "2025-08-14T15:53:08.610686Z", + "iopub.status.idle": "2025-08-14T15:53:17.658993Z", + "shell.execute_reply": "2025-08-14T15:53:17.657745Z", + "shell.execute_reply.started": "2025-08-14T15:53:08.610961Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "import bigframes._config\n", + "import bigframes.pandas as bpd\n", + "\n", + "bpd.options.bigquery.location = \"US\"\n", + "\n", + "# Set to your GCP project ID.\n", + "bpd.options.bigquery.project = \"swast-scratch\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "207%" + } + } + } + }, + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "## Reading data\n", + "\n", + "BigQuery DataFrames can read data from BigQuery, GCS, or even local sources. With `engine=\"bigquery\"`, BigQuery's distributed processing reads the file without it ever having to reach your local Python environment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "225%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T15:53:17.662234Z", + "iopub.status.busy": "2025-08-14T15:53:17.661901Z", + "iopub.status.idle": "2025-08-14T15:53:34.486799Z", + "shell.execute_reply": "2025-08-14T15:53:34.485777Z", + "shell.execute_reply.started": "2025-08-14T15:53:17.662207Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "df = bpd.read_json(\n", + " \"gs://cloud-samples-data/third-party/usa-loc-national-jukebox/jukebox.jsonl\",\n", + " engine=\"bigquery\",\n", + " orient=\"records\",\n", + " lines=True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "122%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T15:53:34.488610Z", + "iopub.status.busy": "2025-08-14T15:53:34.488332Z", + "iopub.status.idle": "2025-08-14T15:53:40.347014Z", + "shell.execute_reply": "2025-08-14T15:53:40.345773Z", + "shell.execute_reply.started": "2025-08-14T15:53:34.488589Z" + }, + "slideshow": { + "slide_type": "slide" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "# Use `peek()` instead of `head()` to see arbitrary rows rather than the \"first\" rows.\n", + "df.peek()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "134%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T15:53:40.348376Z", + "iopub.status.busy": "2025-08-14T15:53:40.348021Z", + "iopub.status.idle": "2025-08-14T15:53:40.364129Z", + "shell.execute_reply": "2025-08-14T15:53:40.363204Z", + "shell.execute_reply.started": "2025-08-14T15:53:40.348351Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "df.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-14T15:55:55.448664Z", + "iopub.status.busy": "2025-08-14T15:55:55.448310Z", + "iopub.status.idle": "2025-08-14T15:55:59.440964Z", + "shell.execute_reply": "2025-08-14T15:55:59.439988Z", + "shell.execute_reply.started": "2025-08-14T15:55:55.448637Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "# For the purposes of a demo, select only a subset of rows.\n", + "df = df.sample(n=250)\n", + "df.cache()\n", + "df.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "161%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T15:56:02.040804Z", + "iopub.status.busy": "2025-08-14T15:56:02.040450Z", + "iopub.status.idle": "2025-08-14T15:56:06.544384Z", + "shell.execute_reply": "2025-08-14T15:56:06.543240Z", + "shell.execute_reply.started": "2025-08-14T15:56:02.040777Z" + }, + "slideshow": { + "slide_type": "slide" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "# As a side effect of how I extracted the song information from the HTML DOM,\n", + "# we ended up with lists in places where we only expect one item.\n", + "#\n", + "# We can \"explode\" to flatten these lists.\n", + "flattened = df.explode([\n", + " \"Recording Repository\",\n", + " \"Recording Label\",\n", + " \"Recording Take Number\",\n", + " \"Recording Date\",\n", + " \"Recording Matrix Number\",\n", + " \"Recording Catalog Number\",\n", + " \"Media Size\",\n", + " \"Recording Location\",\n", + " \"Summary\",\n", + " \"Rights Advisory\",\n", + " \"Title\",\n", + "])\n", + "flattened.peek()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-14T15:56:06.546531Z", + "iopub.status.busy": "2025-08-14T15:56:06.546140Z", + "iopub.status.idle": "2025-08-14T15:56:06.566005Z", + "shell.execute_reply": "2025-08-14T15:56:06.564355Z", + "shell.execute_reply.started": "2025-08-14T15:56:06.546494Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "flattened.shape" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "216%" + } + } + } + }, + "editable": true, + "slideshow": { + "slide_type": "slide" + }, + "tags": [] + }, + "source": [ + "To access unstructured data from BigQuery, create a URI pointing to a file in Google Cloud Storage (GCS). Then, construct a \"blob\" (also known as an \"Object Ref\" in BigQuery terms) so that BigQuery can read from GCS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "211%" + } + } + } + }, + "editable": true, + "execution": { + "iopub.execute_input": "2025-08-14T15:56:07.394879Z", + "iopub.status.busy": "2025-08-14T15:56:07.394509Z", + "iopub.status.idle": "2025-08-14T15:56:12.217017Z", + "shell.execute_reply": "2025-08-14T15:56:12.215852Z", + "shell.execute_reply.started": "2025-08-14T15:56:07.394853Z" + }, + "slideshow": { + "slide_type": "" + }, + "tags": [], + "trusted": true + }, + "outputs": [], + "source": [ + "flattened = flattened.assign(**{\n", + " \"GCS Prefix\": \"gs://cloud-samples-data/third-party/usa-loc-national-jukebox/\",\n", + " \"GCS Stub\": flattened['URL'].str.extract(r'/(jukebox-[0-9]+)/'),\n", + "})\n", + "flattened[\"GCS URI\"] = flattened[\"GCS Prefix\"] + flattened[\"GCS Stub\"] + \".mp3\"\n", + "flattened[\"GCS Blob\"] = flattened[\"GCS URI\"].str.to_blob()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "317%" + } + } + } + }, + "editable": true, + "slideshow": { + "slide_type": "slide" + }, + "tags": [] + }, + "source": [ + "BigQuery (and BigQuery DataFrames) provide access to powerful models and multimodal capabilities. Here, we transcribe audio to text." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "editable": true, + "execution": { + "iopub.execute_input": "2025-08-14T15:56:20.908198Z", + "iopub.status.busy": "2025-08-14T15:56:20.907791Z", + "iopub.status.idle": "2025-08-14T15:58:45.909086Z", + "shell.execute_reply": "2025-08-14T15:58:45.908060Z", + "shell.execute_reply.started": "2025-08-14T15:56:20.908170Z" + }, + "slideshow": { + "slide_type": "" + }, + "tags": [], + "trusted": true + }, + "outputs": [], + "source": [ + "flattened[\"Transcription\"] = flattened[\"GCS Blob\"].blob.audio_transcribe(\n", + " model_name=\"gemini-2.0-flash-001\",\n", + " verbose=True,\n", + ")\n", + "flattened[\"Transcription\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "229%" + } + } + } + }, + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "Sometimes the model has transient errors. Check the status column to see if there are errors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "177%" + } + } + } + }, + "editable": true, + "execution": { + "iopub.execute_input": "2025-08-14T15:59:43.609239Z", + "iopub.status.busy": "2025-08-14T15:59:43.607976Z", + "iopub.status.idle": "2025-08-14T15:59:44.515118Z", + "shell.execute_reply": "2025-08-14T15:59:44.514275Z", + "shell.execute_reply.started": "2025-08-14T15:59:43.609201Z" + }, + "slideshow": { + "slide_type": "" + }, + "tags": [], + "trusted": true + }, + "outputs": [], + "source": [ + "print(f\"Successful rows: {(flattened['Transcription'].struct.field('status') == '').sum()}\")\n", + "print(f\"Failed rows: {(flattened['Transcription'].struct.field('status') != '').sum()}\")\n", + "flattened.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "141%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T15:59:44.820256Z", + "iopub.status.busy": "2025-08-14T15:59:44.819926Z", + "iopub.status.idle": "2025-08-14T15:59:53.147159Z", + "shell.execute_reply": "2025-08-14T15:59:53.146281Z", + "shell.execute_reply.started": "2025-08-14T15:59:44.820232Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "# Show transcribed lyrics.\n", + "flattened[\"Transcription\"].struct.field(\"content\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "152%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T15:59:53.149222Z", + "iopub.status.busy": "2025-08-14T15:59:53.148783Z", + "iopub.status.idle": "2025-08-14T15:59:58.868959Z", + "shell.execute_reply": "2025-08-14T15:59:58.867804Z", + "shell.execute_reply.started": "2025-08-14T15:59:53.149198Z" + }, + "slideshow": { + "slide_type": "slide" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "# Find all instrumentatal songs\n", + "instrumental = flattened[flattened[\"Transcription\"].struct.field(\"content\") == \"\"]\n", + "print(instrumental.shape)\n", + "song = instrumental.peek(1)\n", + "song" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "152%" + } + } + } + }, + "editable": true, + "execution": { + "iopub.execute_input": "2025-08-14T15:59:58.870143Z", + "iopub.status.busy": "2025-08-14T15:59:58.869868Z", + "iopub.status.idle": "2025-08-14T16:00:15.502470Z", + "shell.execute_reply": "2025-08-14T16:00:15.500813Z", + "shell.execute_reply.started": "2025-08-14T15:59:58.870123Z" + }, + "slideshow": { + "slide_type": "" + }, + "tags": [], + "trusted": true + }, + "outputs": [], + "source": [ + "import gcsfs\n", + "import IPython.display\n", + "\n", + "fs = gcsfs.GCSFileSystem(project='bigframes-dev')\n", + "with fs.open(song[\"GCS URI\"].iloc[0]) as song_file:\n", + " song_bytes = song_file.read()\n", + "\n", + "IPython.display.Audio(song_bytes)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "181%" + } + } + } + }, + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "## Creating a searchable index\n", + "\n", + "To be able to search by semantics rather than just text, generate embeddings and then create an index to efficiently search these.\n", + "\n", + "See also, this example: https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "163%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T16:00:15.506380Z", + "iopub.status.busy": "2025-08-14T16:00:15.505775Z", + "iopub.status.idle": "2025-08-14T16:00:25.134987Z", + "shell.execute_reply": "2025-08-14T16:00:25.134124Z", + "shell.execute_reply.started": "2025-08-14T16:00:15.506337Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "from bigframes.ml.llm import TextEmbeddingGenerator\n", + "\n", + "text_model = TextEmbeddingGenerator(model_name=\"text-multilingual-embedding-002\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "125%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T16:00:25.136017Z", + "iopub.status.busy": "2025-08-14T16:00:25.135744Z", + "iopub.status.idle": "2025-08-14T16:00:34.860878Z", + "shell.execute_reply": "2025-08-14T16:00:34.859925Z", + "shell.execute_reply.started": "2025-08-14T16:00:25.135997Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "df_to_index = (\n", + " flattened\n", + " .assign(content=flattened[\"Transcription\"].struct.field(\"content\"))\n", + " [flattened[\"Transcription\"].struct.field(\"content\") != \"\"]\n", + ")\n", + "embedding = text_model.predict(df_to_index)\n", + "embedding.peek(1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "178%" + } + } + } + }, + "editable": true, + "execution": { + "iopub.execute_input": "2025-08-14T16:01:20.816923Z", + "iopub.status.busy": "2025-08-14T16:01:20.816523Z", + "iopub.status.idle": "2025-08-14T16:01:22.480554Z", + "shell.execute_reply": "2025-08-14T16:01:22.479604Z", + "shell.execute_reply.started": "2025-08-14T16:01:20.816894Z" + }, + "slideshow": { + "slide_type": "slide" + }, + "tags": [], + "trusted": true + }, + "outputs": [], + "source": [ + "# Check the status column to look for errors.\n", + "print(f\"Successful rows: {(embedding['ml_generate_embedding_status'] == '').sum()}\")\n", + "print(f\"Failed rows: {(embedding['ml_generate_embedding_status'] != '').sum()}\")\n", + "embedding.shape" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "224%" + } + } + } + } + }, + "source": [ + "We're now ready to save this to a table." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "172%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T16:03:43.611592Z", + "iopub.status.busy": "2025-08-14T16:03:43.611265Z", + "iopub.status.idle": "2025-08-14T16:03:47.459025Z", + "shell.execute_reply": "2025-08-14T16:03:47.458079Z", + "shell.execute_reply.started": "2025-08-14T16:03:43.611568Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "embedding_table_id = f\"{bpd.options.bigquery.project}.kaggle.national_jukebox\"\n", + "embedding.to_gbq(embedding_table_id, if_exists=\"replace\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "183%" + } + } + } + }, + "slideshow": { + "slide_type": "slide" + } + }, + "source": [ + "## Searching the database\n", + "\n", + "To search by semantics, we:\n", + "\n", + "1. Turn our search string into an embedding using the same model as our index.\n", + "2. Find the closest matches to the search string." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "92%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T16:03:52.674429Z", + "iopub.status.busy": "2025-08-14T16:03:52.673629Z", + "iopub.status.idle": "2025-08-14T16:03:59.962635Z", + "shell.execute_reply": "2025-08-14T16:03:59.961482Z", + "shell.execute_reply.started": "2025-08-14T16:03:52.674399Z" + }, + "slideshow": { + "slide_type": "skip" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "\n", + "df_written = bpd.read_gbq(embedding_table_id)\n", + "df_written.peek(1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "127%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T16:03:59.964634Z", + "iopub.status.busy": "2025-08-14T16:03:59.964268Z", + "iopub.status.idle": "2025-08-14T16:04:55.051531Z", + "shell.execute_reply": "2025-08-14T16:04:55.050393Z", + "shell.execute_reply.started": "2025-08-14T16:03:59.964598Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "from bigframes.ml.llm import TextEmbeddingGenerator\n", + "\n", + "search_string = \"walking home\"\n", + "\n", + "text_model = TextEmbeddingGenerator(model_name=\"text-multilingual-embedding-002\")\n", + "search_df = bpd.DataFrame([search_string], columns=['search_string'])\n", + "search_embedding = text_model.predict(search_df)\n", + "search_embedding" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "175%" + } + } + } + }, + "editable": true, + "execution": { + "iopub.execute_input": "2025-08-14T16:05:46.473357Z", + "iopub.status.busy": "2025-08-14T16:05:46.473056Z", + "iopub.status.idle": "2025-08-14T16:05:50.564470Z", + "shell.execute_reply": "2025-08-14T16:05:50.563277Z", + "shell.execute_reply.started": "2025-08-14T16:05:46.473336Z" + }, + "slideshow": { + "slide_type": "slide" + }, + "tags": [], + "trusted": true + }, + "outputs": [], + "source": [ + "import bigframes.bigquery as bbq\n", + "\n", + "vector_search_results = bbq.vector_search(\n", + " base_table=f\"swast-scratch.scipy2025.national_jukebox\",\n", + " column_to_search=\"ml_generate_embedding_result\",\n", + " query=search_embedding,\n", + " distance_type=\"COSINE\",\n", + " query_column_to_search=\"ml_generate_embedding_result\",\n", + " top_k=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-14T16:05:50.566930Z", + "iopub.status.busy": "2025-08-14T16:05:50.566422Z", + "iopub.status.idle": "2025-08-14T16:05:50.576293Z", + "shell.execute_reply": "2025-08-14T16:05:50.575186Z", + "shell.execute_reply.started": "2025-08-14T16:05:50.566893Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "vector_search_results.dtypes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "158%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T16:05:54.787080Z", + "iopub.status.busy": "2025-08-14T16:05:54.786649Z", + "iopub.status.idle": "2025-08-14T16:05:55.581285Z", + "shell.execute_reply": "2025-08-14T16:05:55.580012Z", + "shell.execute_reply.started": "2025-08-14T16:05:54.787054Z" + }, + "slideshow": { + "slide_type": "slide" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "results = vector_search_results[[\"Title\", \"Summary\", \"Names\", \"GCS URI\", \"Transcription\", \"distance\"]].sort_values(\"distance\").to_pandas()\n", + "results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "@deathbeds/jupyterlab-fonts": { + "styles": { + "": { + "body[data-jp-deck-mode='presenting'] &": { + "zoom": "138%" + } + } + } + }, + "execution": { + "iopub.execute_input": "2025-08-14T16:05:56.142373Z", + "iopub.status.busy": "2025-08-14T16:05:56.142038Z", + "iopub.status.idle": "2025-08-14T16:05:56.149020Z", + "shell.execute_reply": "2025-08-14T16:05:56.147966Z", + "shell.execute_reply.started": "2025-08-14T16:05:56.142350Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "print(results[\"Transcription\"].struct.field(\"content\").iloc[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "editable": true, + "execution": { + "iopub.execute_input": "2025-08-14T16:06:04.542878Z", + "iopub.status.busy": "2025-08-14T16:06:04.542537Z", + "iopub.status.idle": "2025-08-14T16:06:04.843052Z", + "shell.execute_reply": "2025-08-14T16:06:04.841220Z", + "shell.execute_reply.started": "2025-08-14T16:06:04.542854Z" + }, + "scrolled": true, + "slideshow": { + "slide_type": "" + }, + "tags": [], + "trusted": true + }, + "outputs": [], + "source": [ + "import gcsfs\n", + "import IPython.display\n", + "\n", + "fs = gcsfs.GCSFileSystem(project='bigframes-dev')\n", + "with fs.open(results[\"GCS URI\"].iloc[0]) as song_file:\n", + " song_bytes = song_file.read()\n", + "\n", + "IPython.display.Audio(song_bytes)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "trusted": true + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kaggle": { + "accelerator": "none", + "dataSources": [ + { + "databundleVersionId": 13238728, + "sourceId": 110281, + "sourceType": "competition" + } + ], + "dockerImageVersionId": 31089, + "isGpuEnabled": false, + "isInternetEnabled": true, + "language": "python", + "sourceType": "notebook" + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/location/regionalized.ipynb b/notebooks/location/regionalized.ipynb index 1b138c6a66..066cd18136 100644 --- a/notebooks/location/regionalized.ipynb +++ b/notebooks/location/regionalized.ipynb @@ -1475,8 +1475,8 @@ } ], "source": [ - "@bpd.remote_function([float], str, bigquery_connection='bigframes-rf-conn')\n", - "def get_bucket(num):\n", + "@bpd.remote_function(bigquery_connection='bigframes-rf-conn', cloud_function_service_account=\"default\")\n", + "def get_bucket(num: float) -> str:\n", " if not num: return \"NA\"\n", " boundary = 4000\n", " return \"at_or_above_4000\" if num >= boundary else \"below_4000\"" diff --git a/notebooks/ml/bq_dataframes_ml_cross_validation.ipynb b/notebooks/ml/bq_dataframes_ml_cross_validation.ipynb index 4bfdcc24aa..501bfc88d3 100644 --- a/notebooks/ml/bq_dataframes_ml_cross_validation.ipynb +++ b/notebooks/ml/bq_dataframes_ml_cross_validation.ipynb @@ -27,21 +27,25 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 2, "metadata": {}, "outputs": [ { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/garrettwu/src/bigframes/venv/lib/python3.10/site-packages/IPython/core/interactiveshell.py:3577: UserWarning: Reading cached table from 2024-10-01 22:44:50.650768+00:00 to avoid incompatibilies with previous reads of this table. To read the latest version, set `use_cache=False` or close the current session with Session.close() or bigframes.pandas.close_session().\n", - " exec(code_obj, self.user_global_ns, self.user_ns)\n" - ] + "data": { + "text/html": [ + "Query job aa2b9845-0e66-4f42-a360-ffe03215caf6 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" }, { "data": { "text/html": [ - "Query job 4c2f2252-687a-47c3-87ad-22db8ad96e2b is DONE. 0 Bytes processed. Open Job" + "Query job fe2bc354-672e-4d08-b969-bb2ede299fca is DONE. 28.9 kB processed. Open Job" ], "text/plain": [ "" @@ -53,7 +57,7 @@ { "data": { "text/html": [ - "Query job a05c7268-8db2-468b-9fb4-0fb5c9534f51 is DONE. 0 Bytes processed. Open Job" + "Query job 8d16fa20-391f-4917-86fc-1a595dba3fc6 is DONE. 33.6 kB processed. Open Job" ], "text/plain": [ "" @@ -97,149 +101,317 @@ " 0\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 50.5\n", - " 15.9\n", - " 225.0\n", - " 5400.0\n", + " 45.2\n", + " 16.4\n", + " 223.0\n", + " 5950.0\n", " MALE\n", " \n", " \n", " 1\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 45.1\n", + " 46.5\n", " 14.5\n", - " 215.0\n", - " 5000.0\n", + " 213.0\n", + " 4400.0\n", " FEMALE\n", " \n", " \n", " 2\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " Torgersen\n", - " 41.4\n", - " 18.5\n", - " 202.0\n", - " 3875.0\n", - " MALE\n", + " Biscoe\n", + " 37.7\n", + " 16.0\n", + " 183.0\n", + " 3075.0\n", + " FEMALE\n", " \n", " \n", " 3\n", - " Adelie Penguin (Pygoscelis adeliae)\n", - " Torgersen\n", - " 38.6\n", - " 17.0\n", - " 188.0\n", - " 2900.0\n", - " FEMALE\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 46.4\n", + " 15.6\n", + " 221.0\n", + " 5000.0\n", + " MALE\n", " \n", " \n", " 4\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 46.5\n", - " 14.8\n", - " 217.0\n", - " 5200.0\n", + " 46.1\n", + " 13.2\n", + " 211.0\n", + " 4500.0\n", " FEMALE\n", " \n", " \n", - " ...\n", - " ...\n", - " ...\n", - " ...\n", - " ...\n", - " ...\n", - " ...\n", - " ...\n", + " 5\n", + " Adelie Penguin (Pygoscelis adeliae)\n", + " Torgersen\n", + " 43.1\n", + " 19.2\n", + " 197.0\n", + " 3500.0\n", + " MALE\n", " \n", " \n", - " 339\n", + " 6\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 45.2\n", + " 15.8\n", + " 215.0\n", + " 5300.0\n", + " MALE\n", + " \n", + " \n", + " 7\n", " Adelie Penguin (Pygoscelis adeliae)\n", " Dream\n", - " 38.1\n", - " 17.6\n", + " 36.2\n", + " 17.3\n", " 187.0\n", - " 3425.0\n", + " 3300.0\n", " FEMALE\n", " \n", " \n", - " 340\n", + " 8\n", + " Chinstrap penguin (Pygoscelis antarctica)\n", + " Dream\n", + " 46.0\n", + " 18.9\n", + " 195.0\n", + " 4150.0\n", + " FEMALE\n", + " \n", + " \n", + " 9\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 54.3\n", + " 15.7\n", + " 231.0\n", + " 5650.0\n", + " MALE\n", + " \n", + " \n", + " 11\n", " Adelie Penguin (Pygoscelis adeliae)\n", + " Torgersen\n", + " 39.5\n", + " 17.4\n", + " 186.0\n", + " 3800.0\n", + " FEMALE\n", + " \n", + " \n", + " 12\n", + " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 36.4\n", - " 17.1\n", - " 184.0\n", - " 2850.0\n", + " 42.7\n", + " 13.7\n", + " 208.0\n", + " 3950.0\n", " FEMALE\n", " \n", " \n", - " 341\n", + " 13\n", + " Adelie Penguin (Pygoscelis adeliae)\n", + " Biscoe\n", + " 41.0\n", + " 20.0\n", + " 203.0\n", + " 4725.0\n", + " MALE\n", + " \n", + " \n", + " 14\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 48.5\n", + " 15.0\n", + " 219.0\n", + " 4850.0\n", + " FEMALE\n", + " \n", + " \n", + " 15\n", " Chinstrap penguin (Pygoscelis antarctica)\n", " Dream\n", - " 40.9\n", - " 16.6\n", - " 187.0\n", - " 3200.0\n", + " 49.6\n", + " 18.2\n", + " 193.0\n", + " 3775.0\n", + " MALE\n", + " \n", + " \n", + " 16\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 50.8\n", + " 17.3\n", + " 228.0\n", + " 5600.0\n", + " MALE\n", + " \n", + " \n", + " 17\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 46.2\n", + " 14.1\n", + " 217.0\n", + " 4375.0\n", " FEMALE\n", " \n", " \n", - " 342\n", + " 18\n", " Adelie Penguin (Pygoscelis adeliae)\n", " Biscoe\n", - " 41.3\n", - " 21.1\n", - " 195.0\n", - " 4400.0\n", + " 38.8\n", + " 17.2\n", + " 180.0\n", + " 3800.0\n", " MALE\n", " \n", " \n", - " 343\n", + " 19\n", " Chinstrap penguin (Pygoscelis antarctica)\n", " Dream\n", - " 45.2\n", - " 16.6\n", - " 191.0\n", - " 3250.0\n", + " 51.0\n", + " 18.8\n", + " 203.0\n", + " 4100.0\n", + " MALE\n", + " \n", + " \n", + " 20\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 42.9\n", + " 13.1\n", + " 215.0\n", + " 5000.0\n", + " FEMALE\n", + " \n", + " \n", + " 21\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 50.4\n", + " 15.3\n", + " 224.0\n", + " 5550.0\n", + " MALE\n", + " \n", + " \n", + " 22\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 49.0\n", + " 16.1\n", + " 216.0\n", + " 5550.0\n", + " MALE\n", + " \n", + " \n", + " 23\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 43.4\n", + " 14.4\n", + " 218.0\n", + " 4600.0\n", + " FEMALE\n", + " \n", + " \n", + " 24\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 45.0\n", + " 15.4\n", + " 220.0\n", + " 5050.0\n", + " MALE\n", + " \n", + " \n", + " 25\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 47.5\n", + " 14.0\n", + " 212.0\n", + " 4875.0\n", " FEMALE\n", " \n", " \n", "\n", - "

334 rows × 7 columns

\n", + "

25 rows × 7 columns

\n", "[334 rows x 7 columns in total]" ], "text/plain": [ - " species island culmen_length_mm \\\n", - "0 Gentoo penguin (Pygoscelis papua) Biscoe 50.5 \n", - "1 Gentoo penguin (Pygoscelis papua) Biscoe 45.1 \n", - "2 Adelie Penguin (Pygoscelis adeliae) Torgersen 41.4 \n", - "3 Adelie Penguin (Pygoscelis adeliae) Torgersen 38.6 \n", - "4 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 \n", - ".. ... ... ... \n", - "339 Adelie Penguin (Pygoscelis adeliae) Dream 38.1 \n", - "340 Adelie Penguin (Pygoscelis adeliae) Biscoe 36.4 \n", - "341 Chinstrap penguin (Pygoscelis antarctica) Dream 40.9 \n", - "342 Adelie Penguin (Pygoscelis adeliae) Biscoe 41.3 \n", - "343 Chinstrap penguin (Pygoscelis antarctica) Dream 45.2 \n", + " species island culmen_length_mm \\\n", + "0 Gentoo penguin (Pygoscelis papua) Biscoe 45.2 \n", + "1 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 \n", + "2 Adelie Penguin (Pygoscelis adeliae) Biscoe 37.7 \n", + "3 Gentoo penguin (Pygoscelis papua) Biscoe 46.4 \n", + "4 Gentoo penguin (Pygoscelis papua) Biscoe 46.1 \n", + "5 Adelie Penguin (Pygoscelis adeliae) Torgersen 43.1 \n", + "6 Gentoo penguin (Pygoscelis papua) Biscoe 45.2 \n", + "7 Adelie Penguin (Pygoscelis adeliae) Dream 36.2 \n", + "8 Chinstrap penguin (Pygoscelis antarctica) Dream 46.0 \n", + "9 Gentoo penguin (Pygoscelis papua) Biscoe 54.3 \n", + "11 Adelie Penguin (Pygoscelis adeliae) Torgersen 39.5 \n", + "12 Gentoo penguin (Pygoscelis papua) Biscoe 42.7 \n", + "13 Adelie Penguin (Pygoscelis adeliae) Biscoe 41.0 \n", + "14 Gentoo penguin (Pygoscelis papua) Biscoe 48.5 \n", + "15 Chinstrap penguin (Pygoscelis antarctica) Dream 49.6 \n", + "16 Gentoo penguin (Pygoscelis papua) Biscoe 50.8 \n", + "17 Gentoo penguin (Pygoscelis papua) Biscoe 46.2 \n", + "18 Adelie Penguin (Pygoscelis adeliae) Biscoe 38.8 \n", + "19 Chinstrap penguin (Pygoscelis antarctica) Dream 51.0 \n", + "20 Gentoo penguin (Pygoscelis papua) Biscoe 42.9 \n", + "21 Gentoo penguin (Pygoscelis papua) Biscoe 50.4 \n", + "22 Gentoo penguin (Pygoscelis papua) Biscoe 49.0 \n", + "23 Gentoo penguin (Pygoscelis papua) Biscoe 43.4 \n", + "24 Gentoo penguin (Pygoscelis papua) Biscoe 45.0 \n", + "25 Gentoo penguin (Pygoscelis papua) Biscoe 47.5 \n", "\n", - " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "0 15.9 225.0 5400.0 MALE \n", - "1 14.5 215.0 5000.0 FEMALE \n", - "2 18.5 202.0 3875.0 MALE \n", - "3 17.0 188.0 2900.0 FEMALE \n", - "4 14.8 217.0 5200.0 FEMALE \n", - ".. ... ... ... ... \n", - "339 17.6 187.0 3425.0 FEMALE \n", - "340 17.1 184.0 2850.0 FEMALE \n", - "341 16.6 187.0 3200.0 FEMALE \n", - "342 21.1 195.0 4400.0 MALE \n", - "343 16.6 191.0 3250.0 FEMALE \n", + " culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "0 16.4 223.0 5950.0 MALE \n", + "1 14.5 213.0 4400.0 FEMALE \n", + "2 16.0 183.0 3075.0 FEMALE \n", + "3 15.6 221.0 5000.0 MALE \n", + "4 13.2 211.0 4500.0 FEMALE \n", + "5 19.2 197.0 3500.0 MALE \n", + "6 15.8 215.0 5300.0 MALE \n", + "7 17.3 187.0 3300.0 FEMALE \n", + "8 18.9 195.0 4150.0 FEMALE \n", + "9 15.7 231.0 5650.0 MALE \n", + "11 17.4 186.0 3800.0 FEMALE \n", + "12 13.7 208.0 3950.0 FEMALE \n", + "13 20.0 203.0 4725.0 MALE \n", + "14 15.0 219.0 4850.0 FEMALE \n", + "15 18.2 193.0 3775.0 MALE \n", + "16 17.3 228.0 5600.0 MALE \n", + "17 14.1 217.0 4375.0 FEMALE \n", + "18 17.2 180.0 3800.0 MALE \n", + "19 18.8 203.0 4100.0 MALE \n", + "20 13.1 215.0 5000.0 FEMALE \n", + "21 15.3 224.0 5550.0 MALE \n", + "22 16.1 216.0 5550.0 MALE \n", + "23 14.4 218.0 4600.0 FEMALE \n", + "24 15.4 220.0 5050.0 MALE \n", + "25 14.0 212.0 4875.0 FEMALE \n", "...\n", "\n", "[334 rows x 7 columns]" ] }, - "execution_count": 4, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -253,7 +425,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -277,7 +449,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ @@ -286,7 +458,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -297,37 +469,13 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "Query job 582e7c02-bcc6-412a-a513-46ee5dba7ad8 is DONE. 2.7 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 917ff09b-072b-4c55-b26f-1780e2e97519 is DONE. 25.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 2f4e102d-48bc-401f-a781-39830e2c6c9b is DONE. 16.4 kB processed. Open Job" + "Query job 9ce9fb43-306d-46e9-bbe5-d98ee55143bd is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -339,7 +487,7 @@ { "data": { "text/html": [ - "Query job aabe8a28-8dce-4e00-8a8c-18e9e090e6e7 is DONE. 26.3 kB processed. Open Job" + "Query job 8c86156d-ee97-4f66-9dc1-db15ff3d8e8e is DONE. 16.4 kB processed. Open Job" ], "text/plain": [ "" @@ -351,19 +499,7 @@ { "data": { "text/html": [ - "Query job ec9d8798-e28e-44bc-aa8e-44ab28f0214f is DONE. 48 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 8aa0fa94-e43e-41c6-9de3-f0a67392c47f is DONE. 48 Bytes processed. Open Job" + "Query job b8f2b382-b938-4dff-8bdb-129703ade285 is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -377,10 +513,10 @@ "output_type": "stream", "text": [ " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - "0 318.358226 151689.571141 0.009814 \n", + "0 297.36838 148892.914876 0.009057 \n", "\n", " median_absolute_error r2_score explained_variance \n", - "0 255.095561 0.780659 0.783304 \n", + "0 238.424052 0.814613 0.816053 \n", "\n", "[1 rows x 6 columns]\n" ] @@ -388,7 +524,7 @@ { "data": { "text/html": [ - "Query job bf6ef937-9583-4aa8-8313-563638465d5f is DONE. 25.9 kB processed. Open Job" + "Query job ec2968f3-1713-4617-8a26-6fe4267f8061 is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -400,7 +536,7 @@ { "data": { "text/html": [ - "Query job 4c8b564c-5bbd-4447-babf-e307524962e5 is DONE. 16.4 kB processed. Open Job" + "Query job c7a1b80f-26f5-41b1-bcdc-b276af141671 is DONE. 16.4 kB processed. Open Job" ], "text/plain": [ "" @@ -412,31 +548,7 @@ { "data": { "text/html": [ - "Query job cd5e337f-6d44-473d-a90b-be8a79bba6bf is DONE. 26.3 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job ad80012d-7c6c-4dbf-9271-2ff7f899f174 is DONE. 48 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 8fc20587-d8ba-4c0f-bed9-3e1cf3c6ae52 is DONE. 48 Bytes processed. Open Job" + "Query job 82054991-c22f-41b3-9802-f16919949e26 is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -450,10 +562,10 @@ "output_type": "stream", "text": [ " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - "0 306.435423 151573.84019 0.008539 \n", + "0 307.6149 139013.303482 0.007907 \n", "\n", " median_absolute_error r2_score explained_variance \n", - "0 244.2899 0.737623 0.742859 \n", + "0 266.589811 0.782835 0.794297 \n", "\n", "[1 rows x 6 columns]\n" ] @@ -461,7 +573,7 @@ { "data": { "text/html": [ - "Query job 90286d2b-e805-4b19-8876-c9973579e9ff is DONE. 25.9 kB processed. Open Job" + "Query job 3e5ae019-7c5b-44ea-8392-85145fdb6802 is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -473,7 +585,7 @@ { "data": { "text/html": [ - "Query job ceb6c8f2-16cc-4758-bde8-3e4975ba1452 is DONE. 16.4 kB processed. Open Job" + "Query job c35dfd28-504d-4d12-b039-da890b9cb51d is DONE. 16.5 kB processed. Open Job" ], "text/plain": [ "" @@ -485,31 +597,7 @@ { "data": { "text/html": [ - "Query job f49434fa-a7e0-406a-bbe2-5651595e3418 is DONE. 26.3 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 5dd7a277-10fe-4117-a354-ef8668a8b913 is DONE. 48 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 4b58b016-9a50-4a66-b86c-8431faad43bf is DONE. 48 Bytes processed. Open Job" + "Query job 29ac1bb3-f864-400e-8cac-0b4c7f78ebcd is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -523,10 +611,10 @@ "output_type": "stream", "text": [ " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - "0 253.349578 112039.741164 0.007153 \n", + "0 348.412701 180661.063512 0.01125 \n", "\n", " median_absolute_error r2_score explained_variance \n", - "0 185.916761 0.823381 0.823456 \n", + "0 313.29406 0.744053 0.74537 \n", "\n", "[1 rows x 6 columns]\n" ] @@ -534,7 +622,7 @@ { "data": { "text/html": [ - "Query job ca700ecf-0c08-4286-b979-2bc7a0bee89c is DONE. 25.9 kB processed. Open Job" + "Query job d90f5938-2894-4c93-8691-21162a2fca4c is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -546,7 +634,7 @@ { "data": { "text/html": [ - "Query job f0731e71-7754-47a2-a553-93a61e712533 is DONE. 16.4 kB processed. Open Job" + "Query job 4c6328b3-2d3f-42bb-9f83-4f8c84773c95 is DONE. 16.4 kB processed. Open Job" ], "text/plain": [ "" @@ -558,31 +646,7 @@ { "data": { "text/html": [ - "Query job ae66d34d-5f0a-4297-9d41-57067ae54a9b is DONE. 26.3 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 7655a649-ceca-4792-b764-fb371f5872ec is DONE. 48 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 8b0634c8-73a9-422c-9644-842142dbb059 is DONE. 48 Bytes processed. Open Job" + "Query job 8a885a6a-d3ad-4569-80ce-4f57d9b86105 is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -596,10 +660,10 @@ "output_type": "stream", "text": [ " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - "0 320.381386 155234.800349 0.008638 \n", + "0 309.991882 151820.705254 0.008898 \n", "\n", " median_absolute_error r2_score explained_variance \n", - "0 306.281263 0.793405 0.794504 \n", + "0 212.758708 0.694001 0.694287 \n", "\n", "[1 rows x 6 columns]\n" ] @@ -607,19 +671,7 @@ { "data": { "text/html": [ - "Query job bb26cde9-1991-4e0a-8492-b19d15b1b7aa is DONE. 25.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 7ddd0883-492d-46bc-a588-f3cbab2474bb is DONE. 16.5 kB processed. Open Job" + "Query job d1e60370-11c8-4f49-a8d5-85417662aa51 is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -631,7 +683,7 @@ { "data": { "text/html": [ - "Query job 5de571e4-d2f9-43c7-b014-3d65a3731b64 is DONE. 26.3 kB processed. Open Job" + "Query job d8e8712a-6347-4725-a27d-49810d4acc1c is DONE. 16.5 kB processed. Open Job" ], "text/plain": [ "" @@ -643,19 +695,7 @@ { "data": { "text/html": [ - "Query job d20ac7d8-cd21-4a1f-a200-2dfa6373bcdb is DONE. 48 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 235e8a80-33ea-4a95-a7d0-34e40a8ca396 is DONE. 48 Bytes processed. Open Job" + "Query job 6a0ebaa6-5572-404f-a41d-b90e2c65d948 is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -669,10 +709,10 @@ "output_type": "stream", "text": [ " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - "0 303.855563 141869.030392 0.008989 \n", + "0 256.569216 103495.042886 0.006605 \n", "\n", " median_absolute_error r2_score explained_variance \n", - "0 245.102301 0.731737 0.732793 \n", + "0 222.940815 0.818589 0.832344 \n", "\n", "[1 rows x 6 columns]\n" ] @@ -696,145 +736,13 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "Query job 9274ae2e-e9a7-4701-ac64-56632323d02a is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 22f9477b-de02-4c07-b480-c3270a69d7e0 is DONE. 25.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job ebb192b7-4a9e-4238-b4e6-b630e2f94988 is DONE. 16.5 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 44441e8c-8753-41b0-b1b7-9a6c4eab8c74 is DONE. 26.3 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 239fed9a-b488-47da-a0df-a3b7c6ec40f4 is DONE. 25.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job f4248b2d-3430-426c-872d-8590f2878366 is DONE. 16.4 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job d9f6b034-c300-4dd7-91dd-48fa912f2456 is DONE. 26.3 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job e2f39f5b-2f4c-402a-a8d5-a7cff918508d is DONE. 25.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 54cf3710-b5f4-4aec-b11f-0281126a151a is DONE. 16.4 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 833d13cd-ec59-499b-98f6-95ec18766698 is DONE. 26.3 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 0120e332-0691-44a4-9198-f5c131b8f59c is DONE. 25.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job f4ba7a4c-5fd9-4f97-ab34-a8f139e7472a is DONE. 16.4 kB processed. Open Job" + "Query job 5bdcd65d-7d72-4094-be3a-cf67a1787cf4 is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -846,7 +754,7 @@ { "data": { "text/html": [ - "Query job 857aadfc-2ade-429c-bef8-428e44d48c55 is DONE. 26.3 kB processed. Open Job" + "Query job bb0504b2-b656-4a08-9bf8-dcab0d188022 is DONE. 16.4 kB processed. Open Job" ], "text/plain": [ "" @@ -858,7 +766,7 @@ { "data": { "text/html": [ - "Query job 906d6d34-a506-4957-b07f-7e5ed2e0634b is DONE. 25.9 kB processed. Open Job" + "Query job 8c5c4b66-9a14-455a-a3f5-99f0f522713f is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -870,7 +778,7 @@ { "data": { "text/html": [ - "Query job 498563db-3e68-4df7-a2d5-83da6adb49ed is DONE. 16.5 kB processed. Open Job" + "Query job 9c9b81de-35b6-4561-8881-57da8b73cc7f is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -882,7 +790,7 @@ { "data": { "text/html": [ - "Query job 01af95ca-6288-4253-b379-7327e1c9de88 is DONE. 26.3 kB processed. Open Job" + "Query job b781f1aa-6572-49e5-ab8d-f1908b497a1c is DONE. 16.4 kB processed. Open Job" ], "text/plain": [ "" @@ -894,7 +802,7 @@ { "data": { "text/html": [ - "Query job 5ce36d32-6db1-42e5-a8cf-84bb8244a57e is DONE. 48 Bytes processed. Open Job" + "Query job 41a2a58e-0289-4d58-8e39-de286f2a91fb is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -906,7 +814,7 @@ { "data": { "text/html": [ - "Query job e05ec77d-6025-4edd-b5e3-9c4e7a124e71 is DONE. 48 Bytes processed. Open Job" + "Query job 7ee839a9-f77c-49b0-844e-8eecc1647b97 is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -918,7 +826,7 @@ { "data": { "text/html": [ - "Query job 418a4a5d-2bb3-41e5-9e7c-9852389a491b is DONE. 48 Bytes processed. Open Job" + "Query job a317d488-8589-4faa-940b-e59af91caf4d is DONE. 16.5 kB processed. Open Job" ], "text/plain": [ "" @@ -930,7 +838,7 @@ { "data": { "text/html": [ - "Query job b33e30da-cfed-4d6f-b227-f433d97879cb is DONE. 48 Bytes processed. Open Job" + "Query job 2de96ea8-519a-4976-a641-eb26a4bd38fb is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -942,7 +850,7 @@ { "data": { "text/html": [ - "Query job 7ad7f0c8-ecae-4ef2-bc91-0ebeb5f88e7b is DONE. 48 Bytes processed. Open Job" + "Query job 41a7d5a0-c76b-4ef3-a3da-d4d5a2ebbb0e is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -954,7 +862,7 @@ { "data": { "text/html": [ - "Query job a6e8bd12-1122-4c26-b0e1-58342238016c is DONE. 48 Bytes processed. Open Job" + "Query job 9e82ddc9-8461-4644-ba34-957a7426ff8e is DONE. 16.4 kB processed. Open Job" ], "text/plain": [ "" @@ -966,7 +874,7 @@ { "data": { "text/html": [ - "Query job c553439c-9586-479c-92c5-01a0d333125b is DONE. 48 Bytes processed. Open Job" + "Query job 0fa84d07-fdfa-41c9-b601-9326a94f3a09 is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -978,7 +886,7 @@ { "data": { "text/html": [ - "Query job c598d64c-26b9-49fc-afad-a6544b38cfa2 is DONE. 48 Bytes processed. Open Job" + "Query job d4495568-f1b5-431b-b892-4fc7dcbccfd5 is DONE. 37.0 kB processed. Open Job" ], "text/plain": [ "" @@ -990,7 +898,7 @@ { "data": { "text/html": [ - "Query job ebcb73e8-1294-4f10-b826-c495046fd714 is DONE. 48 Bytes processed. Open Job" + "Query job af1e6460-3078-4a8b-8992-9e7df9dcfbb3 is DONE. 16.5 kB processed. Open Job" ], "text/plain": [ "" @@ -1002,7 +910,7 @@ { "data": { "text/html": [ - "Query job d73f57ba-a25d-4b90-b474-13d81a3e22ab is DONE. 48 Bytes processed. Open Job" + "Query job f14401bf-fd80-401a-a61d-52614fba1ca7 is DONE. 37.3 kB processed. Open Job" ], "text/plain": [ "" @@ -1015,53 +923,53 @@ "data": { "text/plain": [ "{'test_score': [ mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - " 0 237.154735 97636.17064 0.005571 \n", + " 0 322.341485 157616.627179 0.009137 \n", " \n", " median_absolute_error r2_score explained_variance \n", - " 0 187.883888 0.842018 0.846816 \n", + " 0 269.412639 0.705594 0.724882 \n", " \n", " [1 rows x 6 columns],\n", " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - " 0 304.281635 141966.045867 0.008064 \n", + " 0 289.682121 136550.318797 0.00878 \n", " \n", " median_absolute_error r2_score explained_variance \n", - " 0 236.096453 0.762979 0.764008 \n", + " 0 212.874686 0.799363 0.81416 \n", " \n", " [1 rows x 6 columns],\n", " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - " 0 316.380322 157332.146085 0.009699 \n", + " 0 325.358522 155218.752974 0.009606 \n", " \n", " median_absolute_error r2_score explained_variance \n", - " 0 222.824496 0.764607 0.765369 \n", + " 0 267.301671 0.777174 0.7782 \n", " \n", " [1 rows x 6 columns],\n", " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - " 0 309.609657 152421.826588 0.009772 \n", + " 0 286.874056 120586.575364 0.007484 \n", " \n", " median_absolute_error r2_score explained_variance \n", - " 0 254.163976 0.772954 0.773119 \n", + " 0 247.656578 0.79281 0.796001 \n", " \n", " [1 rows x 6 columns],\n", " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - " 0 339.339345 169760.629993 0.010597 \n", + " 0 287.989397 145947.465344 0.008447 \n", " \n", " median_absolute_error r2_score explained_variance \n", - " 0 312.335706 0.741167 0.74118 \n", + " 0 186.777549 0.791452 0.798825 \n", " \n", " [1 rows x 6 columns]],\n", - " 'fit_time': [18.200648623984307,\n", - " 17.565149880945683,\n", - " 18.202434757025912,\n", - " 18.04062689607963,\n", - " 19.370970834977925],\n", - " 'score_time': [4.76077218609862,\n", - " 4.577479084953666,\n", - " 4.581933492794633,\n", - " 4.741644307971001,\n", - " 5.1031754210125655]}" + " 'fit_time': [18.79181448201416,\n", + " 19.092008439009078,\n", + " 75.7446747609647,\n", + " 17.520530884969048,\n", + " 21.157033596013207],\n", + " 'score_time': [4.247669544012751,\n", + " 6.792615927988663,\n", + " 4.502274781989399,\n", + " 4.484583999030292,\n", + " 4.224339194013737]}" ] }, - "execution_count": 10, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -1097,7 +1005,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.14" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/notebooks/ml/bq_dataframes_ml_linear_regression.ipynb b/notebooks/ml/bq_dataframes_ml_linear_regression.ipynb index fad2f00b31..00aa7a347c 100644 --- a/notebooks/ml/bq_dataframes_ml_linear_regression.ipynb +++ b/notebooks/ml/bq_dataframes_ml_linear_regression.ipynb @@ -35,24 +35,24 @@ "\n", "\n", " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", "
\n", - " \n", - " \"Colab Run in Colab\n", + " \n", + " \"Colab Run in Colab\n", " \n", " \n", - " \n", - " \"GitHub\n", + " \n", + " \"GitHub\n", " View on GitHub\n", " \n", " \n", - " \n", + " \n", " \"Vertex\n", " Open in Vertex AI Workbench\n", " \n", " \n", - " \n", + " \n", " \"BQ\n", " Open in BQ Studio\n", " \n", @@ -79,7 +79,7 @@ "source": [ "## Overview\n", "\n", - "Use this notebook to learn how to train a linear regression model by using BigQuery DataFrames ML. BigQuery DataFrames ML provides a provides a scikit-learn-like API for ML powered by the BigQuery engine.\n", + "Use this notebook to learn how to train a linear regression model using BigQuery DataFrames ML. BigQuery DataFrames ML provides a provides a scikit-learn-like API for ML powered by the BigQuery engine.\n", "\n", "This example is adapted from the [BQML linear regression tutorial](https://cloud.google.com/bigquery-ml/docs/linear-regression-tutorial).\n", "\n", @@ -142,7 +142,10 @@ "source": [ "## Installation\n", "\n", - "Install the following packages, which are required to run this notebook:" + "If you don't have [bigframes](https://pypi.org/project/bigframes/) package already installed, uncomment and execute the following cells to\n", + "\n", + "1. Install the package\n", + "1. Restart the notebook kernel (Jupyter or Colab) to work with the package" ] }, { @@ -153,18 +156,7 @@ }, "outputs": [], "source": [ - "!pip install bigframes" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "58707a750154" - }, - "source": [ - "### Colab only\n", - "\n", - "Uncomment and run the following cell to restart the kernel:" + "# !pip install bigframes" ] }, { @@ -749,6 +741,18 @@ "kernelspec": { "display_name": "Python 3", "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.0" } }, "nbformat": 4, diff --git a/notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb b/notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb new file mode 100644 index 0000000000..6be836c6f8 --- /dev/null +++ b/notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb @@ -0,0 +1,2637 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "id": "ur8xi4C7S06n" + }, + "outputs": [], + "source": [ + "# Copyright 2023 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "JAPoU8Sm5E6e" + }, + "source": [ + "## Train a linear regression model with BigQuery DataFrames ML\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"Vertex\n", + " Open in Vertex AI Workbench\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "24743cf4a1e1" + }, + "source": [ + "**_NOTE_**: This notebook has been tested in the following environment:\n", + "\n", + "* Python version = 3.10" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "tvgnzT1CKxrO" + }, + "source": [ + "## Overview\n", + "\n", + "Use this notebook to learn how to train a linear regression model using BigQuery ML and the `bigframes.bigquery` module.\n", + "\n", + "This example is adapted from the [BQML linear regression tutorial](https://cloud.google.com/bigquery-ml/docs/linear-regression-tutorial).\n", + "\n", + "Learn more about [BigQuery DataFrames](https://dataframes.bigquery.dev/)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "d975e698c9a4" + }, + "source": [ + "### Objective\n", + "\n", + "In this tutorial, you use BigQuery DataFrames to create a linear regression model that predicts the weight of an Adelie penguin based on the penguin's island of residence, culmen length and depth, flipper length, and sex.\n", + "\n", + "The steps include:\n", + "\n", + "- Creating a DataFrame from a BigQuery table.\n", + "- Cleaning and preparing data using pandas.\n", + "- Creating a linear regression model using `bigframes.ml`.\n", + "- Saving the ML model to BigQuery for future use." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "08d289fa873f" + }, + "source": [ + "### Dataset\n", + "\n", + "This tutorial uses the [```penguins``` table](https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=ml_datasets&t=penguins) (a BigQuery Public Dataset) which includes data on a set of penguins including species, island of residence, weight, culmen length and depth, flipper length, and sex." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "aed92deeb4a0" + }, + "source": [ + "### Costs\n", + "\n", + "This tutorial uses billable components of Google Cloud:\n", + "\n", + "* BigQuery (compute)\n", + "* BigQuery ML\n", + "\n", + "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models)\n", + "and [BigQuery ML pricing](https://cloud.google.com/bigquery/pricing#bqml),\n", + "and use the [Pricing Calculator](https://cloud.google.com/products/calculator/)\n", + "to generate a cost estimate based on your projected usage." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "i7EUnXsZhAGF" + }, + "source": [ + "## Installation\n", + "\n", + "If you don't have [bigframes](https://pypi.org/project/bigframes/) package already installed, uncomment and execute the following cells to\n", + "\n", + "1. Install the package\n", + "1. Restart the notebook kernel (Jupyter or Colab) to work with the package" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "id": "9O0Ka4W2MNF3" + }, + "outputs": [], + "source": [ + "# !pip install bigframes" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "id": "f200f10a1da3" + }, + "outputs": [], + "source": [ + "# Automatically restart kernel after installs so that your environment can access the new packages\n", + "# import IPython\n", + "\n", + "# app = IPython.Application.instance()\n", + "# app.kernel.do_shutdown(True)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "BF1j6f9HApxa" + }, + "source": [ + "## Before you begin\n", + "\n", + "Complete the tasks in this section to set up your environment." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "oDfTjfACBvJk" + }, + "source": [ + "### Set up your Google Cloud project\n", + "\n", + "**The following steps are required, regardless of your notebook environment.**\n", + "\n", + "1. [Select or create a Google Cloud project](https://console.cloud.google.com/cloud-resource-manager). When you first create an account, you get a $300 credit towards your compute/storage costs.\n", + "\n", + "2. [Make sure that billing is enabled for your project](https://cloud.google.com/billing/docs/how-to/modify-project).\n", + "\n", + "3. [Enable the BigQuery API](https://console.cloud.google.com/flows/enableapi?apiid=bigquery.googleapis.com).\n", + "\n", + "4. If you are running this notebook locally, install the [Cloud SDK](https://cloud.google.com/sdk)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "WReHDGG5g0XY" + }, + "source": [ + "#### Set your project ID\n", + "\n", + "If you don't know your project ID, try the following:\n", + "* Run `gcloud config list`.\n", + "* Run `gcloud projects list`.\n", + "* See the support page: [Locate the project ID](https://support.google.com/googleapi/answer/7014113)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "oM1iC_MfAts1" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Updated property [core/project].\n" + ] + } + ], + "source": [ + "PROJECT_ID = \"\" # @param {type:\"string\"}\n", + "\n", + "# Set the project id\n", + "! gcloud config set project {PROJECT_ID}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "region" + }, + "source": [ + "#### Set the region\n", + "\n", + "You can also change the `REGION` variable used by BigQuery. Learn more about [BigQuery regions](https://cloud.google.com/bigquery/docs/locations#supported_locations)." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "id": "eF-Twtc4XGem" + }, + "outputs": [], + "source": [ + "REGION = \"US\" # @param {type: \"string\"}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "sBCra4QMA2wR" + }, + "source": [ + "### Authenticate your Google Cloud account\n", + "\n", + "Depending on your Jupyter environment, you might have to manually authenticate. Follow the relevant instructions below." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "74ccc9e52986" + }, + "source": [ + "**Vertex AI Workbench**\n", + "\n", + "Do nothing, you are already authenticated." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "de775a3773ba" + }, + "source": [ + "**Local JupyterLab instance**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "id": "254614fa0c46" + }, + "outputs": [], + "source": [ + "# ! gcloud auth login" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "ef21552ccea8" + }, + "source": [ + "**Colab**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "id": "603adbbf0532" + }, + "outputs": [], + "source": [ + "# from google.colab import auth\n", + "# auth.authenticate_user()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "960505627ddf" + }, + "source": [ + "### Import libraries" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "id": "PyQmSRbKA8r-" + }, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "init_aip:mbsdk,all" + }, + "source": [ + "### Set BigQuery DataFrames options" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "id": "NPPMuw2PXGeo" + }, + "outputs": [], + "source": [ + "# Note: The project option is not required in all environments.\n", + "# On BigQuery Studio, the project ID is automatically detected.\n", + "bpd.options.bigquery.project = PROJECT_ID\n", + "\n", + "# Note: The location option is not required.\n", + "# It defaults to the location of the first table or query\n", + "# passed to read_gbq(). For APIs where a location can't be\n", + "# auto-detected, the location defaults to the \"US\" location.\n", + "bpd.options.bigquery.location = REGION\n", + "\n", + "# Recommended for performance. Disables pandas default ordering of all rows.\n", + "bpd.options.bigquery.ordering_mode = \"partial\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "D21CoOlfFTYI" + }, + "source": [ + "If you want to reset the location of the created DataFrame or Series objects, reset the session by executing `bpd.close_session()`. After that, you can reuse `bpd.options.bigquery.location` to specify another location." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "9EMAqR37AfLS" + }, + "source": [ + "## Read a BigQuery table into a BigQuery DataFrames DataFrame\n", + "\n", + "Read the [```penguins``` table](https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=ml_datasets&t=penguins) into a BigQuery DataFrames DataFrame:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "id": "EDAaIwHpQCDZ" + }, + "outputs": [], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.ml_datasets.penguins\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "DJu837YEXD7B" + }, + "source": [ + "Take a look at the DataFrame:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "id": "_gPD0Zn1Stdb" + }, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "index", + "rawType": "int64", + "type": "integer" + }, + { + "name": "species", + "rawType": "string", + "type": "string" + }, + { + "name": "island", + "rawType": "string", + "type": "string" + }, + { + "name": "culmen_length_mm", + "rawType": "Float64", + "type": "float" + }, + { + "name": "culmen_depth_mm", + "rawType": "Float64", + "type": "float" + }, + { + "name": "flipper_length_mm", + "rawType": "Float64", + "type": "float" + }, + { + "name": "body_mass_g", + "rawType": "Float64", + "type": "float" + }, + { + "name": "sex", + "rawType": "string", + "type": "string" + } + ], + "ref": "a652ba52-0445-4228-a2d5-baf837933515", + "rows": [ + [ + "0", + "Adelie Penguin (Pygoscelis adeliae)", + "Dream", + "36.6", + "18.4", + "184.0", + "3475.0", + "FEMALE" + ], + [ + "1", + "Adelie Penguin (Pygoscelis adeliae)", + "Dream", + "39.8", + "19.1", + "184.0", + "4650.0", + "MALE" + ], + [ + "2", + "Adelie Penguin (Pygoscelis adeliae)", + "Dream", + "40.9", + "18.9", + "184.0", + "3900.0", + "MALE" + ], + [ + "3", + "Chinstrap penguin (Pygoscelis antarctica)", + "Dream", + "46.5", + "17.9", + "192.0", + "3500.0", + "FEMALE" + ], + [ + "4", + "Adelie Penguin (Pygoscelis adeliae)", + "Dream", + "37.3", + "16.8", + "192.0", + "3000.0", + "FEMALE" + ] + ], + "shape": { + "columns": 7, + "rows": 5 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
speciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
0Adelie Penguin (Pygoscelis adeliae)Dream36.618.4184.03475.0FEMALE
1Adelie Penguin (Pygoscelis adeliae)Dream39.819.1184.04650.0MALE
2Adelie Penguin (Pygoscelis adeliae)Dream40.918.9184.03900.0MALE
3Chinstrap penguin (Pygoscelis antarctica)Dream46.517.9192.03500.0FEMALE
4Adelie Penguin (Pygoscelis adeliae)Dream37.316.8192.03000.0FEMALE
\n", + "
" + ], + "text/plain": [ + " species island culmen_length_mm \\\n", + "0 Adelie Penguin (Pygoscelis adeliae) Dream 36.6 \n", + "1 Adelie Penguin (Pygoscelis adeliae) Dream 39.8 \n", + "2 Adelie Penguin (Pygoscelis adeliae) Dream 40.9 \n", + "3 Chinstrap penguin (Pygoscelis antarctica) Dream 46.5 \n", + "4 Adelie Penguin (Pygoscelis adeliae) Dream 37.3 \n", + "\n", + " culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "0 18.4 184.0 3475.0 FEMALE \n", + "1 19.1 184.0 4650.0 MALE \n", + "2 18.9 184.0 3900.0 MALE \n", + "3 17.9 192.0 3500.0 FEMALE \n", + "4 16.8 192.0 3000.0 FEMALE " + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.peek()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "rwPLjqW2Ajzh" + }, + "source": [ + "## Clean and prepare data\n", + "\n", + "You can use pandas as you normally would on the BigQuery DataFrames DataFrame, but calculations happen in the BigQuery query engine instead of your local environment.\n", + "\n", + "Because this model will focus on the Adelie Penguin species, you need to filter the data for only those rows representing Adelie penguins. Then you drop the `species` column because it is no longer needed.\n", + "\n", + "As these functions are applied, only the new DataFrame object `adelie_data` is modified. The source table and the original DataFrame object `df` don't change." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "id": "6i6HkFJZa8na" + }, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 28.9 kB in 12 seconds of slot time. [Job bigframes-dev:US.bb256e8c-f2c7-4eff-b5f3-fcc6836110cf details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 8.4 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
islandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
0Dream36.618.4184.03475.0FEMALE
1Dream39.819.1184.04650.0MALE
2Dream40.918.9184.03900.0MALE
3Dream37.316.8192.03000.0FEMALE
4Dream43.218.5192.04100.0MALE
5Dream40.220.1200.03975.0MALE
6Dream40.818.9208.04300.0MALE
7Dream39.018.7185.03650.0MALE
8Dream37.016.9185.03000.0FEMALE
9Dream34.017.1185.03400.0FEMALE
\n", + "

10 rows × 6 columns

\n", + "
[152 rows x 6 columns in total]" + ], + "text/plain": [ + "island culmen_length_mm culmen_depth_mm flipper_length_mm body_mass_g \\\n", + " Dream 36.6 18.4 184.0 3475.0 \n", + " Dream 39.8 19.1 184.0 4650.0 \n", + " Dream 40.9 18.9 184.0 3900.0 \n", + " Dream 37.3 16.8 192.0 3000.0 \n", + " Dream 43.2 18.5 192.0 4100.0 \n", + " Dream 40.2 20.1 200.0 3975.0 \n", + " Dream 40.8 18.9 208.0 4300.0 \n", + " Dream 39.0 18.7 185.0 3650.0 \n", + " Dream 37.0 16.9 185.0 3000.0 \n", + " Dream 34.0 17.1 185.0 3400.0 \n", + "\n", + " sex \n", + "FEMALE \n", + " MALE \n", + " MALE \n", + "FEMALE \n", + " MALE \n", + " MALE \n", + " MALE \n", + " MALE \n", + "FEMALE \n", + "FEMALE \n", + "...\n", + "\n", + "[152 rows x 6 columns]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Filter down to the data to the Adelie Penguin species\n", + "adelie_data = df[df.species == \"Adelie Penguin (Pygoscelis adeliae)\"]\n", + "\n", + "# Drop the species column\n", + "adelie_data = adelie_data.drop(columns=[\"species\"])\n", + "\n", + "# Take a look at the filtered DataFrame\n", + "adelie_data" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "jhK2OlyMbY4L" + }, + "source": [ + "Drop rows with `NULL` values in order to create a BigQuery DataFrames DataFrame for the training data:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "id": "0am3hdlXZfxZ" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Starting." + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 8.1 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
islandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
0Dream36.618.4184.03475.0FEMALE
1Dream39.819.1184.04650.0MALE
2Dream40.918.9184.03900.0MALE
3Dream37.316.8192.03000.0FEMALE
4Dream43.218.5192.04100.0MALE
5Dream40.220.1200.03975.0MALE
6Dream40.818.9208.04300.0MALE
7Dream39.018.7185.03650.0MALE
8Dream37.016.9185.03000.0FEMALE
9Dream34.017.1185.03400.0FEMALE
\n", + "

10 rows × 6 columns

\n", + "
[146 rows x 6 columns in total]" + ], + "text/plain": [ + "island culmen_length_mm culmen_depth_mm flipper_length_mm body_mass_g \\\n", + " Dream 36.6 18.4 184.0 3475.0 \n", + " Dream 39.8 19.1 184.0 4650.0 \n", + " Dream 40.9 18.9 184.0 3900.0 \n", + " Dream 37.3 16.8 192.0 3000.0 \n", + " Dream 43.2 18.5 192.0 4100.0 \n", + " Dream 40.2 20.1 200.0 3975.0 \n", + " Dream 40.8 18.9 208.0 4300.0 \n", + " Dream 39.0 18.7 185.0 3650.0 \n", + " Dream 37.0 16.9 185.0 3000.0 \n", + " Dream 34.0 17.1 185.0 3400.0 \n", + "\n", + " sex \n", + "FEMALE \n", + " MALE \n", + " MALE \n", + "FEMALE \n", + " MALE \n", + " MALE \n", + " MALE \n", + " MALE \n", + "FEMALE \n", + "FEMALE \n", + "...\n", + "\n", + "[146 rows x 6 columns]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Drop rows with nulls to get training data\n", + "training_data = adelie_data.dropna()\n", + "\n", + "# Take a peek at the training data\n", + "training_data" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Fx4lsNqMorJ-" + }, + "source": [ + "## Create the linear regression model\n", + "\n", + "In this notebook, you create a linear regression model, a type of regression model that generates a continuous value from a linear combination of input features." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a BigQuery dataset to house the model, adding a name for your dataset as the `DATASET_ID` variable:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Dataset bqml_tutorial created.\n" + ] + } + ], + "source": [ + "DATASET_ID = \"bqml_tutorial\" # @param {type:\"string\"}\n", + "\n", + "from google.cloud import bigquery\n", + "client = bigquery.Client(project=PROJECT_ID)\n", + "dataset = bigquery.Dataset(PROJECT_ID + \".\" + DATASET_ID)\n", + "dataset.location = REGION\n", + "dataset = client.create_dataset(dataset, exists_ok=True)\n", + "print(f\"Dataset {dataset.dataset_id} created.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "EloGtMnverFF" + }, + "source": [ + "### Create the model using `bigframes.bigquery.ml.create_model`\n", + "\n", + "When you pass the feature columns without transforms, BigQuery ML uses\n", + "[automatic preprocessing](https://cloud.google.com/bigquery/docs/auto-preprocessing) to encode string values and scale numeric values.\n", + "\n", + "BigQuery ML also [automatically splits the data for training and evaluation](https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-create-glm#data_split_method), although for datasets with less than 500 rows (such as this one), all rows are used for training." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "id": "GskyyUQPowBT" + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " Query started with request ID bigframes-dev:US.a33b3628-730b-46e8-ad17-c78bb48619ce.
SQL
CREATE OR REPLACE MODEL `bigframes-dev.bqml_tutorial.penguin_weight`\n",
+              "OPTIONS(model_type = 'LINEAR_REG')\n",
+              "AS SELECT\n",
+              "`bfuid_col_3` AS `island`,\n",
+              "`bfuid_col_4` AS `culmen_length_mm`,\n",
+              "`bfuid_col_5` AS `culmen_depth_mm`,\n",
+              "`bfuid_col_6` AS `flipper_length_mm`,\n",
+              "`bfuid_col_7` AS `label`,\n",
+              "`bfuid_col_8` AS `sex`\n",
+              "FROM\n",
+              "(SELECT\n",
+              "  `t0`.`bfuid_col_3`,\n",
+              "  `t0`.`bfuid_col_4`,\n",
+              "  `t0`.`bfuid_col_5`,\n",
+              "  `t0`.`bfuid_col_6`,\n",
+              "  `t0`.`bfuid_col_7`,\n",
+              "  `t0`.`bfuid_col_8`\n",
+              "FROM `bigframes-dev._63cfa399614a54153cc386c27d6c0c6fdb249f9e._e154f0aa_5b29_492a_b464_a77c5f5a3dbd_bqdf_60fa3196-5a3e-45ae-898e-c2b473bfa1e9` AS `t0`)\n",
+              "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "index", + "rawType": "object", + "type": "string" + }, + { + "name": "0", + "rawType": "object", + "type": "unknown" + } + ], + "ref": "851c170c-08a5-4c06-8c0b-4547dbde3f18", + "rows": [ + [ + "etag", + "P3XS+g0ZZM19ywL+hdwUmQ==" + ], + [ + "modelReference", + "{'projectId': 'bigframes-dev', 'datasetId': 'bqml_tutorial', 'modelId': 'penguin_weight'}" + ], + [ + "creationTime", + "1764779445166" + ], + [ + "lastModifiedTime", + "1764779445237" + ], + [ + "modelType", + "LINEAR_REGRESSION" + ], + [ + "trainingRuns", + "[{'trainingOptions': {'lossType': 'MEAN_SQUARED_LOSS', 'l2Regularization': 0, 'inputLabelColumns': ['label'], 'dataSplitMethod': 'AUTO_SPLIT', 'optimizationStrategy': 'NORMAL_EQUATION', 'calculatePValues': False, 'enableGlobalExplain': False, 'categoryEncodingMethod': 'ONE_HOT_ENCODING', 'fitIntercept': True, 'standardizeFeatures': True}, 'trainingStartTime': '1764779429690', 'results': [{'index': 0, 'durationMs': '3104', 'trainingLoss': 78553.60163372214}], 'evaluationMetrics': {'regressionMetrics': {'meanAbsoluteError': 223.87876300779865, 'meanSquaredError': 78553.60163372215, 'meanSquaredLogError': 0.005614202871872688, 'medianAbsoluteError': 181.33091105963013, 'rSquared': 0.6239507555914934}}, 'startTime': '2025-12-03T16:30:29.690Z'}]" + ], + [ + "featureColumns", + "[{'name': 'island', 'type': {'typeKind': 'STRING'}}, {'name': 'culmen_length_mm', 'type': {'typeKind': 'FLOAT64'}}, {'name': 'culmen_depth_mm', 'type': {'typeKind': 'FLOAT64'}}, {'name': 'flipper_length_mm', 'type': {'typeKind': 'FLOAT64'}}, {'name': 'sex', 'type': {'typeKind': 'STRING'}}]" + ], + [ + "labelColumns", + "[{'name': 'predicted_label', 'type': {'typeKind': 'FLOAT64'}}]" + ], + [ + "location", + "US" + ] + ], + "shape": { + "columns": 1, + "rows": 9 + } + }, + "text/plain": [ + "etag P3XS+g0ZZM19ywL+hdwUmQ==\n", + "modelReference {'projectId': 'bigframes-dev', 'datasetId': 'b...\n", + "creationTime 1764779445166\n", + "lastModifiedTime 1764779445237\n", + "modelType LINEAR_REGRESSION\n", + "trainingRuns [{'trainingOptions': {'lossType': 'MEAN_SQUARE...\n", + "featureColumns [{'name': 'island', 'type': {'typeKind': 'STRI...\n", + "labelColumns [{'name': 'predicted_label', 'type': {'typeKin...\n", + "location US\n", + "dtype: object" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import bigframes.bigquery as bbq\n", + "\n", + "model_name = f\"{PROJECT_ID}.{DATASET_ID}.penguin_weight\"\n", + "model_metadata = bbq.ml.create_model(\n", + " model_name,\n", + " replace=True,\n", + " options={\n", + " \"model_type\": \"LINEAR_REG\",\n", + " },\n", + " training_data=training_data.rename(columns={\"body_mass_g\": \"label\"})\n", + ")\n", + "model_metadata" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GskyyUQPowBT" + }, + "source": [ + "### Evaluate the model\n", + "\n", + "Check how the model performed by using the `evalutate` function. More information on model evaluation can be found [here](https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-evaluate#mlevaluate_output)." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "id": "kGBJKafpo0dl" + }, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
mean_absolute_errormean_squared_errormean_squared_log_errormedian_absolute_errorr2_scoreexplained_variance
0223.87876378553.6016340.005614181.3309110.6239510.623951
\n", + "

1 rows × 6 columns

\n", + "
[1 rows x 6 columns in total]" + ], + "text/plain": [ + " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", + "0 223.878763 78553.601634 0.005614 \n", + "\n", + " median_absolute_error r2_score explained_variance \n", + "0 181.330911 0.623951 0.623951 \n", + "\n", + "[1 rows x 6 columns]" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.ml.evaluate(model_name)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "P2lUiZZ_cjri" + }, + "source": [ + "### Use the model to predict outcomes\n", + "\n", + "Now that you have evaluated your model, the next step is to use it to predict an\n", + "outcome. You can run `bigframes.bigquery.ml.predict` function on the model to\n", + "predict the body mass in grams of all penguins that reside on the Biscoe\n", + "Islands." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "id": "bsQ9cmoWo0Ps" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: TimeTravelCacheWarning: Reading cached table from 2025-12-03 16:30:18.272882+00:00 to avoid\n", + "incompatibilies with previous reads of this table. To read the latest\n", + "version, set `use_cache=False` or close the current session with\n", + "Session.close() or bigframes.pandas.close_session().\n", + " return method(*args, **kwargs)\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 29.3 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
predicted_labelspeciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
03945.010052Gentoo penguin (Pygoscelis papua)Biscoe<NA><NA><NA><NA><NA>
13914.916297Adelie Penguin (Pygoscelis adeliae)Biscoe39.718.9184.03550.0MALE
23278.611224Adelie Penguin (Pygoscelis adeliae)Biscoe36.417.1184.02850.0FEMALE
34006.367355Adelie Penguin (Pygoscelis adeliae)Biscoe41.618.0192.03950.0MALE
43417.610478Adelie Penguin (Pygoscelis adeliae)Biscoe35.017.9192.03725.0FEMALE
54009.612421Adelie Penguin (Pygoscelis adeliae)Biscoe41.118.2192.04050.0MALE
64231.330911Adelie Penguin (Pygoscelis adeliae)Biscoe42.019.5200.04050.0MALE
73554.308906Gentoo penguin (Pygoscelis papua)Biscoe43.813.9208.04300.0FEMALE
83550.677455Gentoo penguin (Pygoscelis papua)Biscoe43.314.0208.04575.0FEMALE
93537.882543Gentoo penguin (Pygoscelis papua)Biscoe44.013.6208.04350.0FEMALE
\n", + "

10 rows × 8 columns

\n", + "
[168 rows x 8 columns in total]" + ], + "text/plain": [ + " predicted_label species island \\\n", + "0 3945.010052 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "1 3914.916297 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "2 3278.611224 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "3 4006.367355 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "4 3417.610478 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "5 4009.612421 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "6 4231.330911 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "7 3554.308906 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "8 3550.677455 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "9 3537.882543 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "\n", + " culmen_length_mm culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "0 \n", + "1 39.7 18.9 184.0 3550.0 MALE \n", + "2 36.4 17.1 184.0 2850.0 FEMALE \n", + "3 41.6 18.0 192.0 3950.0 MALE \n", + "4 35.0 17.9 192.0 3725.0 FEMALE \n", + "5 41.1 18.2 192.0 4050.0 MALE \n", + "6 42.0 19.5 200.0 4050.0 MALE \n", + "7 43.8 13.9 208.0 4300.0 FEMALE \n", + "8 43.3 14.0 208.0 4575.0 FEMALE \n", + "9 44.0 13.6 208.0 4350.0 FEMALE \n", + "...\n", + "\n", + "[168 rows x 8 columns]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.ml_datasets.penguins\")\n", + "biscoe = df[df[\"island\"].str.contains(\"Biscoe\")]\n", + "bbq.ml.predict(model_name, biscoe)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GTRdUw-Ro5R1" + }, + "source": [ + "### Explain the prediction results\n", + "\n", + "To understand why the model is generating these prediction results, you can use the `explain_predict` function." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " Query started with request ID bigframes-dev:US.161bba69-c852-4916-a2df-bb5b309be6e4.
SQL
SELECT * FROM ML.EXPLAIN_PREDICT(MODEL `bigframes-dev.bqml_tutorial.penguin_weight`, (SELECT\n",
+              "`bfuid_col_22` AS `species`,\n",
+              "`bfuid_col_23` AS `island`,\n",
+              "`bfuid_col_24` AS `culmen_length_mm`,\n",
+              "`bfuid_col_25` AS `culmen_depth_mm`,\n",
+              "`bfuid_col_26` AS `flipper_length_mm`,\n",
+              "`bfuid_col_27` AS `body_mass_g`,\n",
+              "`bfuid_col_28` AS `sex`\n",
+              "FROM\n",
+              "(SELECT\n",
+              "  `t0`.`species`,\n",
+              "  `t0`.`island`,\n",
+              "  `t0`.`culmen_length_mm`,\n",
+              "  `t0`.`culmen_depth_mm`,\n",
+              "  `t0`.`flipper_length_mm`,\n",
+              "  `t0`.`body_mass_g`,\n",
+              "  `t0`.`sex`,\n",
+              "  `t0`.`species` AS `bfuid_col_22`,\n",
+              "  `t0`.`island` AS `bfuid_col_23`,\n",
+              "  `t0`.`culmen_length_mm` AS `bfuid_col_24`,\n",
+              "  `t0`.`culmen_depth_mm` AS `bfuid_col_25`,\n",
+              "  `t0`.`flipper_length_mm` AS `bfuid_col_26`,\n",
+              "  `t0`.`body_mass_g` AS `bfuid_col_27`,\n",
+              "  `t0`.`sex` AS `bfuid_col_28`,\n",
+              "  regexp_contains(`t0`.`island`, 'Biscoe') AS `bfuid_col_29`\n",
+              "FROM (\n",
+              "  SELECT\n",
+              "    `species`,\n",
+              "    `island`,\n",
+              "    `culmen_length_mm`,\n",
+              "    `culmen_depth_mm`,\n",
+              "    `flipper_length_mm`,\n",
+              "    `body_mass_g`,\n",
+              "    `sex`\n",
+              "  FROM `bigquery-public-data.ml_datasets.penguins` FOR SYSTEM_TIME AS OF TIMESTAMP('2025-12-03T16:30:18.272882+00:00')\n",
+              ") AS `t0`\n",
+              "WHERE\n",
+              "  regexp_contains(`t0`.`island`, 'Biscoe'))), STRUCT(3 AS top_k_features))\n",
+              "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
predicted_labeltop_feature_attributionsbaseline_prediction_valueprediction_valueapproximation_errorspeciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
03945.010052[{'feature': 'island', 'attribution': 0.0}\n", + " {'...3945.0100523945.0100520.0Gentoo penguin (Pygoscelis papua)Biscoe<NA><NA><NA><NA><NA>
13914.916297[{'feature': 'flipper_length_mm', 'attribution...3945.0100523914.9162970.0Adelie Penguin (Pygoscelis adeliae)Biscoe39.718.9184.03550.0MALE
23278.611224[{'feature': 'sex', 'attribution': -443.175184...3945.0100523278.6112240.0Adelie Penguin (Pygoscelis adeliae)Biscoe36.417.1184.02850.0FEMALE
34006.367355[{'feature': 'culmen_length_mm', 'attribution'...3945.0100524006.3673550.0Adelie Penguin (Pygoscelis adeliae)Biscoe41.618.0192.03950.0MALE
43417.610478[{'feature': 'sex', 'attribution': -443.175184...3945.0100523417.6104780.0Adelie Penguin (Pygoscelis adeliae)Biscoe35.017.9192.03725.0FEMALE
54009.612421[{'feature': 'culmen_length_mm', 'attribution'...3945.0100524009.6124210.0Adelie Penguin (Pygoscelis adeliae)Biscoe41.118.2192.04050.0MALE
64231.330911[{'feature': 'flipper_length_mm', 'attribution...3945.0100524231.3309110.0Adelie Penguin (Pygoscelis adeliae)Biscoe42.019.5200.04050.0MALE
73554.308906[{'feature': 'sex', 'attribution': -443.175184...3945.0100523554.3089060.0Gentoo penguin (Pygoscelis papua)Biscoe43.813.9208.04300.0FEMALE
83550.677455[{'feature': 'sex', 'attribution': -443.175184...3945.0100523550.6774550.0Gentoo penguin (Pygoscelis papua)Biscoe43.314.0208.04575.0FEMALE
93537.882543[{'feature': 'sex', 'attribution': -443.175184...3945.0100523537.8825430.0Gentoo penguin (Pygoscelis papua)Biscoe44.013.6208.04350.0FEMALE
\n", + "

10 rows × 12 columns

\n", + "
[168 rows x 12 columns in total]" + ], + "text/plain": [ + " predicted_label top_feature_attributions \\\n", + "0 3945.010052 [{'feature': 'island', 'attribution': 0.0}\n", + " {'... \n", + "1 3914.916297 [{'feature': 'flipper_length_mm', 'attribution... \n", + "2 3278.611224 [{'feature': 'sex', 'attribution': -443.175184... \n", + "3 4006.367355 [{'feature': 'culmen_length_mm', 'attribution'... \n", + "4 3417.610478 [{'feature': 'sex', 'attribution': -443.175184... \n", + "5 4009.612421 [{'feature': 'culmen_length_mm', 'attribution'... \n", + "6 4231.330911 [{'feature': 'flipper_length_mm', 'attribution... \n", + "7 3554.308906 [{'feature': 'sex', 'attribution': -443.175184... \n", + "8 3550.677455 [{'feature': 'sex', 'attribution': -443.175184... \n", + "9 3537.882543 [{'feature': 'sex', 'attribution': -443.175184... \n", + "\n", + " baseline_prediction_value prediction_value approximation_error \\\n", + "0 3945.010052 3945.010052 0.0 \n", + "1 3945.010052 3914.916297 0.0 \n", + "2 3945.010052 3278.611224 0.0 \n", + "3 3945.010052 4006.367355 0.0 \n", + "4 3945.010052 3417.610478 0.0 \n", + "5 3945.010052 4009.612421 0.0 \n", + "6 3945.010052 4231.330911 0.0 \n", + "7 3945.010052 3554.308906 0.0 \n", + "8 3945.010052 3550.677455 0.0 \n", + "9 3945.010052 3537.882543 0.0 \n", + "\n", + " species island culmen_length_mm \\\n", + "0 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "1 Adelie Penguin (Pygoscelis adeliae) Biscoe 39.7 \n", + "2 Adelie Penguin (Pygoscelis adeliae) Biscoe 36.4 \n", + "3 Adelie Penguin (Pygoscelis adeliae) Biscoe 41.6 \n", + "4 Adelie Penguin (Pygoscelis adeliae) Biscoe 35.0 \n", + "5 Adelie Penguin (Pygoscelis adeliae) Biscoe 41.1 \n", + "6 Adelie Penguin (Pygoscelis adeliae) Biscoe 42.0 \n", + "7 Gentoo penguin (Pygoscelis papua) Biscoe 43.8 \n", + "8 Gentoo penguin (Pygoscelis papua) Biscoe 43.3 \n", + "9 Gentoo penguin (Pygoscelis papua) Biscoe 44.0 \n", + "\n", + " culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "0 \n", + "1 18.9 184.0 3550.0 MALE \n", + "2 17.1 184.0 2850.0 FEMALE \n", + "3 18.0 192.0 3950.0 MALE \n", + "4 17.9 192.0 3725.0 FEMALE \n", + "5 18.2 192.0 4050.0 MALE \n", + "6 19.5 200.0 4050.0 MALE \n", + "7 13.9 208.0 4300.0 FEMALE \n", + "8 14.0 208.0 4575.0 FEMALE \n", + "9 13.6 208.0 4350.0 FEMALE \n", + "...\n", + "\n", + "[168 rows x 12 columns]" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.ml.explain_predict(model_name, biscoe, top_k_features=3)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "K0mPaoGpcwwy" + }, + "source": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Globally explain the model\n", + "\n", + "To know which features are generally the most important to determine penguin\n", + "weight, you can use the `global_explain` function. In order to use\n", + "`global_explain`, you must retrain the model with the `enable_global_explain`\n", + "option set to `True`." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "id": "ZSP7gt13QrQt" + }, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 6.9 kB in 53 seconds of slot time. [Job bigframes-dev:US.job_welN8ErlZ_sTG7oOEULsWUgmIg7l details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "model_name = f\"{PROJECT_ID}.{DATASET_ID}.penguin_weight_with_global_explain\"\n", + "model_metadata = bbq.ml.create_model(\n", + " model_name,\n", + " replace=True,\n", + " options={\n", + " \"model_type\": \"LINEAR_REG\",\n", + " \"input_label_cols\": [\"body_mass_g\"],\n", + " \"enable_global_explain\": True,\n", + " },\n", + " training_data=training_data,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
featureattribution
0sex221.587592
1flipper_length_mm71.311846
2culmen_depth_mm66.17986
3culmen_length_mm45.443363
4island17.258076
\n", + "

5 rows × 2 columns

\n", + "
[5 rows x 2 columns in total]" + ], + "text/plain": [ + " feature attribution\n", + "0 sex 221.587592\n", + "1 flipper_length_mm 71.311846\n", + "2 culmen_depth_mm 66.17986\n", + "3 culmen_length_mm 45.443363\n", + "4 island 17.258076\n", + "\n", + "[5 rows x 2 columns]" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.ml.global_explain(model_name)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Compatibility with pandas\n", + "\n", + "The functions in `bigframes.bigquery.ml` can accept pandas DataFrames as well. Use the `to_pandas()` method on the results of methods like `predict()` to get a pandas DataFrame back." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " Query started with request ID bigframes-dev:US.18d9027b-7d55-42c9-ad1b-dabccdda80dc.
SQL
SELECT * FROM ML.PREDICT(MODEL `bigframes-dev.bqml_tutorial.penguin_weight_with_global_explain`, (SELECT\n",
+              "`column_0` AS `sex`,\n",
+              "`column_1` AS `flipper_length_mm`,\n",
+              "`column_2` AS `culmen_depth_mm`,\n",
+              "`column_3` AS `culmen_length_mm`,\n",
+              "`column_4` AS `island`\n",
+              "FROM\n",
+              "(SELECT\n",
+              "  *\n",
+              "FROM (\n",
+              "  SELECT\n",
+              "    *\n",
+              "  FROM UNNEST(ARRAY<STRUCT<`column_0` STRING, `column_1` INT64, `column_2` INT64, `column_3` INT64, `column_4` STRING>>[STRUCT('MALE', 180, 15, 40, 'Biscoe'), STRUCT('FEMALE', 190, 16, 41, 'Biscoe'), STRUCT('MALE', 200, 17, 42, 'Dream'), STRUCT('FEMALE', 210, 18, 43, 'Dream')]) AS `column_0`\n",
+              ") AS `t0`)))\n",
+              "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "index", + "rawType": "Int64", + "type": "integer" + }, + { + "name": "predicted_body_mass_g", + "rawType": "Float64", + "type": "float" + }, + { + "name": "sex", + "rawType": "string", + "type": "string" + }, + { + "name": "flipper_length_mm", + "rawType": "Int64", + "type": "integer" + }, + { + "name": "culmen_depth_mm", + "rawType": "Int64", + "type": "integer" + }, + { + "name": "culmen_length_mm", + "rawType": "Int64", + "type": "integer" + }, + { + "name": "island", + "rawType": "string", + "type": "string" + } + ], + "ref": "01d67015-64b6-463e-8c16-e8ac1363ff67", + "rows": [ + [ + "0", + "3596.332210728767", + "MALE", + "180", + "15", + "40", + "Biscoe" + ], + [ + "1", + "3384.6999176328636", + "FEMALE", + "190", + "16", + "41", + "Biscoe" + ], + [ + "2", + "4049.581795919061", + "MALE", + "200", + "17", + "42", + "Dream" + ], + [ + "3", + "3837.9495028231568", + "FEMALE", + "210", + "18", + "43", + "Dream" + ] + ], + "shape": { + "columns": 6, + "rows": 4 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
predicted_body_mass_gsexflipper_length_mmculmen_depth_mmculmen_length_mmisland
03596.332211MALE1801540Biscoe
13384.699918FEMALE1901641Biscoe
24049.581796MALE2001742Dream
33837.949503FEMALE2101843Dream
\n", + "
" + ], + "text/plain": [ + " predicted_body_mass_g sex flipper_length_mm culmen_depth_mm \\\n", + "0 3596.332211 MALE 180 15 \n", + "1 3384.699918 FEMALE 190 16 \n", + "2 4049.581796 MALE 200 17 \n", + "3 3837.949503 FEMALE 210 18 \n", + "\n", + " culmen_length_mm island \n", + "0 40 Biscoe \n", + "1 41 Biscoe \n", + "2 42 Dream \n", + "3 43 Dream " + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "predict_df = pd.DataFrame({\n", + " \"sex\": [\"MALE\", \"FEMALE\", \"MALE\", \"FEMALE\"],\n", + " \"flipper_length_mm\": [180, 190, 200, 210],\n", + " \"culmen_depth_mm\": [15, 16, 17, 18],\n", + " \"culmen_length_mm\": [40, 41, 42, 43],\n", + " \"island\": [\"Biscoe\", \"Biscoe\", \"Dream\", \"Dream\"],\n", + "})\n", + "bbq.ml.predict(model_metadata, predict_df).to_pandas()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Compatibility with `bigframes.ml`\n", + "\n", + "The models created with `bigframes.bigquery.ml` can be used with the scikit-learn-like `bigframes.ml` modules by using the `read_gbq_model` method.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "LinearRegression(enable_global_explain=True,\n", + " optimize_strategy='NORMAL_EQUATION')" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model = bpd.read_gbq_model(model_name)\n", + "model" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 7.3 kB in a moment of slot time. [Job bigframes-dev:US.f2f86927-bbd1-431d-b89e-3d6a064268d7 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
mean_absolute_errormean_squared_errormean_squared_log_errormedian_absolute_errorr2_scoreexplained_variance
0223.87876378553.6016340.005614181.3309110.6239510.623951
\n", + "

1 rows × 6 columns

\n", + "
[1 rows x 6 columns in total]" + ], + "text/plain": [ + " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", + " 223.878763 78553.601634 0.005614 \n", + "\n", + " median_absolute_error r2_score explained_variance \n", + " 181.330911 0.623951 0.623951 \n", + "\n", + "[1 rows x 6 columns]" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "X = training_data[[\"sex\", \"flipper_length_mm\", \"culmen_depth_mm\", \"culmen_length_mm\", \"island\"]]\n", + "y = training_data[[\"body_mass_g\"]]\n", + "model.score(X, y)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "G_wjSfXpWTuy" + }, + "source": [ + "# Summary and next steps\n", + "\n", + "You've created a linear regression model using `bigframes.bigquery.ml`.\n", + "\n", + "Learn more about BigQuery DataFrames in the [documentation](https://dataframes.bigquery.dev/) and find more sample notebooks in the [GitHub repo](https://github.com/googleapis/python-bigquery-dataframes/tree/main/notebooks)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "TpV-iwP9qw9c" + }, + "source": [ + "## Cleaning up\n", + "\n", + "To clean up all Google Cloud resources used in this project, you can [delete the Google Cloud\n", + "project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#shutting_down_projects) you used for the tutorial.\n", + "\n", + "Otherwise, you can uncomment the remaining cells and run them to delete the individual resources you created in this tutorial:" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "id": "sx_vKniMq9ZX" + }, + "outputs": [], + "source": [ + "# # Delete the BigQuery dataset and associated ML model\n", + "# from google.cloud import bigquery\n", + "# client = bigquery.Client(project=PROJECT_ID)\n", + "# client.delete_dataset(\n", + "# DATASET_ID, delete_contents=True, not_found_ok=True\n", + "# )\n", + "# print(\"Deleted dataset '{}'.\".format(DATASET_ID))" + ] + } + ], + "metadata": { + "colab": { + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/notebooks/ml/bq_dataframes_ml_linear_regression_big.ipynb b/notebooks/ml/bq_dataframes_ml_linear_regression_big.ipynb new file mode 100644 index 0000000000..5c016f9157 --- /dev/null +++ b/notebooks/ml/bq_dataframes_ml_linear_regression_big.ipynb @@ -0,0 +1,1064 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "ur8xi4C7S06n" + }, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "JAPoU8Sm5E6e" + }, + "source": [ + "## Train a linear regression model with BigQuery DataFrames ML\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"Vertex\n", + " Open in Vertex AI Workbench\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "24743cf4a1e1" + }, + "source": [ + "**_NOTE_**: This notebook has been tested in the following environment:\n", + "\n", + "* Python version = 3.11" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "tvgnzT1CKxrO" + }, + "source": [ + "## Overview\n", + "\n", + "This notebook demonstrates training a linear regression model on Big Data using BigQuery DataFrames ML. BigQuery DataFrames ML provides a provides a scikit-learn-like API for ML powered by the BigQuery engine.\n", + "\n", + "Learn more about [BigQuery DataFrames](https://cloud.google.com/python/docs/reference/bigframes/latest)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "d975e698c9a4" + }, + "source": [ + "### Objective\n", + "\n", + "In this tutorial, we use BigQuery DataFrames to create a linear regression model that predicts the levels of Ozone in the atmosphere.\n", + "\n", + "The steps include:\n", + "\n", + "- Creating a DataFrame from the BigQuery table.\n", + "- Cleaning and preparing data using `bigframes.pandas` module.\n", + "- Creating a linear regression model using `bigframes.ml` module.\n", + "- Saving the ML model to BigQuery for future use.\n", + "\n", + "\n", + "Let's formally define our problem as: **Train a linear regression model to predict the level of ozone in the atmosphere given the measurements of other constituents and properties of the atmosphere.**" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "08d289fa873f" + }, + "source": [ + "### Dataset\n", + "\n", + "In this tutorial we are going to use the [`bigquery-public-data.epa_historical_air_quality`](https://console.cloud.google.com/marketplace/product/epa/historical-air-quality) dataset. To quote the description of the dataset:\n", + "\n", + "\"The United States Environmental Protection Agency (EPA) protects both public health and the environment by establishing the standards for national air quality. The EPA provides annual summary data as well as hourly and daily data in the categories of criteria gases, particulates, meteorological, and toxics.\"\n", + "\n", + "There are several tables capturing data about the constituents of the atmosphere, see them in the [BigQuery cloud console](https://pantheon.corp.google.com/bigquery?p=bigquery-public-data&d=epa_historical_air_quality&page=dataset). Most tables carry 10's of GBs of data, but that is not an issue with BigQuery DataFrames as the data is efficiently processed at BigQuery without transferring them to the client." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "aed92deeb4a0" + }, + "source": [ + "### Costs\n", + "\n", + "This tutorial uses billable components of Google Cloud:\n", + "\n", + "* BigQuery (compute)\n", + "* BigQuery ML\n", + "\n", + "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models)\n", + "and [BigQuery ML pricing](https://cloud.google.com/bigquery/pricing#bqml),\n", + "and use the [Pricing Calculator](https://cloud.google.com/products/calculator/)\n", + "to generate a cost estimate based on your projected usage." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "i7EUnXsZhAGF" + }, + "source": [ + "## Installation\n", + "\n", + "If you don't have [bigframes](https://pypi.org/project/bigframes/) package already installed, uncomment and execute the following cells to\n", + "\n", + "1. Install the package\n", + "1. Restart the notebook kernel (Jupyter or Colab) to work with the package" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "9O0Ka4W2MNF3" + }, + "outputs": [], + "source": [ + "# !pip install bigframes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "f200f10a1da3" + }, + "outputs": [], + "source": [ + "# Automatically restart kernel after installs so that your environment can access the new packages\n", + "\n", + "# import IPython\n", + "#\n", + "# app = IPython.Application.instance()\n", + "# app.kernel.do_shutdown(True)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "BF1j6f9HApxa" + }, + "source": [ + "## Before you begin\n", + "\n", + "Complete the tasks in this section to set up your environment." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "oDfTjfACBvJk" + }, + "source": [ + "### Set up your Google Cloud project\n", + "\n", + "**The following steps are required, regardless of your notebook environment.**\n", + "\n", + "1. [Select or create a Google Cloud project](https://console.cloud.google.com/cloud-resource-manager). When you first create an account, you get a $300 credit towards your compute/storage costs.\n", + "\n", + "2. [Make sure that billing is enabled for your project](https://cloud.google.com/billing/docs/how-to/modify-project).\n", + "\n", + "3. [Enable the BigQuery API](https://console.cloud.google.com/flows/enableapi?apiid=bigquery.googleapis.com).\n", + "\n", + "4. If you are running this notebook locally, install the [Cloud SDK](https://cloud.google.com/sdk)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "WReHDGG5g0XY" + }, + "source": [ + "#### Set your project ID\n", + "\n", + "If you don't know your project ID, try the following:\n", + "* Run `gcloud config list`.\n", + "* Run `gcloud projects list`.\n", + "* See the support page: [Locate the project ID](https://support.google.com/googleapi/answer/7014113)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "oM1iC_MfAts1" + }, + "outputs": [], + "source": [ + "PROJECT_ID = \"\" # @param {type:\"string\"}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "region" + }, + "source": [ + "#### Set the BigQuery location\n", + "\n", + "You can also change the `LOCATION` variable used by BigQuery. Learn more about [BigQuery locations](https://cloud.google.com/bigquery/docs/locations#supported_locations)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "eF-Twtc4XGem" + }, + "outputs": [], + "source": [ + "LOCATION = \"US\" # @param {type: \"string\"}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "sBCra4QMA2wR" + }, + "source": [ + "### Set up APIs, IAM permissions and Authentication\n", + "\n", + "Follow the instructions at https://cloud.google.com/bigquery/docs/use-bigquery-dataframes#permissions.\n", + "\n", + "Depending on your notebook environment, you might have to manually authenticate. Follow the relevant instructions below." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "74ccc9e52986" + }, + "source": [ + "**Vertex AI Workbench**\n", + "\n", + "Do nothing, you are already authenticated." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "de775a3773ba" + }, + "source": [ + "**Local JupyterLab instance**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "254614fa0c46" + }, + "outputs": [], + "source": [ + "# ! gcloud auth login\n", + "# ! gcloud auth application-default login" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "ef21552ccea8" + }, + "source": [ + "**Colab**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "603adbbf0532" + }, + "outputs": [], + "source": [ + "# from google.colab import auth\n", + "# auth.authenticate_user()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "960505627ddf" + }, + "source": [ + "### Import libraries" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "PyQmSRbKA8r-" + }, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "init_aip:mbsdk,all" + }, + "source": [ + "### Set BigQuery DataFrames options" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "NPPMuw2PXGeo" + }, + "outputs": [], + "source": [ + "# NOTE: The project option is not required in all environments.\n", + "# On BigQuery Studio, the project ID is automatically detected.\n", + "bpd.options.bigquery.project = PROJECT_ID\n", + "\n", + "# NOTE: The location option is not required.\n", + "# It defaults to the location of the first table or query\n", + "# passed to read_gbq(). For APIs where a location can't be\n", + "# auto-detected, the location defaults to the \"US\" location.\n", + "bpd.options.bigquery.location = LOCATION\n", + "\n", + "# NOTE: For a machine learning model the order of the data is\n", + "# not important. So let's relax the ordering_mode to accept\n", + "# partial ordering. This allows BigQuery DataFrames to run cost\n", + "# and performance optimized jobs at the BigQuery engine.\n", + "bpd.options.bigquery.ordering_mode = \"partial\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "D21CoOlfFTYI" + }, + "source": [ + "If you want to reset the location of the created DataFrame or Series objects, reset the session by executing `bpd.close_session()`. After that, you can reuse `bpd.options.bigquery.location` to specify another location." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "9EMAqR37AfLS" + }, + "source": [ + "## Read data in BigQuery tables as DataFrame\n", + "\n", + "Let's read the tables in the dataset to construct a BigQuery DataFrames DataFrame. We will combine measurements of various parameters of the atmosphere from multiple tables to represent a consolidated dataframe to use for our model training and prediction. We have daily and hourly versions of the data available, but since we want to create a model that is dynamic so that it can capture the variance throughout the day, we would choose the hourly version.\n", + "\n", + "Note that we would use the pandas APIs as we normally would on the BigQuery DataFrames DataFrame, but calculations happen in the BigQuery query engine instead of the local environment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset = \"bigquery-public-data.epa_historical_air_quality\"\n", + "hourly_summary_tables = [\n", + " \"co_hourly_summary\",\n", + " \"hap_hourly_summary\",\n", + " \"no2_hourly_summary\",\n", + " \"nonoxnoy_hourly_summary\",\n", + " \"o3_hourly_summary\",\n", + " \"pm10_hourly_summary\",\n", + " \"pm25_frm_hourly_summary\",\n", + " \"pm25_nonfrm_hourly_summary\",\n", + " \"pm25_speciation_hourly_summary\",\n", + " \"pressure_hourly_summary\",\n", + " \"rh_and_dp_hourly_summary\",\n", + " \"so2_hourly_summary\",\n", + " \"temperature_hourly_summary\",\n", + " \"voc_hourly_summary\",\n", + " \"wind_hourly_summary\",\n", + "]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's pick index columns - to identify a measurement of the atmospheric parameter, param column - to identify which param the measurement pertains to, and value column - the column containing the measurement itself." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "index_columns = [\"state_name\", \"county_name\", \"site_num\", \"date_local\", \"time_local\"]\n", + "param_column = \"parameter_name\"\n", + "value_column = \"sample_measurement\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's observe how much data each table contains:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for table in hourly_summary_tables:\n", + " # get the bigframes global session\n", + " bigframes_session = bpd.get_global_session()\n", + "\n", + " # get the bigquery table info\n", + " table_info = bigframes_session.bqclient.get_table(f\"{dataset}.{table}\")\n", + "\n", + " # read the table as a dataframe\n", + " df = bpd.read_gbq(f\"{dataset}.{table}\")\n", + "\n", + " # print metadata about the table\n", + " print(\n", + " f\"{table}: \"\n", + " f\"{round(table_info.num_bytes/1_000_000_000, 1)} GB, \"\n", + " f\"{round(table_info.num_rows/1_000_000, 1)} million rows, \"\n", + " f\"{df[param_column].nunique()} params\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's be mindful that the rows in each table may contain duplicates, which may introdude bias in any model trained on the raw data. We will make sure to drop the duplicates when we use the data for model training.\n", + "\n", + "Since we want to predict ozone level, we obviously pick the `o3` table. Let's also pick the tables about other gases - `co`, `no2` and `so2`. Let's also pick `pressure` and `temperature` tables as they seem fundamental indicators for the atmosphere. Note that each of these tables capture measurements for a single parameter (i.e. the column `parameter_name` has a single unique value).\n", + "\n", + "We are also interested in the nonoxny and wind tables, but they capture multiple parameters (i.e. the column `parameter_name` has a more than one unique values). We will include their measurements in later step, as they require extar processing to separate out the measurements for the individual parameters.\n", + "\n", + "We skip the other tables in this exercise for either they have very little or fragmented data or they seem uninteresting for the purpose of predicting ozone levels. You can take this as a separate exercise to train a linear regression model by including those parameters. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's maintain an array of dtaframes, one for each parameter, and eventually combine them into a single dataframe." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "params_dfs = []" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's process the tables with single parameter measurements first." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "EDAaIwHpQCDZ" + }, + "outputs": [], + "source": [ + "table_param_dict = {\n", + " \"co_hourly_summary\" : \"co\",\n", + " \"no2_hourly_summary\" : \"no2\",\n", + " \"o3_hourly_summary\" : \"o3\",\n", + " \"pressure_hourly_summary\" : \"pressure\",\n", + " \"so2_hourly_summary\" : \"so2\",\n", + " \"temperature_hourly_summary\" : \"temperature\",\n", + "}\n", + "\n", + "for table, param in table_param_dict.items():\n", + " param_df = bpd.read_gbq(\n", + " f\"{dataset}.{table}\",\n", + " columns=index_columns + [value_column]\n", + " )\n", + " param_df = param_df\\\n", + " .sort_values(index_columns)\\\n", + " .drop_duplicates(index_columns)\\\n", + " .set_index(index_columns)\\\n", + " .rename(columns={value_column : param})\n", + " params_dfs.append(param_df)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The nonoxnoy table captures measurements for 3 parameters. Let's analyze how many instances of each parameter it contains." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "nonoxnoy_table = f\"{dataset}.nonoxnoy_hourly_summary\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bpd.read_gbq(nonoxnoy_table, columns=[param_column]).value_counts()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We see that the NOy data is significantly sparse as compared to NO and NOx, so we skip that and include NO and NOx data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "no_df = bpd.read_gbq(\n", + " nonoxnoy_table,\n", + " columns=index_columns + [value_column],\n", + " filters=[(param_column, \"==\", \"Nitric oxide (NO)\")]\n", + ")\n", + "no_df = no_df\\\n", + " .sort_values(index_columns)\\\n", + " .drop_duplicates(index_columns)\\\n", + " .set_index(index_columns)\\\n", + " .rename(columns={value_column: \"no_\"})\n", + "params_dfs.append(no_df)\n", + "\n", + "nox_df = bpd.read_gbq(\n", + " nonoxnoy_table,\n", + " columns=index_columns + [value_column],\n", + " filters=[(param_column, \"==\", \"Oxides of nitrogen (NOx)\")]\n", + ")\n", + "nox_df = nox_df\\\n", + " .sort_values(index_columns)\\\n", + " .drop_duplicates(index_columns)\\\n", + " .set_index(index_columns)\\\n", + " .rename(columns={value_column: \"nox\"})\n", + "params_dfs.append(nox_df)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The wind table captures measurements for 2 parameters. Let's analyze how many instances of each parameter it contains." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "wind_table = f\"{dataset}.wind_hourly_summary\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bpd.read_gbq(wind_table, columns=[param_column]).value_counts()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's include the data for wind speed and wind direction." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "wind_speed_df = bpd.read_gbq(\n", + " wind_table,\n", + " columns=index_columns + [value_column],\n", + " filters=[(param_column, \"==\", \"Wind Speed - Resultant\")]\n", + ")\n", + "wind_speed_df = wind_speed_df\\\n", + " .sort_values(index_columns)\\\n", + " .drop_duplicates(index_columns)\\\n", + " .set_index(index_columns)\\\n", + " .rename(columns={value_column: \"wind_speed\"})\n", + "params_dfs.append(wind_speed_df)\n", + "\n", + "wind_dir_df = bpd.read_gbq(\n", + " wind_table,\n", + " columns=index_columns + [value_column],\n", + " filters=[(param_column, \"==\", \"Wind Direction - Resultant\")]\n", + ")\n", + "wind_dir_df = wind_dir_df\\\n", + " .sort_values(index_columns)\\\n", + " .drop_duplicates(index_columns)\\\n", + " .set_index(index_columns)\\\n", + " .rename(columns={value_column: \"wind_dir\"})\n", + "params_dfs.append(wind_dir_df)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's observe each individual parameter and number of data points for each parameter." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for param_df in params_dfs:\n", + " print(f\"{param_df.columns.values}: {len(param_df)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's combine data from all parameters into a single DataFrame. The measurements for each parameter may not be available for every (state, county, site, date, time) identifier, we will consider only those identifiers for which measurements of all parameters are available. To achieve this we will combine the measurements via \"inner\" join.\n", + "\n", + "We will also materialize this combined data via `cache` method for efficient reuse in the subsequent steps." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "df = bpd.concat(params_dfs, axis=1, join=\"inner\").cache()\n", + "df.shape" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "rwPLjqW2Ajzh" + }, + "source": [ + "## Clean and prepare data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's temporarily bring the index columns as dataframe columns for further processing on the index values for the purpose of data preparation.\n", + "We will reconstruct the index back at the time of the model training." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "df = df.reset_index()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Observe the years from which we have consolidated data so far." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "df[\"date_local\"].dt.year.value_counts().sort_index().to_pandas()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this tutorial we would train a model from the past data to predict ozone levels for the future data. Let's define the cut-off year as 2020. We will pretend that the data before 2020 has known ozone levels, and the 2020 onwards the ozone levels are unknown, which we will predict using our model.\n", + "\n", + "We should further separate the known data into training and test sets. The model would be trained on the training set and then evaluated on the test set to make sure the model generalizes beyond the training data. We could use [train_test_split](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.model_selection#bigframes_ml_model_selection_train_test_split) method to randomly split the training and test data, but we leave that for you to try out. In this exercise, let's split based on another cutoff year 2017 - the known data before 2017 would be training data and 2017 onwards would be the test data. This way we stay with the idea that the model is trained on past data and then used to predict the future values." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "6i6HkFJZa8na" + }, + "outputs": [], + "source": [ + "train_data_filter = (df.date_local.dt.year < 2017)\n", + "test_data_filter = (df.date_local.dt.year >= 2017) & (df.date_local.dt.year < 2020)\n", + "predict_data_filter = (df.date_local.dt.year >= 2020)\n", + "\n", + "df_train = df[train_data_filter].set_index(index_columns)\n", + "df_test = df[test_data_filter].set_index(index_columns)\n", + "df_predict = df[predict_data_filter].set_index(index_columns)\n", + "\n", + "df_train.shape, df_test.shape, df_predict.shape" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "M_-0X7NxYK5f" + }, + "source": [ + "Prepare your feature (or input) columns and the target (or output) column for the purpose of model training and evaluation:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "YKwCW7Nsavap" + }, + "outputs": [], + "source": [ + "X_train = df_train.drop(columns=\"o3\")\n", + "y_train = df_train[\"o3\"]\n", + "\n", + "X_test = df_test.drop(columns=\"o3\")\n", + "y_test = df_test[\"o3\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Prepare the unknown data for prediction." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "wej78IDUaRW9" + }, + "outputs": [], + "source": [ + "X_predict = df_predict.drop(columns=\"o3\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Fx4lsNqMorJ-" + }, + "source": [ + "## Create the linear regression model\n", + "\n", + "BigQuery DataFrames ML lets you seamlessly transition from exploring data to creating machine learning models through its scikit-learn-like API, `bigframes.ml`. BigQuery DataFrames ML supports several types of [ML models](https://cloud.google.com/python/docs/reference/bigframes/latest#ml-capabilities).\n", + "\n", + "In this notebook, you create a [`LinearRegression`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.linear_model.LinearRegression) model, a type of regression model that generates a continuous value from a linear combination of input features.\n", + "\n", + "When you create a model with BigQuery DataFrames ML, it is saved in an internal location and limited to the BigQuery DataFrames session. However, as you'll see in the next section, you can use `to_gbq` to save the model permanently to your BigQuery project." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "EloGtMnverFF" + }, + "source": [ + "### Create the model using `bigframes.ml`\n", + "\n", + "Please note that BigQuery DataFrames ML is backed by BigQuery ML, which uses\n", + "[automatic preprocessing](https://cloud.google.com/bigquery/docs/auto-preprocessing) to encode string values and scale numeric values when you pass the feature columns without transforms.\n", + "\n", + "BigQuery ML also [automatically splits the data for training and evaluation](https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-create-glm#data_split_method), although for datasets with less than 500 rows (such as this one), all rows are used for training." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "GskyyUQPowBT" + }, + "outputs": [], + "source": [ + "from bigframes.ml.linear_model import LinearRegression\n", + "\n", + "model = LinearRegression()\n", + "\n", + "model.fit(X_train, y_train)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "UGjeMPC2caKK" + }, + "source": [ + "### Score the model\n", + "\n", + "Check how the model performs by using the [`score`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.linear_model.LinearRegression#bigframes_ml_linear_model_LinearRegression_score) method. More information on BigQuery ML model scoring can be found [here](https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-evaluate#mlevaluate_output)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "kGBJKafpo0dl" + }, + "outputs": [], + "source": [ + "# On the training data\n", + "model.score(X_train, y_train)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# On the test data\n", + "model.score(X_test, y_test)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "P2lUiZZ_cjri" + }, + "source": [ + "### Predict using the model\n", + "\n", + "Use the model to predict the levels of ozone. The predicted levels are returned in the column `predicted_o3`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "bsQ9cmoWo0Ps" + }, + "outputs": [], + "source": [ + "df_pred = model.predict(X_predict)\n", + "df_pred.peek()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GTRdUw-Ro5R1" + }, + "source": [ + "## Save the model in BigQuery\n", + "\n", + "The model is saved locally within this session. You can save the model permanently to BigQuery for use in future sessions, and to make the model sharable with others." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "K0mPaoGpcwwy" + }, + "source": [ + "Create a BigQuery dataset to house the model, adding a name for your dataset as the `DATASET_ID` variable:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "ZSP7gt13QrQt" + }, + "outputs": [], + "source": [ + "DATASET_ID = \"\" # @param {type:\"string\"}\n", + "\n", + "if not DATASET_ID:\n", + " raise ValueError(\"Please define the DATASET_ID\")\n", + "\n", + "client = bpd.get_global_session().bqclient\n", + "dataset = client.create_dataset(DATASET_ID, exists_ok=True)\n", + "print(f\"Dataset {dataset.dataset_id} created.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "zqAIWWgJczp-" + }, + "source": [ + "Save the model using the `to_gbq` method:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "QE_GD4Byo_jb" + }, + "outputs": [], + "source": [ + "model.to_gbq(DATASET_ID + \".o3_lr_model\" , replace=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "f7uHacAy49rT" + }, + "source": [ + "You can view the saved model in the BigQuery console under the dataset you created in the first step. Run the following cell and follow the link to view your BigQuery console:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "qDBoiA_0488Z" + }, + "outputs": [], + "source": [ + "print(f'https://console.cloud.google.com/bigquery?ws=!1m5!1m4!5m3!1s{PROJECT_ID}!2s{DATASET_ID}!3so3_lr_model')" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "G_wjSfXpWTuy" + }, + "source": [ + "# Summary and next steps\n", + "\n", + "You've created a linear regression model using `bigframes.ml`.\n", + "\n", + "Learn more about BigQuery DataFrames in the [documentation](https://cloud.google.com/python/docs/reference/bigframes/latest) and find more sample notebooks in the [GitHub repo](https://github.com/googleapis/python-bigquery-dataframes/tree/main/notebooks)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "TpV-iwP9qw9c" + }, + "source": [ + "## Cleaning up\n", + "\n", + "To clean up all Google Cloud resources used in this project, you can [delete the Google Cloud\n", + "project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#shutting_down_projects) you used for the tutorial.\n", + "\n", + "Otherwise, you can uncomment the remaining cells and run them to delete the individual resources you created in this tutorial:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "sx_vKniMq9ZX" + }, + "outputs": [], + "source": [ + "# # Delete the BigQuery dataset and associated ML model\n", + "# client.delete_dataset(DATASET_ID, delete_contents=True, not_found_ok=True)" + ] + } + ], + "metadata": { + "colab": { + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/notebooks/ml/easy_linear_regression.ipynb b/notebooks/ml/easy_linear_regression.ipynb index fdabd82a4b..5a7258a182 100644 --- a/notebooks/ml/easy_linear_regression.ipynb +++ b/notebooks/ml/easy_linear_regression.ipynb @@ -52,20 +52,9 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Dataset(DatasetReference('shobs-test', 'bqml_tutorial'))" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "dataset = f\"{session.bqclient.project}.bqml_tutorial\"\n", "session.bqclient.create_dataset(dataset, exists_ok=True)" @@ -96,383 +85,9 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 525fc879-1f59-45e8-96b4-f9c67d244d06 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 91aa1b30-2b0e-41eb-9bfb-4f6232913b31 is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
speciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
0Adelie Penguin (Pygoscelis adeliae)Biscoe40.118.9188.04300.0MALE
1Adelie Penguin (Pygoscelis adeliae)Torgersen39.118.7181.03750.0MALE
2Gentoo penguin (Pygoscelis papua)Biscoe47.414.6212.04725.0FEMALE
3Chinstrap penguin (Pygoscelis antarctica)Dream42.516.7187.03350.0FEMALE
4Adelie Penguin (Pygoscelis adeliae)Biscoe43.219.0197.04775.0MALE
5Gentoo penguin (Pygoscelis papua)Biscoe46.715.3219.05200.0MALE
6Adelie Penguin (Pygoscelis adeliae)Biscoe41.321.1195.04400.0MALE
7Gentoo penguin (Pygoscelis papua)Biscoe45.213.8215.04750.0FEMALE
8Gentoo penguin (Pygoscelis papua)Biscoe46.513.5210.04550.0FEMALE
9Gentoo penguin (Pygoscelis papua)Biscoe50.515.2216.05000.0FEMALE
10Gentoo penguin (Pygoscelis papua)Biscoe48.215.6221.05100.0MALE
11Adelie Penguin (Pygoscelis adeliae)Dream38.118.6190.03700.0FEMALE
12Gentoo penguin (Pygoscelis papua)Biscoe50.715.0223.05550.0MALE
13Adelie Penguin (Pygoscelis adeliae)Biscoe37.820.0190.04250.0MALE
14Adelie Penguin (Pygoscelis adeliae)Biscoe35.017.9190.03450.0FEMALE
15Gentoo penguin (Pygoscelis papua)Biscoe48.715.7208.05350.0MALE
16Adelie Penguin (Pygoscelis adeliae)Torgersen34.621.1198.04400.0MALE
17Gentoo penguin (Pygoscelis papua)Biscoe46.815.4215.05150.0MALE
18Chinstrap penguin (Pygoscelis antarctica)Dream50.320.0197.03300.0MALE
19Adelie Penguin (Pygoscelis adeliae)Dream37.218.1178.03900.0MALE
20Chinstrap penguin (Pygoscelis antarctica)Dream51.018.8203.04100.0MALE
21Adelie Penguin (Pygoscelis adeliae)Biscoe40.517.9187.03200.0FEMALE
22Gentoo penguin (Pygoscelis papua)Biscoe45.513.9210.04200.0FEMALE
23Adelie Penguin (Pygoscelis adeliae)Dream42.218.5180.03550.0FEMALE
24Chinstrap penguin (Pygoscelis antarctica)Dream51.720.3194.03775.0MALE
\n", - "

25 rows × 7 columns

\n", - "
[344 rows x 7 columns in total]" - ], - "text/plain": [ - " species island culmen_length_mm \\\n", - "0 Adelie Penguin (Pygoscelis adeliae) Biscoe 40.1 \n", - "1 Adelie Penguin (Pygoscelis adeliae) Torgersen 39.1 \n", - "2 Gentoo penguin (Pygoscelis papua) Biscoe 47.4 \n", - "3 Chinstrap penguin (Pygoscelis antarctica) Dream 42.5 \n", - "4 Adelie Penguin (Pygoscelis adeliae) Biscoe 43.2 \n", - "5 Gentoo penguin (Pygoscelis papua) Biscoe 46.7 \n", - "6 Adelie Penguin (Pygoscelis adeliae) Biscoe 41.3 \n", - "7 Gentoo penguin (Pygoscelis papua) Biscoe 45.2 \n", - "8 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 \n", - "9 Gentoo penguin (Pygoscelis papua) Biscoe 50.5 \n", - "10 Gentoo penguin (Pygoscelis papua) Biscoe 48.2 \n", - "11 Adelie Penguin (Pygoscelis adeliae) Dream 38.1 \n", - "12 Gentoo penguin (Pygoscelis papua) Biscoe 50.7 \n", - "13 Adelie Penguin (Pygoscelis adeliae) Biscoe 37.8 \n", - "14 Adelie Penguin (Pygoscelis adeliae) Biscoe 35.0 \n", - "15 Gentoo penguin (Pygoscelis papua) Biscoe 48.7 \n", - "16 Adelie Penguin (Pygoscelis adeliae) Torgersen 34.6 \n", - "17 Gentoo penguin (Pygoscelis papua) Biscoe 46.8 \n", - "18 Chinstrap penguin (Pygoscelis antarctica) Dream 50.3 \n", - "19 Adelie Penguin (Pygoscelis adeliae) Dream 37.2 \n", - "20 Chinstrap penguin (Pygoscelis antarctica) Dream 51.0 \n", - "21 Adelie Penguin (Pygoscelis adeliae) Biscoe 40.5 \n", - "22 Gentoo penguin (Pygoscelis papua) Biscoe 45.5 \n", - "23 Adelie Penguin (Pygoscelis adeliae) Dream 42.2 \n", - "24 Chinstrap penguin (Pygoscelis antarctica) Dream 51.7 \n", - "\n", - " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "0 18.9 188.0 4300.0 MALE \n", - "1 18.7 181.0 3750.0 MALE \n", - "2 14.6 212.0 4725.0 FEMALE \n", - "3 16.7 187.0 3350.0 FEMALE \n", - "4 19.0 197.0 4775.0 MALE \n", - "5 15.3 219.0 5200.0 MALE \n", - "6 21.1 195.0 4400.0 MALE \n", - "7 13.8 215.0 4750.0 FEMALE \n", - "8 13.5 210.0 4550.0 FEMALE \n", - "9 15.2 216.0 5000.0 FEMALE \n", - "10 15.6 221.0 5100.0 MALE \n", - "11 18.6 190.0 3700.0 FEMALE \n", - "12 15.0 223.0 5550.0 MALE \n", - "13 20.0 190.0 4250.0 MALE \n", - "14 17.9 190.0 3450.0 FEMALE \n", - "15 15.7 208.0 5350.0 MALE \n", - "16 21.1 198.0 4400.0 MALE \n", - "17 15.4 215.0 5150.0 MALE \n", - "18 20.0 197.0 3300.0 MALE \n", - "19 18.1 178.0 3900.0 MALE \n", - "20 18.8 203.0 4100.0 MALE \n", - "21 17.9 187.0 3200.0 FEMALE \n", - "22 13.9 210.0 4200.0 FEMALE \n", - "23 18.5 180.0 3550.0 FEMALE \n", - "24 20.3 194.0 3775.0 MALE \n", - "...\n", - "\n", - "[344 rows x 7 columns]" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# read a BigQuery table to a BigQuery DataFrame\n", "df = bigframes.pandas.read_gbq(f\"bigquery-public-data.ml_datasets.penguins\")\n", @@ -491,357 +106,9 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "Query job d2bd7c5e-2652-4c0d-8495-8ef65e89031b is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 92f0a5e5-bc61-426f-a9ef-213a1c376851 is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
islandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
0Biscoe40.118.9188.04300.0MALE
1Torgersen39.118.7181.03750.0MALE
4Biscoe43.219.0197.04775.0MALE
6Biscoe41.321.1195.04400.0MALE
11Dream38.118.6190.03700.0FEMALE
13Biscoe37.820.0190.04250.0MALE
14Biscoe35.017.9190.03450.0FEMALE
16Torgersen34.621.1198.04400.0MALE
19Dream37.218.1178.03900.0MALE
21Biscoe40.517.9187.03200.0FEMALE
23Dream42.218.5180.03550.0FEMALE
30Dream39.221.1196.04150.0MALE
32Torgersen42.917.6196.04700.0MALE
38Dream41.117.5190.03900.0MALE
40Torgersen38.621.2191.03800.0MALE
42Biscoe35.516.2195.03350.0FEMALE
44Dream39.218.6190.04250.0MALE
45Torgersen35.215.9186.03050.0FEMALE
46Dream43.218.5192.04100.0MALE
49Biscoe39.617.7186.03500.0FEMALE
53Biscoe45.620.3191.04600.0MALE
58Torgersen40.916.8191.03700.0FEMALE
60Torgersen40.318.0195.03250.0FEMALE
62Dream36.018.5186.03100.0FEMALE
63Torgersen39.320.6190.03650.0MALE
\n", - "

25 rows × 6 columns

\n", - "
[146 rows x 6 columns in total]" - ], - "text/plain": [ - " island culmen_length_mm culmen_depth_mm flipper_length_mm \\\n", - "0 Biscoe 40.1 18.9 188.0 \n", - "1 Torgersen 39.1 18.7 181.0 \n", - "4 Biscoe 43.2 19.0 197.0 \n", - "6 Biscoe 41.3 21.1 195.0 \n", - "11 Dream 38.1 18.6 190.0 \n", - "13 Biscoe 37.8 20.0 190.0 \n", - "14 Biscoe 35.0 17.9 190.0 \n", - "16 Torgersen 34.6 21.1 198.0 \n", - "19 Dream 37.2 18.1 178.0 \n", - "21 Biscoe 40.5 17.9 187.0 \n", - "23 Dream 42.2 18.5 180.0 \n", - "30 Dream 39.2 21.1 196.0 \n", - "32 Torgersen 42.9 17.6 196.0 \n", - "38 Dream 41.1 17.5 190.0 \n", - "40 Torgersen 38.6 21.2 191.0 \n", - "42 Biscoe 35.5 16.2 195.0 \n", - "44 Dream 39.2 18.6 190.0 \n", - "45 Torgersen 35.2 15.9 186.0 \n", - "46 Dream 43.2 18.5 192.0 \n", - "49 Biscoe 39.6 17.7 186.0 \n", - "53 Biscoe 45.6 20.3 191.0 \n", - "58 Torgersen 40.9 16.8 191.0 \n", - "60 Torgersen 40.3 18.0 195.0 \n", - "62 Dream 36.0 18.5 186.0 \n", - "63 Torgersen 39.3 20.6 190.0 \n", - "\n", - " body_mass_g sex \n", - "0 4300.0 MALE \n", - "1 3750.0 MALE \n", - "4 4775.0 MALE \n", - "6 4400.0 MALE \n", - "11 3700.0 FEMALE \n", - "13 4250.0 MALE \n", - "14 3450.0 FEMALE \n", - "16 4400.0 MALE \n", - "19 3900.0 MALE \n", - "21 3200.0 FEMALE \n", - "23 3550.0 FEMALE \n", - "30 4150.0 MALE \n", - "32 4700.0 MALE \n", - "38 3900.0 MALE \n", - "40 3800.0 MALE \n", - "42 3350.0 FEMALE \n", - "44 4250.0 MALE \n", - "45 3050.0 FEMALE \n", - "46 4100.0 MALE \n", - "49 3500.0 FEMALE \n", - "53 4600.0 MALE \n", - "58 3700.0 FEMALE \n", - "60 3250.0 FEMALE \n", - "62 3100.0 FEMALE \n", - "63 3650.0 MALE \n", - "...\n", - "\n", - "[146 rows x 6 columns]" - ] - }, - "execution_count": 26, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# filter down to the data we want to analyze\n", "adelie_data = df[df.species == \"Adelie Penguin (Pygoscelis adeliae)\"]\n", @@ -880,56 +147,9 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 43c8fdc2-0bc3-4607-a36d-5bee87c894d8 is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 97e0c84d-aa6a-4197-9377-740d973ea44d is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 726b9a5e-48a1-4ced-ac34-fa028dcb2bf4 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "LinearRegression()" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from bigframes.ml.linear_model import LinearRegression\n", "\n", @@ -942,104 +162,9 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 28975567-2526-40f7-a7be-9dee6f782b4e is DONE. 9.5 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 5c71d3d9-0e1c-45bd-866f-1f98f056260d is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 890767f7-a83b-469a-9f3e-abd5667f8202 is DONE. 48 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
mean_absolute_errormean_squared_errormean_squared_log_errormedian_absolute_errorr2_scoreexplained_variance
0223.87876378553.6016340.005614181.3309110.6239510.623951
\n", - "

1 rows × 6 columns

\n", - "
[1 rows x 6 columns in total]" - ], - "text/plain": [ - " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - "0 223.878763 78553.601634 0.005614 \n", - "\n", - " median_absolute_error r2_score explained_variance \n", - "0 181.330911 0.623951 0.623951 \n", - "\n", - "[1 rows x 6 columns]" - ] - }, - "execution_count": 29, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# check how the model performed\n", "model.score(feature_columns, label_columns)" @@ -1047,103 +172,9 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "Query job d59df3e8-cf87-4340-a4c7-a27c3abfcc50 is DONE. 29.1 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 5af493aa-96f9-434f-a101-ec855f4de694 is DONE. 8 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job e2076bc3-3966-4c45-8265-c461756a7782 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job e9cdfca7-30f6-4e93-95fb-244896e7c2ab is DONE. 16 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
predicted_body_mass_g
3345891.735118
\n", - "

1 rows × 1 columns

\n", - "
[1 rows x 1 columns in total]" - ], - "text/plain": [ - " predicted_body_mass_g\n", - "334 5891.735118\n", - "\n", - "[1 rows x 1 columns]" - ] - }, - "execution_count": 30, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# use the model to predict the missing labels\n", "model.predict(missing_body_mass)" @@ -1159,32 +190,9 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "Copy job cb4ef454-10df-4325-b9cb-6084df3ac9d5 is DONE. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "LinearRegression(optimize_strategy='NORMAL_EQUATION')" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# save the model to a permanent location in BigQuery, so we can use it in future sessions (and elsewhere in BQ)\n", "model.to_gbq(penguins_model, replace=True)" @@ -1199,20 +207,9 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LinearRegression(optimize_strategy='NORMAL_EQUATION')" - ] - }, - "execution_count": 32, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# WARNING - until b/281709360 is fixed & pipeline is updated, pipelines will load as models,\n", "# and details of their transform steps will be lost (the loaded model will behave the same)\n", diff --git a/notebooks/ml/timeseries_analysis.ipynb b/notebooks/ml/timeseries_analysis.ipynb new file mode 100644 index 0000000000..01c5a20efa --- /dev/null +++ b/notebooks/ml/timeseries_analysis.ipynb @@ -0,0 +1,1135 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cf1403ce", + "metadata": {}, + "source": [ + "# Time Series Forecasting with BigFrames\n", + "\n", + "This notebook provides a comprehensive walkthrough of time series forecasting using the BigFrames library. We will explore two powerful models, TimesFM and ARIMAPlus, to predict bikeshare trip demand based on historical data from San Francisco. The process covers data loading, preprocessing, model training, and visualization of the results." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c0b2db75", + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "from bigframes.ml import forecasting\n", + "bpd.options.display.repr_mode = \"anywidget\"" + ] + }, + { + "cell_type": "markdown", + "id": "0eba46b9", + "metadata": {}, + "source": [ + "### 1. Data Loading and Preprocessing\n", + "\n", + "The first step is to load the San Francisco bikeshare dataset from BigQuery. We then preprocess the data by filtering for trips made by 'Subscriber' type users from 2018 onwards. This ensures we are working with a relevant and consistent subset of the data. Finally, we aggregate the trip data by the hour to create a time series of trip counts." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "83928f4d", + "metadata": {}, + "outputs": [], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.san_francisco_bikeshare.bikeshare_trips\")\n", + "df = df[df[\"start_date\"] >= \"2018-01-01\"]\n", + "df = df[df[\"subscriber_type\"] == \"Subscriber\"]\n", + "df[\"trip_hour\"] = df[\"start_date\"].dt.floor(\"h\")\n", + "df_grouped = df[[\"trip_hour\", \"trip_id\"]].groupby(\"trip_hour\").count().reset_index()\n", + "df_grouped = df_grouped.rename(columns={\"trip_id\": \"num_trips\"})" + ] + }, + { + "cell_type": "markdown", + "id": "c43b7e65", + "metadata": {}, + "source": [ + "### 2. Forecasting with TimesFM\n", + "\n", + "In this section, we use the TimesFM (Time Series Foundation Model) to forecast future bikeshare demand. TimesFM is a powerful model designed for a wide range of time series forecasting tasks. We will use it to predict the number of trips for the last week of our dataset." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "1096e154", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dataframe.py:5340: FutureWarning: The 'ai' property will be removed. Please use 'bigframes.bigquery.ai'\n", + "instead.\n", + " warnings.warn(msg, category=FutureWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 58.7 MB in 19 seconds of slot time. [Job bigframes-dev:US.eb026c28-038a-4ca7-acfa-474ed0be4119 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 7.1 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 7.1 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "929eda852e564b799cf76e62d9f7b46a", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
forecast_timestampforecast_valueconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundai_forecast_status
02018-04-24 14:00:00+00:00126.5192110.9596.837778156.200644
12018-04-30 21:00:00+00:0082.2661970.95-7.690994172.223388
22018-04-25 14:00:00+00:00130.0572660.9578.019585182.094948
32018-04-26 06:00:00+00:0047.2352140.95-16.565634111.036063
42018-04-28 01:00:00+00:000.7611390.95-61.08053162.602809
52018-04-27 11:00:00+00:00160.4370420.9580.767928240.106157
62018-04-25 07:00:00+00:00321.4184880.95207.344246435.492729
72018-04-24 16:00:00+00:00284.6405640.95198.550187370.730941
82018-04-25 16:00:00+00:00329.6537480.95201.918472457.389023
92018-04-26 10:00:00+00:00160.9959720.9567.706721254.285223
\n", + "

10 rows × 6 columns

\n", + "
[168 rows x 6 columns in total]" + ], + "text/plain": [ + " forecast_timestamp forecast_value confidence_level \\\n", + "0 2018-04-24 14:00:00+00:00 126.519211 0.95 \n", + "1 2018-04-30 21:00:00+00:00 82.266197 0.95 \n", + "2 2018-04-25 14:00:00+00:00 130.057266 0.95 \n", + "3 2018-04-26 06:00:00+00:00 47.235214 0.95 \n", + "4 2018-04-28 01:00:00+00:00 0.761139 0.95 \n", + "5 2018-04-27 11:00:00+00:00 160.437042 0.95 \n", + "6 2018-04-25 07:00:00+00:00 321.418488 0.95 \n", + "7 2018-04-24 16:00:00+00:00 284.640564 0.95 \n", + "8 2018-04-25 16:00:00+00:00 329.653748 0.95 \n", + "9 2018-04-26 10:00:00+00:00 160.995972 0.95 \n", + "\n", + " prediction_interval_lower_bound prediction_interval_upper_bound \\\n", + "0 96.837778 156.200644 \n", + "1 -7.690994 172.223388 \n", + "2 78.019585 182.094948 \n", + "3 -16.565634 111.036063 \n", + "4 -61.080531 62.602809 \n", + "5 80.767928 240.106157 \n", + "6 207.344246 435.492729 \n", + "7 198.550187 370.730941 \n", + "8 201.918472 457.389023 \n", + "9 67.706721 254.285223 \n", + "\n", + " ai_forecast_status \n", + "0 \n", + "1 \n", + "2 \n", + "3 \n", + "4 \n", + "5 \n", + "6 \n", + "7 \n", + "8 \n", + "9 \n", + "...\n", + "\n", + "[168 rows x 6 columns]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = df_grouped.head(2842-168).ai.forecast(\n", + " timestamp_column=\"trip_hour\",\n", + " data_column=\"num_trips\",\n", + " horizon=168\n", + ")\n", + "result" + ] + }, + { + "cell_type": "markdown", + "id": "90e80a82", + "metadata": {}, + "source": [ + "### 3. Forecasting with ARIMAPlus\n", + "\n", + "Next, we will use the ARIMAPlus model, which is a BigQuery ML model available through BigFrames. ARIMAPlus is an advanced forecasting model that can capture complex time series patterns. We will train it on the same historical data and use it to forecast the same period as the TimesFM model." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "f41e1cf0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " Query processed 1.8 MB in 46 seconds of slot time. [Job bigframes-dev:US.ac354d97-dc91-4d01-9dca-7069db6a26a7 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 92.2 kB in a moment of slot time. [Job bigframes-dev:US.e61f41af-8761-4853-ae41-d38760c966ed details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 1.3 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 10.8 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "0624fdda2be74b13bc6e6c30e38842b6", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
forecast_timestampforecast_valuestandard_errorconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundconfidence_interval_lower_boundconfidence_interval_upper_bound
02018-04-24 00:00:00+00:0052.76833534.874520.95-15.462203120.998872-15.462203120.998872
12018-04-24 01:00:00+00:0067.328148.0752550.95-26.729122161.385322-26.729122161.385322
22018-04-24 02:00:00+00:0075.20557353.9109210.95-30.268884180.68003-30.268884180.68003
32018-04-24 03:00:00+00:0080.07092255.9940760.95-29.479141189.620985-29.479141189.620985
42018-04-24 04:00:00+00:0075.16177956.5839740.95-35.542394185.865952-35.542394185.865952
52018-04-24 05:00:00+00:0081.42843256.850870.95-29.797913192.654778-29.797913192.654778
62018-04-24 06:00:00+00:00116.98144557.1807670.955.109671228.8532185.109671228.853218
72018-04-24 07:00:00+00:00237.22236157.7703070.95124.197176350.247546124.197176350.247546
82018-04-24 08:00:00+00:00323.72257258.6816620.95208.91436438.530784208.91436438.530784
92018-04-24 09:00:00+00:00357.28895259.8069060.95240.279247474.298656240.279247474.298656
\n", + "

10 rows × 8 columns

\n", + "
[168 rows x 8 columns in total]" + ], + "text/plain": [ + " forecast_timestamp forecast_value standard_error \\\n", + "0 2018-04-24 00:00:00+00:00 52.768335 34.87452 \n", + "1 2018-04-24 01:00:00+00:00 67.3281 48.075255 \n", + "2 2018-04-24 02:00:00+00:00 75.205573 53.910921 \n", + "3 2018-04-24 03:00:00+00:00 80.070922 55.994076 \n", + "4 2018-04-24 04:00:00+00:00 75.161779 56.583974 \n", + "5 2018-04-24 05:00:00+00:00 81.428432 56.85087 \n", + "6 2018-04-24 06:00:00+00:00 116.981445 57.180767 \n", + "7 2018-04-24 07:00:00+00:00 237.222361 57.770307 \n", + "8 2018-04-24 08:00:00+00:00 323.722572 58.681662 \n", + "9 2018-04-24 09:00:00+00:00 357.288952 59.806906 \n", + "\n", + " confidence_level prediction_interval_lower_bound \\\n", + "0 0.95 -15.462203 \n", + "1 0.95 -26.729122 \n", + "2 0.95 -30.268884 \n", + "3 0.95 -29.479141 \n", + "4 0.95 -35.542394 \n", + "5 0.95 -29.797913 \n", + "6 0.95 5.109671 \n", + "7 0.95 124.197176 \n", + "8 0.95 208.91436 \n", + "9 0.95 240.279247 \n", + "\n", + " prediction_interval_upper_bound confidence_interval_lower_bound \\\n", + "0 120.998872 -15.462203 \n", + "1 161.385322 -26.729122 \n", + "2 180.68003 -30.268884 \n", + "3 189.620985 -29.479141 \n", + "4 185.865952 -35.542394 \n", + "5 192.654778 -29.797913 \n", + "6 228.853218 5.109671 \n", + "7 350.247546 124.197176 \n", + "8 438.530784 208.91436 \n", + "9 474.298656 240.279247 \n", + "\n", + " confidence_interval_upper_bound \n", + "0 120.998872 \n", + "1 161.385322 \n", + "2 180.68003 \n", + "3 189.620985 \n", + "4 185.865952 \n", + "5 192.654778 \n", + "6 228.853218 \n", + "7 350.247546 \n", + "8 438.530784 \n", + "9 474.298656 \n", + "...\n", + "\n", + "[168 rows x 8 columns]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model = forecasting.ARIMAPlus(\n", + " auto_arima_max_order=5, # Reduce runtime for large datasets\n", + " data_frequency=\"hourly\",\n", + " horizon=168\n", + ")\n", + "X = df_grouped.head(2842-168)[[\"trip_hour\"]]\n", + "y = df_grouped.head(2842-168)[[\"num_trips\"]]\n", + "model.fit(\n", + " X, y\n", + ")\n", + "predictions = model.predict(horizon=168, confidence_level=0.95)\n", + "predictions" + ] + }, + { + "cell_type": "markdown", + "id": "ec5a4513", + "metadata": {}, + "source": [ + "### 4. Compare and Visualize Forecasts\n", + "\n", + "Now we will visualize the forecasts from both TimesFM and ARIMAPlus against the actual historical data. This allows for a direct comparison of the two models' performance." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "7f5b5b1e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 31.7 MB in 11 seconds of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 58.8 MB in 12 seconds of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjMAAAH7CAYAAAA5AR6GAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzsfXmcFMXd/tNz7M0uh8CicomoIKAGo25AMQZFBN+oGDVeaEx8o3jBKxrfGIN4kGCMV1CjPyNGY/T1TIInqGBEIIjRKCgqCotyKQILLLsz012/P2a6u6q7vtU1Mws7M9vP57Ofnemp6rO66qnne5TBGGMIESJEiBAhQoQoUkTa+wRChAgRIkSIECHyQUhmQoQIESJEiBBFjZDMhAgRIkSIECGKGiGZCREiRIgQIUIUNUIyEyJEiBAhQoQoaoRkJkSIECFChAhR1AjJTIgQIUKECBGiqBGSmRAhQoQIESJEUSMkMyFChAgRIkSIokZIZkKECMCxxx6LY489tr1PI0QIJcJ2GqIjIyQzIUoehmFo/c2fP3+PnM/s2bPJc/jFL36xR86hPfH444/jzjvvbPP9NjU14cYbb8QhhxyCmpoaVFZWYsiQIbj22muxbt26Nj9eiBAhCgdGuDZTiFLHY489Jnz/85//jLlz5+LRRx8Vth9//PHo2bOnr34ikQAAlJWVtcn5zJ49GxdeeCGmT5+O/v37C78NGTIEhx56aJscp1Axfvx4fPjhh1i9enWb7fPzzz/H6NGj0djYiB/96EcYOXIkysrK8J///Ad//etf0bVrV3zyySdtdrxCRFu30xAhigmx9j6BECF2N84991zh++LFizF37lzfdi+am5tRVVW12waHsWPH4vDDD2/z/e7cuRPV1dVtvt9CRSqVwmmnnYaNGzdi/vz5GDlypPD7Lbfcgt/+9rftdHa7H7u7nYYIUQwIzUwhQiDtbzBkyBAsW7YMxxxzDKqqqvC///u/zm+8L8L8+fNhGAaefPJJ/O///i/q6+tRXV2N//qv/8LatWvb7Jxef/11HH300aiurkbnzp3xwx/+EB999JFQZtq0aTAMAytWrMDZZ5+NLl26CIP5Y489huHDh6OyshJdu3bFWWedJT3HJUuW4KSTTkKXLl1QXV2NYcOG4a677nJ+/89//oMLLrgA++23HyoqKlBfX4+f/OQn2Lx5s7Cf7du346qrrkK/fv1QXl6OHj164Pjjj8e7774LIH0vX3jhBaxZs8YxrfXr18+pf8899+Dggw9GVVUVunTpgsMPPxyPP/648j4988wzeP/99/HLX/7SR2QAoLa2Frfccouw7amnnnLuy1577YVzzz0XX331lVDmggsuQE1NDRobGzF+/HjU1NRgn332waxZswAAH3zwAY477jhUV1ejb9++vvO0zYlvvvkm/vu//xvdunVDbW0tzj//fGzZskUo+7e//Q3jxo3D3nvvjfLycgwYMAA33XQTTNMUymXTTnXv57///W+MHTsWtbW1qKmpwQ9+8AMsXrxYei0LFy7ElClT0L17d1RXV+PUU0/F119/LXssIULsUYTKTIgQGWzevBljx47FWWedhXPPPVdqcuJxyy23wDAMXHvttdi0aRPuvPNOjB49Gu+99x4qKysDj7dt2zZ88803wra99toLADBv3jyMHTsW++23H6ZNm4Zdu3bhnnvuwYgRI/Duu+8KBAAAfvSjH2HgwIG49dZbYVuOb7nlFvzqV7/CGWecgZ/+9Kf4+uuvcc899+CYY47Bv//9b3Tu3BkAMHfuXIwfPx69evXClVdeifr6enz00UeYM2cOrrzySqfM559/jgsvvBD19fVYvnw5HnjgASxfvhyLFy+GYRgAgJ///Od4+umncdlll2Hw4MHYvHkz3nrrLXz00Uf4zne+g1/+8pfYtm0bvvzyS9xxxx0AgJqaGgDAgw8+iCuuuAKnn346rrzySrS0tOA///kPlixZgrPPPpu8j3//+98BAOedd17gPQdcM993v/tdzJgxAxs3bsRdd92FhQsXCvcFAEzTxNixY3HMMcdg5syZ+Mtf/oLLLrsM1dXV+OUvf4lzzjkHp512Gu6//36cf/75aGho8JkOL7vsMnTu3BnTpk3DypUrcd9992HNmjUOKbbPqaamBlOmTEFNTQ1ef/113HDDDWhqasJtt90m7E+3nercz+XLl+Poo49GbW0trrnmGsTjcfzxj3/EscceiwULFuDII48U9nn55ZejS5cu+PWvf43Vq1fjzjvvxGWXXYYnn3xS696HCLHbwEKE6GCYNGkS8zb9UaNGMQDs/vvv95UfNWoUGzVqlPP9jTfeYADYPvvsw5qampzt//d//8cAsLvuukt5/IcffpgBkP7ZOPTQQ1mPHj3Y5s2bnW3vv/8+i0Qi7Pzzz3e2/frXv2YA2I9//GPhGKtXr2bRaJTdcsstwvYPPviAxWIxZ3sqlWL9+/dnffv2ZVu2bBHKWpblfG5ubvZdx1//+lcGgL355pvOtrq6OjZp0iTl9Y8bN4717dvXt/2HP/whO/jgg5V1ZTjssMNYXV2dVtlEIsF69OjBhgwZwnbt2uVsnzNnDgPAbrjhBmfbxIkTGQB26623Otu2bNnCKisrmWEY7IknnnC2f/zxxwwA+/Wvf+1ss5/z8OHDWSKRcLbPnDmTAWB/+9vfnG2y+/vf//3frKqqirW0tDjbsmmnOvfzlFNOYWVlZWzVqlXOtnXr1rFOnTqxY445xncto0ePFtrF5MmTWTQaZVu3blUeJ0SI3Y3QzBQiRAbl5eW48MILtcuff/756NSpk/P99NNPR69evfDiiy9q1Z81axbmzp0r/AHA+vXr8d577+GCCy5A165dnfLDhg3D8ccfL93/z3/+c+H7s88+C8uycMYZZ+Cbb75x/urr6zFw4EC88cYbANImhi+++AJXXXWVoEgAcFQDAILS1NLSgm+++QZHHXUUADgmJADo3LkzlixZklP0UOfOnfHll19i6dKlWdVramoSnoMK77zzDjZt2oRLL70UFRUVzvZx48bhoIMOwgsvvOCr89Of/lQ4xwMPPBDV1dU444wznO0HHnggOnfujM8//9xX/+KLL0Y8Hne+X3LJJYjFYsJz5O/v9u3b8c033+Doo49Gc3MzPv74Y2F/uu006H6apolXX30Vp5xyCvbbbz9ne69evXD22WfjrbfeQlNTk+9a+HZx9NFHwzRNrFmzJvB8QoTYnQjJTIgQGeyzzz5ZOVEOHDhQ+G4YBvbff3/tKJ0jjjgCo0ePFv4AOAPDgQce6KszaNAgfPPNN9i5c6ew3Wva+PTTT8EYw8CBA9G9e3fh76OPPsKmTZsAAKtWrQKQjqJS4dtvv8WVV16Jnj17orKyEt27d3eOuW3bNqfczJkz8eGHH6J379444ogjMG3aNOkAL8O1116LmpoaHHHEERg4cCAmTZqEhQsXBtarra3F9u3btY6hurcHHXSQb1CuqKhA9+7dhW11dXXYd999hUHd3u71hQH87aSmpga9evUS2sny5ctx6qmnoq6uDrW1tejevbvjoM7fX0C/nQbdz6+//hrNzc1kO7Msy+df1adPH+F7ly5dAEB63SFC7EmEPjMhQmSg4+dSqPCeu2VZMAwDL730EqLRqK+87aeiizPOOANvv/02pk6dikMPPRQ1NTWwLAsnnngiLMsSyh199NF47rnn8Oqrr+K2227Db3/7Wzz77LMYO3as8hiDBg3CypUrMWfOHLz88st45plncO+99+KGG27AjTfeSNY76KCD8O9//xtr165F7969s7quIMjunWo7yyHTxdatWzFq1CjU1tZi+vTpGDBgACoqKvDuu+/i2muvFe4voN9Oc72fKrTldYcI0ZYIlZkQIXLEp59+KnxnjOGzzz7zOedmi759+wIAVq5c6fvt448/xl577RUYej1gwAAwxtC/f3+f+jN69GjHRDRgwAAAwIcffkjua8uWLXjttdfwi1/8AjfeeCNOPfVUHH/88YJpgkevXr1w6aWX4vnnn8cXX3yBbt26CdFEXkWDR3V1Nc4880w8/PDDaGxsxLhx43DLLbegpaWFrHPyyScD8OcTkkF1b1euXOn83pbwtpMdO3Zg/fr1TjuZP38+Nm/ejNmzZ+PKK6/E+PHjMXr0aEf1yAeq+9m9e3dUVVWR7SwSibQ5OQwRYnchJDMhQuSIP//5z4J54+mnn8b69esDFYgg9OrVC4ceeigeeeQRbN261dn+4Ycf4tVXX8VJJ50UuI/TTjsN0WgUN954o2/WzBhzQqq/853voH///rjzzjuFY9nlAHc27t2PN4uvaZo+k0iPHj2w9957o7W11dlWXV3tKwfAF+ZdVlaGwYMHgzGGZDJJXuvpp5+OoUOH4pZbbsGiRYt8v2/fvh2//OUvAQCHH344evTogfvvv184p5deegkfffQRxo0bRx4nVzzwwAPC+d93331IpVJOO5Hd30QigXvvvTev4wbdz2g0ihNOOAF/+9vfBJPXxo0b8fjjj2PkyJGora3N6xxChNhTCM1MIULkiK5du2LkyJG48MILsXHjRtx5553Yf//98bOf/Szvfd92220YO3YsGhoacNFFFzmh2XV1dZg2bVpg/QEDBuDmm2/Gddddh9WrV+OUU05Bp06d8MUXX+C5557DxRdfjKuvvhqRSAT33XcfTj75ZBx66KG48MIL0atXL3z88cdYvnw5XnnlFdTW1jqhyclkEvvssw9effVVfPHFF8Ixt2/fjn333Renn366s6TAvHnzsHTpUtx+++1OueHD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+wIAhg4dqn2dIQoTiZSFix99Bw37dcN/jxrQ3qcTosAQkpkQIXJAZTyKFdPHtNuxs8WwYcOE77169cKmTZty3kfPnj1RVVXlEBl727/+9a+sz82Lww8/PLDMIYccImRjbmhowI4dO7B27Voccsgh+MEPfoChQ4dizJgxOOGEE3D66aejS5cueZ9biPbD8+99hfkrv8b8lV+HZCaEDyGZCREiBxiGkbWppz3hXXfHMAxYloVIJO02x5tg7KUbVPswDIPcZ76orq7Oq340GsXcuXPx9ttv49VXX8U999yDX/7yl1iyZAn69++f9/mFaB/sSoRLLoSgEToAhwjRgdG9e3cAwPr1651tvDPw7kJZWVle6wG9//772LVrl/N98eLFqKmpQe/evQGkidWIESNw44034t///jfKysrw3HPP5X3eIdoP4coLIVQonqlliBAh2hyVlZU46qij8Jvf/Ab9+/fHpk2bcP311+/24/br1w9LlizB6tWrUVNTg65du2ZVP5FI4KKLLsL111+P1atX49e//jUuu+wyRCIRLFmyBK+99hpOOOEE9OjRA0uWLMHXX3+NQYMG7aarCbEnEHKZECqEykyIEB0cf/rTn5BKpTB8+HBcddVVuPnmm3f7Ma+++mpEo1EMHjwY3bt3R2NjY1b1f/CDH2DgwIE45phjcOaZZ+K//uu/nHDz2tpavPnmmzjppJNwwAEH4Prrr8ftt9+OsWPH7oYrCREiRCHAYEUYr9jU1IS6ujps27YNtbW17X06IToAWlpa8MUXX6B///6oqKho79Pp0LjggguwdetWPP/88+19KkWPYmrXjy5eg189/yEAYPVvxrXz2ZQW+v3iBefz7r63u2v8DpWZECFChAhR8AjNTLsPpeCPFJKZECFCtCkaGxtRU1ND/mVrUgoRAiiNAbdQESmBmxs6AIcIEaJNsffeeysjovbee++89j979uy86ofIHVt2JtCluqxdjm20gTbzzLIvUV0exYlDerXBGZUOIgZQ7IHvIZkJESJEmyIWi2H//fdv79MI0cZ47t9fYvKT7+OSYwfg2hMP2uPHz1c82NjUgv956n0AwBczToJRAmpEWyF9L4rOfVZAaGYKESJEiBCB+PXflgMA7pu/ql2Ony/1aNrlJoMsvrCX3YtSoHUhmQkRIkSIEAWPfIUUvr4VshkBpeAzE5KZECFChAgRiOI3y7jnX8yrQ+8OFP2jRUhmQoQIESJEESBfB+BQmaERKjMhQoQIEaJDoN3Hu3zNTNznkMuIaO9H2xYIyUyIEB0M8+fPh2EY2Lp1a7uex8KFCzF06FDE43Gccsopu+UYjDFcfPHF6Nq1KwzD2COLaIbYPch3wOXNZKEyI6LdiWobICsy069fPxiG4fubNGkSgHRq7EmTJqFbt26oqanBhAkTsHHjRmEfjY2NGDduHKqqqtCjRw9MnToVqVSq7a4oRIgQAo499lhcddVVzvfvfe97WL9+Perq6trvpABMmTIFhx56KL744ovdljvm5ZdfxuzZszFnzhysX78eQ4YM2S3H2RPo168f7rzzznY7fluMd9uak8h1BR2Vz86O1hSSpqWuz30OyYyISKT42UxWZGbp0qVYv3698zd37lwAwI9+9CMAwOTJk/GPf/wDTz31FBYsWIB169bhtNNOc+qbpolx48YhkUjg7bffxiOPPILZs2fjhhtuaMNLChEihAplZWWor69vd4fOVatW4bjjjsO+++6Lzp0777Zj9OrVC9/73vdQX1+PWCz71FqMsXDC1QZ4t3ELDpn+Ki77679zqi+aiVwysmVnAkN+/QqO//0CdX3BZyanUyhZdDifme7du6O+vt75mzNnDgYMGIBRo0Zh27ZteOihh/D73/8exx13HIYPH46HH34Yb7/9NhYvXgwAePXVV7FixQo89thjOPTQQzF27FjcdNNNmDVrFhKJxG65wBAhdgsYAxI72+cvi1nlBRdcgAULFuCuu+5ylNTZs2cLZqbZs2ejc+fOmDNnDg488EBUVVXh9NNPR3NzMx555BH069cPXbp0wRVXXAHTdPOEtra24uqrr8Y+++yD6upqHHnkkZg/f77z+5o1a3DyySejS5cuqK6uxsEHH4wXX3wRq1evhmEY2Lx5M37yk58452Sbv1555RUcdthhqKysxHHHHYdNmzbhpZdewqBBg1BbW4uzzz4bzc3NWtd++eWXo7GxEYZhoF+/fs55X3HFFejRowcqKiowcuRILF261Klnn8dLL72E4cOHo7y8HG+99RYsy8KMGTPQv39/VFZW4pBDDsHTTz8tHHP58uUYP348amtr0alTJxx99NFYtSqdl2Xp0qU4/vjjsddee6Gurg6jRo3Cu+++yzUphmnTpqFPnz4oLy/H3nvvjSuu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2aaeP/3pT+jTpw9qampw6aWXwjRNzJw5E/X19ejRowduueUW4di///3vMXToUFRXV6N379649NJLsWPHDud3+zjPP/88Bg4ciIqKCowZMwZr19L+C8ceeyyuuuoqYdspp5yCCy64wPl+7733Ovvr2bMnzjnrzMD7dMEFF+Dyyy9HY2MjDMNAv379AACtra3OCtcVFRUYOXIkli5d6tSbP38+DMPASy+9hOHDh6O8vBxvvfUWLMvCjBkz0L9/f1RWVuKQQw7B008/LRxz+fLlGD9+PGpra9GpUyccffTRzrNZunQpjj/+eOy1116oq6vDqFGj8O677zp1GWOYNm0a+vTpg/Lycuy999644oornHu0Zs0aTJ48GYZh7NZoFd1xT8cBOKnIKUWFZlMmJ199yOvz+011WDLjfi5WJbpjTB3ygGpWfsVf/42vtu7C//13g7YTW4jskW8/LL6oOexAUmdXaheOfPzInM8pHyw5ewmq4vor/u7cuRNTpkzBsGHDsGPHDtxwww049dRTBX+QX/ziF7j99ttx2GGHoaKiAq+88opvP6tWrcJLL72El19+GatWrcLpp5+Ozz//HAcccAAWLFiAt99+Gz/5yU8wevRoHHlk+t5EIhHcfffd6N+/Pz7//HNceumluOaaa3Dvvfc6+21ubsYtt9yCP//5zygrK8Oll16Ks846CwsXLszp/rzzzju44oor8Oijj+J73/sevv32W7z2xvzAenfddRcGDBiABx54AEuXLkU0GgUAXHPNNXjmmWfwyCOPoG/fvpg5cybGjBmDzz77DF27dhXu4e9+9zvst99+6NKlC2bMmIHHHnsM999/PwYOHIg333wT5557Lrp3745Ro0bhq6++wjHHHINjjz0Wr7/+Ompra7Fw4UKkUmlTx/bt2zFx4kTcc889YIzh9ttvx0knnYRPP/0UnTp1wjPPPIM77rgDTzzxBA4++GBs2LAB77//PgDg2WefxSGHHIKLL74YP/vZz3K6j15Q76HuZIOKSuTzzOSUwVcwM9HHp31u9I5fyvAu1lmMxKAYz3mPQsX6//5+ejn7/3y5FYf16bJHz6sjoS1pYi4O3cU6U7ExYcIE4fuf/vQndO/eHStWrEBNTQ0A4KqrrsJpp52m3I9lWfjTn/6ETp06YfDgwfj+97+PlStX4sUXX0QkEsGBBx6I3/72t3jj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8ggURcEzzzyDhQsXIhaL4ZVXXilyM1144YU488wzcfvtt6OhoQGpVAqXX345stms8Pk1TcP111+Puro6NDU14Zvf/Kbl/S1btuCMM85AMplEKpXC2Wefjebm5qI28Lj66qtx3HHHsf+PO+44rFixAldffTVGjBjBat5ISEgMLUgyIyEh4Yj77rsPixcvxiWXXIKdO3di586dGDduHADgxhtvxHe+8x2sX78ec+fOdTx+5cqVWL9+PV588UU8+eST+PWvf43bb79d+PyPPvooKisr8cYbb+Duu+/GHXfcgeeeew6ATnTOOOMMtLS04G9/+xuee+45bNy4Eeecc07gfj766KOIRqN49dVX8eMf/zjw8RISEoMP6WaSkJBwRHV1NaLRKCoqKtDU1AQA+PDDDwEAd9xxB0488UTP46PRKB5++GFUVFTg4IMPxh133IHrrrsO3/rWt6Cq/vuouXPn4rbbbgMATJ06FQ888ABWrlyJE088EStXrsSaNWuwadMmRrAee+wxHHzwwXjrrbdw2GGHCfdz6tSpuPvuu4U/LyEhUXqQlhkJCYnAOPTQQ30/M2/ePFRUVLD/Fy9ejK6uLmzdulXoHHaLz6hRo7B7924AwPr16zFu3DhGZABg1qxZqKmpwfr164W+n2LhwoWBPi8hIVF6kGRGQkIiMCorKwf8HJFIxPK/oijQNE34eFVVQQixvJbL5Yo+dyD6IiEhMbCQZEZCQsIV0WgUhUKhT8euXr0avb297P/XX38dyWTSYk3pK2bOnImtW7darDzr1q1DW1sbZs2aBQBoaGjAzp07LcetWrVqv88tISFRepBkRkJCwhUTJ07EG2+8gc2bN2Pv3r2BLCPZbBYXX3wx1q1bhz/96U+47bbbsGLFCiG9jB+WLVuGOXPm4Pzzz8e7776LN998ExdccAGOPfZY5gI74YQT8Pbbb+Oxxx7DJ598gttuuw0ffPDBfp9bQkKi9CDJjISEhCu+/vWvIxQKYdasWWhoaMCWLVuEj126dCmmTp2KY445Bueccw5OP/30ovDqvkJRFPzf//0famtrccwxx2DZsmWYNGkSnnrqKfaZk046Cf/+7/+O66+/Hocddhg6OztxwQUX9Mv5JSQkSgsKsTuVJSQkJPYTF154Idra2vDb3/52sJsiISFRBpCWGQkJCQkJCYkhDUlmJCQkDii2bNnCyh84/QRxZUlISEgA0s0kISFxgJHP57F582bX9ydOnIhwWObzlJCQEIckMxISEhISEhJDGtLNJCEhISEhITGkIcmMhISEhISExJCGJDMSEhISEhISQxqSzEhISEhISEgMaUgyIyEhISEhITGkIcmMhISEhISExJCGJDMSEhISEhISQxr/H1aA8JlVgtCdAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "timesfm_result = result.sort_values(\"forecast_timestamp\")[[\"forecast_timestamp\", \"forecast_value\"]]\n", + "timesfm_result = timesfm_result.rename(columns={\n", + " \"forecast_timestamp\": \"trip_hour\",\n", + " \"forecast_value\": \"timesfm_forecast\"\n", + "})\n", + "arimaplus_result = predictions.sort_values(\"forecast_timestamp\")[[\"forecast_timestamp\", \"forecast_value\"]]\n", + "arimaplus_result = arimaplus_result.rename(columns={\n", + " \"forecast_timestamp\": \"trip_hour\",\n", + " \"forecast_value\": \"arimaplus_forecast\"\n", + "})\n", + "df_all = df_grouped.merge(timesfm_result, on=\"trip_hour\", how=\"left\")\n", + "df_all = df_all.merge(arimaplus_result, on=\"trip_hour\", how=\"left\")\n", + "df_all.tail(672).plot.line(\n", + " x=\"trip_hour\",\n", + " y=[\"num_trips\", \"timesfm_forecast\", \"arimaplus_forecast\"],\n", + " rot=45,\n", + " title=\"Trip Forecasts Comparison\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "015804c3", + "metadata": {}, + "source": [ + "### 5. Multiple Time Series Forecasting\n", + "\n", + "This section demonstrates a more advanced capability of ARIMAPlus: forecasting multiple time series simultaneously. This is useful when you have several independent series that you want to model together, such as trip counts from different bikeshare stations. The `id_col` parameter is key here, as it is used to differentiate between the individual time series." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6dbe6c48", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: TimeTravelCacheWarning: Reading cached table from 2025-12-12 23:04:48.874384+00:00 to avoid\n", + "incompatibilies with previous reads of this table. To read the latest\n", + "version, set `use_cache=False` or close the current session with\n", + "Session.close() or bigframes.pandas.close_session().\n", + " return method(*args, **kwargs)\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 69.8 MB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Number of stations: 41\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 69.8 MB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 69.8 MB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Date range: 2013-08-29 to 2018-04-30\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " Query processed 18.8 MB in 2 minutes of slot time. [Job bigframes-dev:US.74ada07a-98ad-4d03-90bb-2b98f1d8b558 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 1.4 MB in 4 seconds of slot time. [Job bigframes-dev:US.a292f715-1d9c-406d-a7d5-f99b2ba71660 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 4.6 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 11.5 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "00fc1edbf6fd40dfb949a3e3a30b6c3e", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
forecast_timestampstart_station_nameforecast_valuestandard_errorconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundconfidence_interval_lower_boundconfidence_interval_upper_bound
02016-09-01 00:00:00+00:00Beale at Market27.9114173.4224340.9521.21556834.60726521.21556834.607265
12016-09-01 00:00:00+00:00Civic Center BART (7th at Market)17.094554.2662870.958.7477425.4413618.7477425.441361
22016-09-01 00:00:00+00:00Embarcadero at Bryant22.3436483.3937020.9515.70401228.98328415.70401228.983284
32016-09-01 00:00:00+00:00Embarcadero at Folsom28.253293.3821580.9521.6362434.87033921.6362434.870339
42016-09-01 00:00:00+00:00Embarcadero at Sansome52.5380836.2692910.9540.27247764.80368940.27247764.803689
52016-09-01 00:00:00+00:00Embarcadero at Vallejo16.5132332.9536830.9510.73447622.2919910.73447622.29199
62016-09-01 00:00:00+00:00Market at 10th34.0512746.2057980.9521.9098946.19265821.9098946.192658
72016-09-01 00:00:00+00:00Market at 4th25.7460294.0015920.9517.91708233.57497717.91708233.574977
82016-09-01 00:00:00+00:00Market at Sansome46.1343685.0718520.9536.21150356.05723336.21150356.057233
92016-09-01 00:00:00+00:00Mechanics Plaza (Market at Battery)23.2699413.1946750.9517.01969229.52018917.01969229.520189
\n", + "

10 rows × 9 columns

\n", + "
[123 rows x 9 columns in total]" + ], + "text/plain": [ + " forecast_timestamp start_station_name \\\n", + "0 2016-09-01 00:00:00+00:00 Beale at Market \n", + "1 2016-09-01 00:00:00+00:00 Civic Center BART (7th at Market) \n", + "2 2016-09-01 00:00:00+00:00 Embarcadero at Bryant \n", + "3 2016-09-01 00:00:00+00:00 Embarcadero at Folsom \n", + "4 2016-09-01 00:00:00+00:00 Embarcadero at Sansome \n", + "5 2016-09-01 00:00:00+00:00 Embarcadero at Vallejo \n", + "6 2016-09-01 00:00:00+00:00 Market at 10th \n", + "7 2016-09-01 00:00:00+00:00 Market at 4th \n", + "8 2016-09-01 00:00:00+00:00 Market at Sansome \n", + "9 2016-09-01 00:00:00+00:00 Mechanics Plaza (Market at Battery) \n", + "\n", + " forecast_value standard_error confidence_level \\\n", + "0 27.911417 3.422434 0.95 \n", + "1 17.09455 4.266287 0.95 \n", + "2 22.343648 3.393702 0.95 \n", + "3 28.25329 3.382158 0.95 \n", + "4 52.538083 6.269291 0.95 \n", + "5 16.513233 2.953683 0.95 \n", + "6 34.051274 6.205798 0.95 \n", + "7 25.746029 4.001592 0.95 \n", + "8 46.134368 5.071852 0.95 \n", + "9 23.269941 3.194675 0.95 \n", + "\n", + " prediction_interval_lower_bound prediction_interval_upper_bound \\\n", + "0 21.215568 34.607265 \n", + "1 8.74774 25.441361 \n", + "2 15.704012 28.983284 \n", + "3 21.63624 34.870339 \n", + "4 40.272477 64.803689 \n", + "5 10.734476 22.29199 \n", + "6 21.90989 46.192658 \n", + "7 17.917082 33.574977 \n", + "8 36.211503 56.057233 \n", + "9 17.019692 29.520189 \n", + "\n", + " confidence_interval_lower_bound confidence_interval_upper_bound \n", + "0 21.215568 34.607265 \n", + "1 8.74774 25.441361 \n", + "2 15.704012 28.983284 \n", + "3 21.63624 34.870339 \n", + "4 40.272477 64.803689 \n", + "5 10.734476 22.29199 \n", + "6 21.90989 46.192658 \n", + "7 17.917082 33.574977 \n", + "8 36.211503 56.057233 \n", + "9 17.019692 29.520189 \n", + "...\n", + "\n", + "[123 rows x 9 columns]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_multi = bpd.read_gbq(\"bigquery-public-data.san_francisco_bikeshare.bikeshare_trips\")\n", + "df_multi = df_multi[df_multi[\"start_station_name\"].str.contains(\"Market|Powell|Embarcadero\")]\n", + " \n", + "# Create daily aggregation\n", + "features = bpd.DataFrame({\n", + " \"start_station_name\": df_multi[\"start_station_name\"],\n", + " \"date\": df_multi[\"start_date\"].dt.date,\n", + "})\n", + "\n", + "# Group by station and date\n", + "num_trips = features.groupby(\n", + " [\"start_station_name\", \"date\"], as_index=False\n", + ").size()\n", + "# Rename the size column to \"num_trips\"\n", + "num_trips = num_trips.rename(columns={num_trips.columns[-1]: \"num_trips\"})\n", + "\n", + "# Check data quality\n", + "print(f\"Number of stations: {num_trips['start_station_name'].nunique()}\")\n", + "print(f\"Date range: {num_trips['date'].min()} to {num_trips['date'].max()}\")\n", + "\n", + "# Use daily frequency \n", + "model = forecasting.ARIMAPlus(\n", + " data_frequency=\"daily\",\n", + " horizon=30,\n", + " auto_arima_max_order=3,\n", + " min_time_series_length=10,\n", + " time_series_length_fraction=0.8\n", + ")\n", + "\n", + "model.fit(\n", + " num_trips[[\"date\"]],\n", + " num_trips[[\"num_trips\"]],\n", + " id_col=num_trips[[\"start_station_name\"]]\n", + ")\n", + "\n", + "predictions_multi = model.predict()\n", + "predictions_multi" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/multimodal/multimodal_dataframe.ipynb b/notebooks/multimodal/multimodal_dataframe.ipynb new file mode 100644 index 0000000000..0822ee4c2d --- /dev/null +++ b/notebooks/multimodal/multimodal_dataframe.ipynb @@ -0,0 +1,1575 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "YOrUAvz6DMw-" + }, + "source": [ + "# BigFrames Multimodal DataFrame\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This notebook is introducing BigFrames Multimodal features:\n", + "1. Create Multimodal DataFrame\n", + "2. Combine unstructured data with structured data\n", + "3. Conduct image transformations\n", + "4. Use LLM models to ask questions and generate embeddings on images\n", + "5. PDF chunking function\n", + "6. Transcribe audio" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "PEAJQQ6AFg-n" + }, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install the latest bigframes package if bigframes version < 2.4.0" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# !pip install bigframes --upgrade" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "bGyhLnfEeB0X", + "outputId": "83ac8b64-3f44-4d43-d089-28a5026cbb42" + }, + "outputs": [], + "source": [ + "PROJECT = \"bigframes-dev\" # replace with your project. \n", + "# Refer to https://cloud.google.com/bigquery/docs/multimodal-data-dataframes-tutorial#required_roles for your required permissions\n", + "\n", + "OUTPUT_BUCKET = \"bigframes_blob_test\" # replace with your GCS bucket. \n", + "# The connection (or bigframes-default-connection of the project) must have read/write permission to the bucket. \n", + "# Refer to https://cloud.google.com/bigquery/docs/multimodal-data-dataframes-tutorial#grant-permissions for setting up connection service account permissions.\n", + "# In this Notebook it uses bigframes-default-connection by default. You can also bring in your own connections in each method.\n", + "\n", + "import bigframes\n", + "# Setup project\n", + "bigframes.options.bigquery.project = PROJECT\n", + "\n", + "# Display options\n", + "bigframes.options.display.blob_display_width = 300\n", + "bigframes.options.display.progress_bar = None\n", + "\n", + "import bigframes.pandas as bpd" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "ifKOq7VZGtZy" + }, + "source": [ + "### 1. Create Multimodal DataFrame\n", + "There are several ways to create Multimodal DataFrame. The easiest way is from the wildcard paths." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "fx6YcZJbeYru", + "outputId": "d707954a-0dd0-4c50-b7bf-36b140cf76cf" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/global_session.py:113: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", + " _global_session = bigframes.session.connect(\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + } + ], + "source": [ + "# Create blob columns from wildcard path.\n", + "df_image = bpd.from_glob_path(\n", + " \"gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/images/*\", name=\"image\"\n", + ")\n", + "# Other ways are: from string uri column\n", + "# df = bpd.DataFrame({\"uri\": [\"gs:///\", \"gs:///\"]})\n", + "# df[\"blob_col\"] = df[\"uri\"].str.to_blob()\n", + "\n", + "# From an existing object table\n", + "# df = bpd.read_gbq_object_table(\"\", name=\"blob_col\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 487 + }, + "id": "HhCb8jRsLe9B", + "outputId": "03081cf9-3a22-42c9-b38f-649f592fdada" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
image
0
1
2
3
4
\n", + "

5 rows × 1 columns

\n", + "
[5 rows x 1 columns in total]" + ], + "text/plain": [ + " image\n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "2 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "3 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "4 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "\n", + "[5 rows x 1 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Take only the 5 images to deal with. Preview the content of the Mutimodal DataFrame\n", + "df_image = df_image.head(5)\n", + "df_image" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "b6RRZb3qPi_T" + }, + "source": [ + "### 2. Combine unstructured data with structured data" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "4YJCdmLtR-qu" + }, + "source": [ + "Now you can put more information into the table to describe the files. Such as author info from inputs, or other metadata from the gcs object itself." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "id": "YYYVn7NDH0Me" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:121: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "version. Use `json_query` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:121: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "version. Use `json_query` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:121: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "version. Use `json_query` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
imageauthorcontent_typesizeupdated
0aliceimage/png15912402025-03-20 17:45:04+00:00
1bobimage/png11829512025-03-20 17:45:02+00:00
2bobimage/png15208842025-03-20 17:44:55+00:00
3aliceimage/png12354012025-03-20 17:45:19+00:00
4bobimage/png15919232025-03-20 17:44:47+00:00
\n", + "

5 rows × 5 columns

\n", + "
[5 rows x 5 columns in total]" + ], + "text/plain": [ + " image author content_type \\\n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "2 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "3 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n", + "4 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "\n", + " size updated \n", + "0 1591240 2025-03-20 17:45:04+00:00 \n", + "1 1182951 2025-03-20 17:45:02+00:00 \n", + "2 1520884 2025-03-20 17:44:55+00:00 \n", + "3 1235401 2025-03-20 17:45:19+00:00 \n", + "4 1591923 2025-03-20 17:44:47+00:00 \n", + "\n", + "[5 rows x 5 columns]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Combine unstructured data with structured data\n", + "df_image[\"author\"] = [\"alice\", \"bob\", \"bob\", \"alice\", \"bob\"] # type: ignore\n", + "df_image[\"content_type\"] = df_image[\"image\"].blob.content_type()\n", + "df_image[\"size\"] = df_image[\"image\"].blob.size()\n", + "df_image[\"updated\"] = df_image[\"image\"].blob.updated()\n", + "df_image" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "NUd4Kog_QLRS" + }, + "source": [ + "Then you can filter the rows based on the structured data. And for different content types, you can display them respectively or together." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 75 + }, + "id": "UGuAk9PNDRF3", + "outputId": "73feb33d-4a05-48fb-96e5-3c48c2a456f3" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:121: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "version. Use `json_query` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# filter images and display, you can also display audio and video types\n", + "df_image[df_image[\"author\"] == \"alice\"][\"image\"].blob.display()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "1IJuakwJTZey" + }, + "source": [ + "### 3. Conduct image transformations\n", + "BigFrames Multimodal DataFrame provides image(and other) transformation functions. Such as image_blur, image_resize and image_normalize. The output can be saved to GCS folders or to BQ as bytes." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "VWsl5BBPJ6N7", + "outputId": "45d2356e-322b-4982-cfa7-42d034dc4344" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n" + ] + } + ], + "source": [ + "df_image[\"blurred\"] = df_image[\"image\"].blob.image_blur(\n", + " (20, 20), dst=f\"gs://{OUTPUT_BUCKET}/image_blur_transformed/\", engine=\"opencv\"\n", + ")\n", + "df_image[\"resized\"] = df_image[\"image\"].blob.image_resize(\n", + " (300, 200), dst=f\"gs://{OUTPUT_BUCKET}/image_resize_transformed/\", engine=\"opencv\"\n", + ")\n", + "df_image[\"normalized\"] = df_image[\"image\"].blob.image_normalize(\n", + " alpha=50.0,\n", + " beta=150.0,\n", + " norm_type=\"minmax\",\n", + " dst=f\"gs://{OUTPUT_BUCKET}/image_normalize_transformed/\",\n", + " engine=\"opencv\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "rWCAGC8w64vU", + "outputId": "d7d456f0-8b56-492c-fe1b-967e9664d813" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n" + ] + } + ], + "source": [ + "# You can also chain functions together\n", + "df_image[\"blur_resized\"] = df_image[\"blurred\"].blob.image_resize((300, 200), dst=f\"gs://{OUTPUT_BUCKET}/image_blur_resize_transformed/\", engine=\"opencv\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Using `verbose` mode for detailed output\\n\n", + "\\n\n", + "All multimodal functions support a `verbose` parameter, which defaults to `False`.\\n\n", + "\\n\n", + "* When `verbose=False` (the default), the function will only return the main content of the result (e.g., the transformed image, the extracted text).\\n\n", + "* When `verbose=True`, the function returns a `STRUCT` containing two fields:\\n\n", + " * `content`: The main result of the operation.\\n\n", + " * `status`: An informational field. If the operation is successful, this will be empty. If an error occurs during the processing of a specific row, this field will contain the error message, allowing the overall job to complete without failing.\\n\n", + "\\n\n", + "Using `verbose=True` is highly recommended for debugging and for workflows where you need to handle potential failures on a row-by-row basis. Let's see it in action with the `image_blur` function." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
blurred_verbose
0{'status': '', 'content': {'uri': 'gs://bigfra...
1{'status': '', 'content': {'uri': 'gs://bigfra...
2{'status': '', 'content': {'uri': 'gs://bigfra...
3{'status': '', 'content': {'uri': 'gs://bigfra...
4{'status': '', 'content': {'uri': 'gs://bigfra...
\n", + "

5 rows × 1 columns

\n", + "
[5 rows x 1 columns in total]" + ], + "text/plain": [ + " blurred_verbose\n", + "0 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "1 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "2 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "3 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "4 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "\n", + "[5 rows x 1 columns]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_image[\"blurred_verbose\"] = df_image[\"image\"].blob.image_blur(\n", + " (20, 20), dst=f\"gs://{OUTPUT_BUCKET}/image_blur_transformed_verbose/\", engine=\"opencv\", verbose=True\n", + ")\n", + "df_image[[\"blurred_verbose\"]]" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 605 + }, + "id": "6NGK6GYSU44B", + "outputId": "859101c1-2ee4-4f9a-e250-e8947127420a" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
imageauthorcontent_typesizeupdatedblurredresizednormalizedblur_resizedblurred_verbose
0aliceimage/png15912402025-03-20 17:45:04+00:00{'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/k9-guard-dog-paw-balm.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}
1bobimage/png11829512025-03-20 17:45:02+00:00{'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/k9-guard-dog-hot-spot-spray.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}
2bobimage/png15208842025-03-20 17:44:55+00:00{'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/fluffy-buns-chinchilla-food-variety-pack.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}
3aliceimage/png12354012025-03-20 17:45:19+00:00{'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/purrfect-perch-cat-scratcher.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}
4bobimage/png15919232025-03-20 17:44:47+00:00{'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/chirpy-seed-deluxe-bird-food.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}
\n", + "

5 rows × 10 columns

\n", + "
[5 rows x 10 columns in total]" + ], + "text/plain": [ + " image author content_type \\\n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "2 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "3 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n", + "4 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "\n", + " size updated \\\n", + "0 1591240 2025-03-20 17:45:04+00:00 \n", + "1 1182951 2025-03-20 17:45:02+00:00 \n", + "2 1520884 2025-03-20 17:44:55+00:00 \n", + "3 1235401 2025-03-20 17:45:19+00:00 \n", + "4 1591923 2025-03-20 17:44:47+00:00 \n", + "\n", + " blurred \\\n", + "0 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "1 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "2 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "3 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "4 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "\n", + " resized \\\n", + "0 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "1 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "2 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "3 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "4 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "\n", + " normalized \\\n", + "0 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "1 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "2 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "3 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "4 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "\n", + " blur_resized \\\n", + "0 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "1 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "2 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "3 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "4 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "\n", + " blurred_verbose \n", + "0 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "1 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "2 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "3 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "4 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "\n", + "[5 rows x 10 columns]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_image" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Euk5saeVVdTP" + }, + "source": [ + "### 4. Use LLM models to ask questions and generate embeddings on images" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "id": "mRUGfcaFVW-3" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FutureWarning: Since upgrading the default model can cause unintended breakages, the\n", + "default model will be removed in BigFrames 3.0. Please supply an\n", + "explicit model to avoid this message.\n", + " return method(*args, **kwargs)\n" + ] + } + ], + "source": [ + "from bigframes.ml import llm\n", + "gemini = llm.GeminiTextGenerator()" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 657 + }, + "id": "DNFP7CbjWdR9", + "outputId": "3f90a062-0abc-4bce-f53c-db57b06a14b9" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ml_generate_text_llm_resultimage
0The item is a tin of K9 Guard dog paw balm.
1The item is K9 Guard Dog Hot Spot Spray.
\n", + "

2 rows × 2 columns

\n", + "
[2 rows x 2 columns in total]" + ], + "text/plain": [ + " ml_generate_text_llm_result \\\n", + "0 The item is a tin of K9 Guard dog paw balm. \n", + "1 The item is K9 Guard Dog Hot Spot Spray. \n", + "\n", + " image \n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", + "\n", + "[2 rows x 2 columns]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Ask the same question on the images\n", + "df_image = df_image.head(2)\n", + "answer = gemini.predict(df_image, prompt=[\"what item is it?\", df_image[\"image\"]])\n", + "answer[[\"ml_generate_text_llm_result\", \"image\"]]" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "id": "IG3J3HsKhyBY" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + } + ], + "source": [ + "# Ask different questions\n", + "df_image[\"question\"] = [\"what item is it?\", \"what color is the picture?\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 657 + }, + "id": "qKOb765IiVuD", + "outputId": "731bafad-ea29-463f-c8c1-cb7acfd70e5d" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ml_generate_text_llm_resultimage
0The item is a tin of K9Guard Dog Paw Balm.
1The bottle is mostly white, with a light blue accents. The background is a light gray. There are also black and green elements on the bottle's label.
\n", + "

2 rows × 2 columns

\n", + "
[2 rows x 2 columns in total]" + ], + "text/plain": [ + " ml_generate_text_llm_result \\\n", + "0 The item is a tin of K9Guard Dog Paw Balm. \n", + "1 The bottle is mostly white, with a light blue ... \n", + "\n", + " image \n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", + "\n", + "[2 rows x 2 columns]" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "answer_alt = gemini.predict(df_image, prompt=[df_image[\"question\"], df_image[\"image\"]])\n", + "answer_alt[[\"ml_generate_text_llm_result\", \"image\"]]" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 300 + }, + "id": "KATVv2CO5RT1", + "outputId": "6ec01f27-70b6-4f69-c545-e5e3c879480c" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FutureWarning: Since upgrading the default model can cause unintended breakages, the\n", + "default model will be removed in BigFrames 3.0. Please supply an\n", + "explicit model to avoid this message.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ml_generate_embedding_resultml_generate_embedding_statusml_generate_embedding_start_secml_generate_embedding_end_seccontent
0[ 0.00638842 0.01666344 0.00451782 ... -0.02...<NA><NA>{\"access_urls\":{\"expiry_time\":\"2025-10-25T00:2...
1[ 0.00973689 0.02148374 0.00244311 ... 0.00...<NA><NA>{\"access_urls\":{\"expiry_time\":\"2025-10-25T00:2...
\n", + "

2 rows × 5 columns

\n", + "
[2 rows x 5 columns in total]" + ], + "text/plain": [ + " ml_generate_embedding_result \\\n", + "0 [ 0.00638842 0.01666344 0.00451782 ... -0.02... \n", + "1 [ 0.00973689 0.02148374 0.00244311 ... 0.00... \n", + "\n", + " ml_generate_embedding_status ml_generate_embedding_start_sec \\\n", + "0 \n", + "1 \n", + "\n", + " ml_generate_embedding_end_sec \\\n", + "0 \n", + "1 \n", + "\n", + " content \n", + "0 {\"access_urls\":{\"expiry_time\":\"2025-10-25T00:2... \n", + "1 {\"access_urls\":{\"expiry_time\":\"2025-10-25T00:2... \n", + "\n", + "[2 rows x 5 columns]" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Generate embeddings.\n", + "embed_model = llm.MultimodalEmbeddingGenerator()\n", + "embeddings = embed_model.predict(df_image[\"image\"])\n", + "embeddings" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "iRUi8AjG7cIf" + }, + "source": [ + "### 5. PDF chunking function" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "id": "oDDuYtUm5Yiy" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + } + ], + "source": [ + "df_pdf = bpd.from_glob_path(\"gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/documents/*\", name=\"pdf\")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "7jLpMYaj7nj8", + "outputId": "06d5456f-580f-4693-adff-2605104b056c" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:239: UserWarning: The `json_extract_string_array` is deprecated and will be removed in a\n", + "future version. Use `json_value_array` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:239: UserWarning: The `json_extract_string_array` is deprecated and will be removed in a\n", + "future version. Use `json_value_array` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n" + ] + } + ], + "source": [ + "df_pdf[\"chunked\"] = df_pdf[\"pdf\"].blob.pdf_chunk(engine=\"pypdf\")" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:239: UserWarning: The `json_extract_string_array` is deprecated and will be removed in a\n", + "future version. Use `json_value_array` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
chunked_verbose
0{'status': '', 'content': array([\"CritterCuisi...
\n", + "

1 rows × 1 columns

\n", + "
[1 rows x 1 columns in total]" + ], + "text/plain": [ + " chunked_verbose\n", + "0 {'status': '', 'content': array([\"CritterCuisi...\n", + "\n", + "[1 rows x 1 columns]" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_pdf[\"chunked_verbose\"] = df_pdf[\"pdf\"].blob.pdf_chunk(engine=\"pypdf\", verbose=True)\n", + "df_pdf[[\"chunked_verbose\"]]" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "id": "kaPvJATN7zlw" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/plain": [ + "0 CritterCuisine Pro 5000 - Automatic Pet Feeder...\n", + "0 on a level, stable surface to prevent tipping....\n", + "0 included)\\nto maintain the schedule during pow...\n", + "0 digits for Meal 1 will flash.\\n\u0000. Use the UP/D...\n", + "0 paperclip) for 5\\nseconds. This will reset all...\n", + "0 unit with a damp cloth. Do not immerse the bas...\n", + "0 continues,\\ncontact customer support.\\nE2: Foo...\n", + "Name: chunked, dtype: string" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "chunked = df_pdf[\"chunked\"].explode()\n", + "chunked" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 6. Audio transcribe function" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + } + ], + "source": [ + "audio_gcs_path = \"gs://bigframes_blob_test/audio/*\"\n", + "df = bpd.from_glob_path(audio_gcs_path, name=\"audio\")" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/plain": [ + "0 Now, as all books, not primarily intended as p...\n", + "Name: transcribed_content, dtype: string" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "transcribed_series = df['audio'].blob.audio_transcribe(model_name=\"gemini-2.0-flash-001\", verbose=False)\n", + "transcribed_series" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/plain": [ + "0 {'status': '', 'content': 'Now, as all books, ...\n", + "Name: transcription_results, dtype: struct[pyarrow]" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "transcribed_series_verbose = df['audio'].blob.audio_transcribe(model_name=\"gemini-2.0-flash-001\", verbose=True)\n", + "transcribed_series_verbose" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.18" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/notebooks/remote_functions/remote_function.ipynb b/notebooks/remote_functions/remote_function.ipynb index 2114311e10..e2bc88ecae 100644 --- a/notebooks/remote_functions/remote_function.ipynb +++ b/notebooks/remote_functions/remote_function.ipynb @@ -174,7 +174,7 @@ "source": [ "# User defined function\n", "# https://www.codespeedy.com/find-nth-prime-number-in-python/\n", - "def nth_prime(n):\n", + "def nth_prime(n: int) -> int:\n", " prime_numbers = [2,3]\n", " i=3\n", " if(0 int:\n", " prime_numbers = [2,3]\n", " i=3\n", " if(0Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" }, { "data": { "text/html": [ - "Query job 9d155f10-e37a-4d20-b2ff-02868ecb58f4 is DONE. 582.8 kB processed. Open Job" + "Query job ba19f29c-33d3-4f12-9605-ddeafb74918e is DONE. 582.8 kB processed. Open Job" ], "text/plain": [ "" @@ -88,7 +92,7 @@ { "data": { "text/html": [ - "Query job 5a524e70-12dc-4116-b416-04570bbf754e is DONE. 82.0 kB processed. Open Job" + "Query job dd1ff8be-700a-4ce5-91a0-31413f70cfad is DONE. 82.0 kB processed. Open Job" ], "text/plain": [ "" @@ -125,49 +129,49 @@ " \n", "
36RedsCubs15988RoyalsAthletics176
358106DodgersDiamondbacks223Giants216
416YankeesWhite Sox216166PhilliesRoyals162
523RaysAthletics187247RangersRoyals161
594PiratesBrewers169374AthleticsAstros161
\n", "" ], "text/plain": [ - " homeTeamName awayTeamName duration_minutes\n", - "36 Reds Cubs 159\n", - "358 Dodgers Diamondbacks 223\n", - "416 Yankees White Sox 216\n", - "523 Rays Athletics 187\n", - "594 Pirates Brewers 169" + " homeTeamName awayTeamName duration_minutes\n", + "88 Royals Athletics 176\n", + "106 Dodgers Giants 216\n", + "166 Phillies Royals 162\n", + "247 Rangers Royals 161\n", + "374 Athletics Astros 161" ] }, - "execution_count": 22, + "execution_count": 3, "metadata": {}, "output_type": "execute_result" } @@ -216,7 +220,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 4, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -226,10 +230,18 @@ "outputId": "19351206-116e-4da2-8ff0-f288b7745b27" }, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/arwas/src1/python-bigquery-dataframes/bigframes/functions/_function_session.py:335: UserWarning: You have not explicitly set a user-managed cloud_function_service_account. Using the default compute service account, {cloud_function_service_account}. To use Bigframes 2.0, please set an explicit user-managed cloud_function_service_account or set cloud_function_service_account explicitly to `default`.See, https://cloud.google.com/functions/docs/securing/function-identity.\n", + " warnings.warn(msg, category=UserWarning)\n" + ] + }, { "data": { "text/html": [ - "Query job ec8d958d-93ef-45ae-8150-6ccfa8feb89a is DONE. 0 Bytes processed. Open Job" + "Query job 7c021760-59c4-4f3a-846c-9693a4d16eef is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -242,12 +254,12 @@ "name": "stdout", "output_type": "stream", "text": [ - "Created cloud function 'projects/bigframes-dev/locations/us-central1/functions/bigframes-session54c8b0-e22dbecc9ec0374bda36bc23df3775b0-g8zp' and BQ remote function 'bigframes-dev._1b6c31ff1bcd5d2f6d86833cf8268317f1b12d57.bigframes_session54c8b0_e22dbecc9ec0374bda36bc23df3775b0_g8zp'.\n" + "Created cloud function 'projects/bigframes-dev/locations/us-central1/functions/bigframes-sessionca6012-ca541a90249f8b62951f38b7aba6a711-49to' and BQ remote function 'bigframes-dev._ed1e4d0f7d41174ba506d34d15dccf040d13f69e.bigframes_sessionca6012_ca541a90249f8b62951f38b7aba6a711_49to'.\n" ] } ], "source": [ - "@bpd.remote_function(reuse=False)\n", + "@bpd.remote_function(reuse=False, cloud_function_service_account=\"default\")\n", "def duration_category(duration_minutes: int) -> str:\n", " if duration_minutes < 90:\n", " return \"short\"\n", @@ -454,7 +466,7 @@ } ], "source": [ - "@bpd.remote_function(reuse=False)\n", + "@bpd.remote_function(reuse=False, cloud_function_service_account=\"default\")\n", "def duration_category(duration_minutes: int) -> str:\n", " if duration_minutes < 90:\n", " return DURATION_CATEGORY_SHORT\n", @@ -663,7 +675,7 @@ } ], "source": [ - "@bpd.remote_function(reuse=False)\n", + "@bpd.remote_function(reuse=False, cloud_function_service_account=\"default\")\n", "def duration_category(duration_minutes: int) -> str:\n", " duration_hours = mymath.ceil(duration_minutes / 60)\n", " return f\"{duration_hours}h\"\n", @@ -874,7 +886,7 @@ } ], "source": [ - "@bpd.remote_function(reuse=False)\n", + "@bpd.remote_function(reuse=False, cloud_function_service_account=\"default\")\n", "def duration_category(duration_minutes: int) -> str:\n", " duration_hours = get_hour_ceiling(duration_minutes)\n", " return f\"{duration_hours} hrs\"\n", @@ -1056,7 +1068,7 @@ } ], "source": [ - "@bpd.remote_function(reuse=False, packages=[\"cryptography\"])\n", + "@bpd.remote_function(reuse=False, packages=[\"cryptography\"], cloud_function_service_account=\"default\")\n", "def get_hash(input: str) -> str:\n", " from cryptography.fernet import Fernet\n", "\n", @@ -1259,7 +1271,7 @@ } ], "source": [ - "@bpd.remote_function(reuse=False, packages=[\"humanize\"])\n", + "@bpd.remote_function(reuse=False, packages=[\"humanize\"], cloud_function_service_account=\"default\")\n", "def duration_category(duration_minutes: int) -> str:\n", " timedelta = dt.timedelta(minutes=duration_minutes)\n", " return humanize.naturaldelta(timedelta)\n", @@ -1430,7 +1442,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.11.4" } }, "nbformat": 4, diff --git a/notebooks/remote_functions/remote_function_vertex_claude_model.ipynb b/notebooks/remote_functions/remote_function_vertex_claude_model.ipynb index 78f0d27474..9792c90205 100644 --- a/notebooks/remote_functions/remote_function_vertex_claude_model.ipynb +++ b/notebooks/remote_functions/remote_function_vertex_claude_model.ipynb @@ -10,12 +10,12 @@ "\n", " \n", " \n", - " \"Colab Run in Colab\n", + " \"Colab Run in Colab\n", " \n", " \n", " \n", " \n", - " \"GitHub\n", + " \"GitHub\n", " View on GitHub\n", " \n", " \n", @@ -286,7 +286,9 @@ "source": [ "@bpd.remote_function(packages=[\"anthropic[vertex]\", \"google-auth[requests]\"],\n", " max_batching_rows=1, \n", - " bigquery_connection=\"bigframes-dev.us-east5.bigframes-rf-conn\") # replace with your connection\n", + " bigquery_connection=\"bigframes-dev.us-east5.bigframes-rf-conn\", # replace with your connection\n", + " cloud_function_service_account=\"default\",\n", + ")\n", "def anthropic_transformer(message: str) -> str:\n", " from anthropic import AnthropicVertex\n", " client = AnthropicVertex(region=LOCATION, project_id=PROJECT)\n", diff --git a/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb b/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb index c3b4c8e616..d69aecd8c3 100644 --- a/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb +++ b/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb @@ -35,17 +35,17 @@ "\n", " \n", " \n", - " \"Colab Run in Colab\n", + " \"Colab Run in Colab\n", " \n", " \n", " \n", - " \n", - " \"GitHub\n", + " \n", + " \"GitHub\n", " View on GitHub\n", " \n", " \n", " \n", - " \n", + " \n", " \"BQ\n", " Open in BQ Studio\n", " \n", @@ -222,7 +222,9 @@ "# It defaults to the location of the first table or query\n", "# passed to read_gbq(). For APIs where a location can't be\n", "# auto-detected, the location defaults to the \"US\" location.\n", - "bpd.options.bigquery.location = REGION" + "bpd.options.bigquery.location = REGION\n", + "# Improves performance by avoiding generating total row ordering\n", + "bpd.options.bigquery.ordering_mode = \"partial\"" ] }, { @@ -341,18 +343,6 @@ "id": "gFbCgfFC2gHw" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job 307ec006-490f-435d-b3e3-74eb1d73fe0f is DONE. 372.9 MB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [ @@ -365,7 +355,7 @@ }, { "data": { - "image/png": "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zNCGrMHghoiIFGFqWIqTpcgeGNwQEZEiygMVc3tLmVyh2KEtIrWwObiZNWsWtm7disLCQke0h4iIbjFaM/tJcXViqi6bg5vdu3fj7rvvRnBwMPr374/XXnsNf/75JwoKChzRPiIiUqmKFYorjhkHNMbxzemUHMxck4CULH7AJvNsDm42b96MzMxMbNmyBXfeeSfi4+MxZswYBAcHo1+/fo5oIxERqVBFzY3169x8H38RK/cm49nvDjmyaeTibF7nBgDc3d3Rt29f1KtXD6GhoQgICMDatWtx6tQpe7ePiIhUznizzIrjGpirsjlxNduhbSLXZnPm5vPPP8f999+PRo0aISYmBhs3bkS/fv0QHx+P9PR0R7SRiIhUyOT2C4q0hNTG5szNk08+iXr16uH555/HU089BX9/f0e0i4iIVM7UsFSVG2caHst5U2SezZmbn3/+GQ888ABWrVqFevXqISYmBjNnzsSmTZuQn5/viDYSEZEKlRcUW9o4kzOmqDpsztyMHj0ao0ePBgBkZWXh77//xurVq3HXXXdBq9VyijgREdnI9PRvBjZUXdUqKM7IyMC2bdsQFxeHuLg4HD9+HCEhIejfv7+920dERGplauNMK6tuOChFltgc3HTq1AknT55ESEgIbrvtNkyePBkDBgxA586dHdE+IiJSKZMFxczWkB1Uq6B4wIAB6NixoyPaQ0REtxhz2y9YwnpissTm4Obpp58GABQXF+PcuXNo0aIF3N2rNbpFRES3sPIZT+bqbDSwvNYNkTk2z5YqKCjAY489Bl9fX3To0AHJyckAgGeeeQazZ8+2ewOJiEh9UrMLkVesA1C9qeBEltgc3Lzyyis4cuQI4uLi4O3tbTg+ePBgfP/993ZtHBERqU9KViF6v7cFm0+kAjC/E7jGQqAjmM0hC2weT1q7di2+//579OnTR/bC69ChA86cOWPXxhERkfrsPZch+15jZldwS1hzQ5bYnLlJT09HWFhYpeN5eXlWvyiJiOjW5ePhJvtea+bSYemKwtiGLLE5uOnRowd+/fVXw/flAc0XX3yB6Oho+7WMiIhUybtScGOh5oafmakabB6Weu+99zBixAicOHECpaWlWLBgAU6cOIFdu3Zh27ZtjmgjERGpmLldwYmqy+bMTb9+/XD48GGUlpaiU6dO2LRpE8LCwrB79250797dEW0kIiIV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", + "image/png": "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", "text/plain": [ "
" ] @@ -419,13 +409,14 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 55, "metadata": { "id": "LqqHzjty8jk0" }, "outputs": [], "source": [ - "symptom_data = all_data[[\"new_confirmed\", \"search_trends_cough\", \"search_trends_fever\", \"search_trends_bruise\"]]" + "regional_data = all_data[all_data[\"aggregation_level\"] == 1] # get only region level data,\n", + "symptom_data = regional_data[[\"location_key\", \"new_confirmed\", \"search_trends_cough\", \"search_trends_fever\", \"search_trends_bruise\", \"population\", \"date\"]]" ] }, { @@ -434,92 +425,45 @@ "id": "b3DlJX-k9SPk" }, "source": [ - "Not all rows have data for all of these columns, so let's select only the rows that do." + "Not all rows have data for all of these columns, so let's select only the rows that do. Finally, lets add a new column capturing new confirmed cases as a percentage of area population." ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": { "id": "g4MeM8Oe9Q6X" }, "outputs": [], "source": [ - "symptom_data = symptom_data.dropna()" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "IlXt__om9QYI" - }, - "source": [ - "We want to use a line of best fit to make the correlation stand out. Matplotlib does not include a feature for lines of best fit, but seaborn, which is built on matplotlib, does.\n", + "symptom_data = symptom_data.dropna()\n", + "symptom_data = symptom_data[symptom_data[\"new_confirmed\"] > 0]\n", + "symptom_data[\"new_cases_percent_of_pop\"] = (symptom_data[\"new_confirmed\"] / symptom_data[\"population\"]) * 100\n", "\n", - "BigQuery DataFrames does not currently integrate with seaborn by default. So we will demonstrate how to downsample and download a DataFrame, and use seaborn on the downloaded data." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "MmfgKMaEXNbL" - }, - "source": [ - "### Downsample and download" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "wIuG1JRTPAk9" - }, - "source": [ - "BigQuery DataFrames options let us set up the sampling functionality we need. Calls to `to_pandas()` usually download all the data available in our BigQuery table and store it locally as a pandas DataFrame. `pd.options.sampling.enable_downsampling = True` will make future calls to `to_pandas` use downsampling to download only part of the data, and `pd.options.sampling.max_download_size` allows us to set the amount of data to download." + "\n", + "# remove impossible data points\n", + "symptom_data = symptom_data[(symptom_data[\"new_cases_percent_of_pop\"] >= 0)]\n" ] }, { "cell_type": "code", - "execution_count": 12, - "metadata": { - "id": "x95ZgBkyDMP4" - }, + "execution_count": null, + "metadata": {}, "outputs": [], "source": [ - "bpd.options.sampling.enable_downsampling = True # enable downsampling\n", - "bpd.options.sampling.max_download_size = 5 # download only 5 mb of data" + "# group data up by week\n", + "weekly_data = symptom_data.groupby([symptom_data.location_key, symptom_data.date.dt.isocalendar().week]).agg({\"new_cases_percent_of_pop\": \"sum\", \"search_trends_cough\": \"mean\", \"search_trends_fever\": \"mean\", \"search_trends_bruise\": \"mean\"})" ] }, { "cell_type": "markdown", "metadata": { - "id": "C6sCXkrQPJC_" + "id": "IlXt__om9QYI" }, "source": [ - "Download the data and note the message letting us know that downsampling is being used." - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "id": "V0OK02D7PJSL" - }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 44159a16-cab9-4ffa-be68-2228387a48c2 is DONE. 12.6 GB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "local_symptom_data = symptom_data.to_pandas(sampling_method=\"uniform\")" + "We want to use a line of best fit to make the correlation stand out. Matplotlib does not include a feature for lines of best fit, but seaborn, which is built on matplotlib, does.\n", + "\n", + "BigQuery DataFrames does not currently integrate with seaborn by default. So we will demonstrate how to downsample and download a DataFrame, and use seaborn on the downloaded data." ] }, { @@ -540,12 +484,12 @@ "source": [ "We will now use seaborn to make the plots with the lines of best fit for cough, fever, and bruise. Note that since we're working with a local pandas dataframe, you could use any other Python library or technique you're familiar with, but we'll stick to seaborn for this notebook.\n", "\n", - "Seaborn will take a few seconds to calculate the lines. Since cough and fever are symptoms of COVID-19, but bruising isn't, we expect the slope of the line of best fit to be positive in the first two graphs, but not the third, indicating that there is a correlation between new COVID-19 cases and cough- and fever-related searches." + "Seaborn will take a few minutes to calculate the lines. Since cough and fever are symptoms of COVID-19, but bruising isn't, we expect the slope of the line of best fit to be positive in the first two graphs, but not the third, indicating that there is a correlation between new COVID-19 cases and cough- and fever-related searches." ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 59, "metadata": { "id": "EG7qM3R18bOb" }, @@ -553,16 +497,16 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 14, + "execution_count": 59, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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", + "image/png": "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N9z+2hBcuapN1XaZLoaoctRsO1O0dq100O4Avdnjder0yd2SMlQ8P+ClqSIBtDX4B2HI967neeriJC9OldZd74przSbSbKL9S+a2girLJA2FdEzb954SgUCwc9ouPt72trfxB3/wB7z22mvcd999vPDCC3zrW9/i937v91p+v23b2PYb463lcrndRwI2Tvk3REk2oXFjqUYmruIH4Z72D6zMCOSqNrmas0oknehLNk3PwjAkaahULLf5s5IksVixuZWrM1+2mS/bnOrf2JW1OS3iB4RhNJlwrDdJX0rfs+bT3WY3p0/2S9hYro8XBAxkYqRiats2zTpewFcvz/P0pSlu5errro91x/n4o2P82AODGNre91FIkkRSV5qvWSAQHHza/pv8a7/2a5TLZe6//34URcH3fX77t3+bn/mZn2n5/Z/61Kf4rd/6rXYfYx19KZ2RrlhzqqUvFW3qbIiSXNVBlSWm8iYxXSJpKIRhuGEJpp0BaOUdfMpQKZneKpEkSVLT9GyjhWqNczx+opebS9VNXVkbgmu0O7G85dZoBuatZiY2e/07fW/a+Z7u5vTJXo/VrvXqaNem2arl8cUXZvj8c9Pkas666w8MZ3jysXHedqp3X8oaoqwiEBxe2i4+nn76af77f//v/I//8T948MEHef755/nlX/5lRkZG+Lmf+7l13//JT36ST3ziE80/l8tlxsfH230sFis2l2fLVG2PpapNb1JnMBtvZh0qlstod5ybSzVML+C713NMdCc3LMHsVgBa2xfRl9JZKFvNP1cst2VQTRkqqiJjuT6j3ZHvxVZNzJK6suo5thL0N3v9d+qv2eh52vme7ub0yV6N1Xp+QKHuNrNc7WK+bPG5Z6f40otzmO56x9O3n+rlycfGeWj07qfAtosqy6RikeAQZRWB4PDSdvHxK7/yK/zar/0aP/3TPw3A2bNnuXXrFp/61Kdaig/DMDCM3U/v387XeX2xRldcY75cY6Insco+XZIkDFWmL20QhiG3l2qYKZdcNWxasa9kJ6OsWwnqa/sY1mY6RrpiLYPqdpo5135vEAR88+oSNdsjaai8896+O1rLb/b679Rfs5G4aGdQ383pk90eq/WDaGy2ZLpt9eq4tlDl6YuT/NWVhXVNpJoi8WMPDPHxC2PNnUZbJQxD8jWXuuM1S0HbyViJsopAcPRo+296vV5H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ueOOO3j00Ue7Hdaa+exnP4umaXz4wx/udigd8clPfhJN01Z87dixo9thddSRI0f4gz/4A8rlMq7rcsMNN/D00093O6yO2bRp0yl/h5qmsXv37m6HdtGSJOEv//Iv2bx5M67rsnXrVv7qr/7qvM5fWUvrIvn4l3/5Fz7ykY/wwAMP8Oyzz3LjjTfyG7/xG0xPT3c7tI7wPI8bb7yRL33pS90OZU08/vjj7N69m6eeeorHHnuMKIp4z3veg+d53Q6tI8bGxvjsZz/LM888w9NPP8073/lOfuu3fotf/epX3Q6t4/bs2cNXvvIVdu3a1e1QOur666/n2LFjy19PPvlkt0PqmIWFBd7ylrdgWRaPPvooL7zwAn/7t39Lb29vt0PrmD179qz4+3vssccAeN/73tflyC7e5z73Ob785S/zxS9+kRdffJHPfe5z/M3f/A1f+MIXuhuYWgduu+02tXv37uXvkyRRo6Oj6sEHH+xiVGsDUI888ki3w1hT09PTClCPP/54t0NZM729veof//Efux1GR9XrdXXttdeqxx57TL3jHe9Q999/f7dD6ogHHnhA3Xjjjd0OY8189KMfVW9961u7HcYldf/996utW7eqNE27HcpFu/vuu9V999234rHf+Z3fUffcc0+XImq76mc+wjDkmWee4d3vfvfyY7qu8+53v5v//d//7WJk4kJVq1UA+vr6uhxJ5yVJwje/+U08z+OOO+7odjgdtXv3bu6+++4V/y9eLV599VVGR0fZsmUL99xzDwcPHux2SB3z7//+79xyyy28733vY3BwkJtuuomvfe1r3Q5rzYRhyD//8z9z3333dfxw02648847+eEPf8grr7wCwC9+8QuefPJJ7rrrrq7GddkdLNdps7OzJEnC0NDQiseHhoZ46aWXuhSVuFBpmvLhD3+Yt7zlLezcubPb4XTMc889xx133IHv++TzeR555BHe9KY3dTusjvnmN7/Js88+y549e7odSsfdfvvtfP3rX2f79u0cO3aMT33qU7ztbW/j+eefp1AodDu8i7Z//36+/OUv85GPfIRPfOIT7Nmzhz/5kz/Btm3uvffebofXcd/5zneoVCp84AMf6HYoHfGxj32MWq3Gjh07MAyDJEn49Kc/zT333NPVuK765ENcXXbv3s3zzz9/Va2pA2zfvp29e/dSrVb513/9V+69914ef/zxqyIBOXToEPfffz+PPfYYmUym2+F03Il3kLt27eL2229nYmKCb33rW/zhH/5hFyPrjDRNueWWW/jMZz4DwE033cTzzz/PP/zDP1yVycc//dM/cddddzE6OtrtUDriW9/6Ft/4xjd4+OGHuf7669m7dy8f/vCHGR0d7erf31WffPT392MYBlNTUysen5qaYnh4uEtRiQvxoQ99iO9973s88cQTjI2NdTucjrJtm2uuuQaAN7/5zezZs4e///u/5ytf+UqXI7t4zzzzDNPT09x8883LjyVJwhNPPMEXv/hFgiDAMIwuRthZPT09bNu2jddee63boXTEyMjIKUnwddddx7/92791KaK1c+DAAX7wgx/w7W9/u9uhdMyf//mf87GPfYzf+73fA+CGG27gwIEDPPjgg11NPq76mg/btnnzm9/MD3/4w+XH0jTlhz/84VW3pn61UkrxoQ99iEceeYT//u//ZvPmzd0Oac2laUoQBN0OoyPe9a538dxzz7F3797lr1tuuYV77rmHvXv3XlWJB0Cj0WDfvn2MjIx0O5SOeMtb3nLK1vZXXnmFiYmJLkW0dh566CEGBwe5++67ux1KxzSbTXR95VBvGAZpmnYporarfuYD4CMf+Qj33nsvt9xyC7fddhuf//zn8TyPD37wg90OrSMajcaKu6zXX3+dvXv30tfXx/j4eBcj64zdu3fz8MMP893vfpdCocDk5CQApVIJ13W7HN3F+/jHP85dd93F+Pg49Xqdhx9+mB//+Md8//vf73ZoHVEoFE6pz8nlcpTL5auibufP/uzPeO9738vExARHjx7lgQcewDAMfv/3f7/boXXEn/7pn3LnnXfymc98ht/93d/lZz/7GV/96lf56le/2u3QOipNUx566CHuvfdeTPPqGRrf+9738ulPf5rx8XGuv/56fv7zn/N3f/d33Hfffd0NrKt7bS6hL3zhC2p8fFzZtq1uu+029dRTT3U7pI750Y9+pIBTvu69995uh9YRp3tvgHrooYe6HVpH3HfffWpiYkLZtq0GBgbUu971LvVf//Vf3Q5rTV1NW23f//73q5GREWXbttqwYYN6//vfr1577bVuh9VR//Ef/6F27typHMdRO3bsUF/96le7HVLHff/731eAevnll7sdSkfVajV1//33q/HxcZXJZNSWLVvUX/zFX6ggCLoal6ZUl9ucCSGEEGJdueprPoQQQghxeZHkQwghhBCXlCQfQgghhLikJPkQQgghxCUlyYcQQgghLilJPoQQQghxSUnyIYQQQohLSpIPIYQQQlxSknwIIYQQ4pKS5EMIIYQQl5QkH0IIIYS4pCT5EEIIIcQl9f8DvBq4eqmKlScAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -574,19 +518,13 @@ "source": [ "import seaborn as sns\n", "\n", - "# first, convert to a data type that is suitable for seaborn\n", - "local_symptom_data[\"new_confirmed\"] = \\\n", - " local_symptom_data[\"new_confirmed\"].astype(float)\n", - "local_symptom_data[\"search_trends_cough\"] = \\\n", - " local_symptom_data[\"search_trends_cough\"].astype(float)\n", - "\n", "# draw the graph. This might take ~30 seconds.\n", - "sns.regplot(x=\"new_confirmed\", y=\"search_trends_cough\", data=local_symptom_data)" + "sns.regplot(x=\"new_cases_percent_of_pop\", y=\"search_trends_cough\", data=weekly_data, scatter_kws={'alpha': 0.2, \"s\" :5})" ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 62, "metadata": { "id": "5nVy61rEGaM4" }, @@ -594,16 +532,16 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 15, + "execution_count": 62, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjMAAAGxCAYAAACXwjeMAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/H5lhTAAAACXBIWXMAAA9hAAAPYQGoP6dpAABu2UlEQVR4nO3deXxU5dk//s85Z/ZkJgshG5vsBAqI8MiiglUqqF8rSJ+6tVXrVotai32su/K0Cq22Yq1Sf2qx7ePe1q3u0oILuCEoQlgSkEWykITMvp5z//44mSGTdTKZMDPJ5/1qXjVnTmbuGSZzrtz3dV+XJIQQICIiIspScroHQERERNQbDGaIiIgoqzGYISIioqzGYIaIiIiyGoMZIiIiymoMZoiIiCirMZghIiKirMZghoiIiLKaId0D6GuapuHQoUOw2+2QJCndwyEiIqIECCHgdrtRXl4OWe567qXfBzOHDh3CsGHD0j0MIiIiSsKBAwcwdOjQLs/p98GM3W4HoL8YDocjzaMhIiKiRLhcLgwbNix2He9Kvw9moktLDoeDwQwREVGWSSRFhAnARERElNUYzBAREVFWYzBDREREWY3BDBEREWU1BjNERESU1RjMEBERUVZjMENERERZjcEMERERZTUGM0RERJTV+n0FYKLOaJrAtkMuNPlCKLSZMKncAVlmM1IiomzDYIYGpA1VDVi9vhrV9R6EVQGjImF0cS6umTcac8YUpXt4RETUA1xmogFnQ1UDbn1xKyprXMgxG1BsNyPHbEBljRu3vrgVG6oa0j1EIiLqAQYzNKBomsDq9dXwBCMosZshBOANRSAEUGI3wRNUsXp9NTRNpHuoRESUIC4z0YCy7ZAL1fUemA0y9jX5EIxoEAKQJMBskOGwGlFd78G2Qy5MHpqX7uESEVECGMzQgNLkC8EbVOEPR6AKwCBLkCRAAPCHNYQiQVhNBjT5QukeKhERJYjBDA0o+VYj/GEVqiZgVGRIkr57SQJglIGwqsEfUpFvNaZ3oERElDDmzNCAI0V3X7fdhS21uZ2IiLICgxkaUJr9YViMCmRJQkQV0ISAgP7/EVVAliRYjAqa/eF0D5WIiBLEZSYaUAptJuSYFOSaDXD6wwhGVAhNn42xGBXkWY0QQqDQZkr3UImIKEEMZmhAmVTuwOjiXFTWuDFikBXBsEBE02CQZZiNEupcIVSU2TGp3JHuoRIRUYK4zEQDiixLuGbeaOSaFdS5QoAE5JgMgATUuULINSu4Zt5otjUgIsoiDGZowJkzpgj3Lp6MijI7fMEI6j1B+IIRVJTZce/iyWxnQESUZbjMRAPSnDFFmDVqEBtNEhH1AwxmaMCSZYlVfomI+oGMWmZauXIlJEnCDTfcEDsWCASwdOlSDBo0CLm5uViyZAnq6urSN0giIiLKKBkTzHz66ad49NFHMWXKlLjjP//5z/Hqq6/ihRdewPr163Ho0CGcd955aRolERERZZqMCGY8Hg8uvvhiPPbYYygoKIgddzqdeOKJJ/D73/8ep512GqZPn441a9Zgw4YN+Oijj9I4YiIiIsoUGRHMLF26FGeffTbmz58fd3zTpk0Ih8NxxydMmIDhw4dj48aNHd5XMBiEy+WK+yIiIqL+K+0JwM8++yw+//xzfPrpp+1uq62thclkQn5+ftzxkpIS1NbWdnh/K1aswPLly/tiqERERJSB0jozc+DAAfzsZz/DU089BYvFkpL7vOWWW+B0OmNfBw4cSMn9EhERUWZKazCzadMm1NfX44QTToDBYIDBYMD69evxhz/8AQaDASUlJQiFQmhubo77ubq6OpSWlnZ4n2azGQ6HI+6LiIiI+q+0LjOdfvrp2Lp1a9yxyy67DBMmTMAvf/lLDBs2DEajEWvXrsWSJUsAADt37sT+/fsxe/bsdAyZiIiIMkxagxm73Y5vfetbccdycnIwaNCg2PHLL78cy5YtQ2FhIRwOB6677jrMnj0bs2bNSseQiYiIKMOkPQG4Ow888ABkWcaSJUsQDAaxYMECPPLII+keFhEREWUISQgh0j2IvuRyuZCXlwen08n8GSIioizRk+t3RtSZISIiIkoWgxkiIiLKagxmiIiIKKsxmCEiIqKsxmCGiIiIshqDGSIiIspqDGaIiIgoqzGYISIioqzGYIaIiIiyGoMZIiIiymoMZoiIiCirMZghIiKirMZghoiIiLIagxkiIiLKagxmiIiIKKsxmCEiIqKsxmCGiIiIshqDGSIiIspqDGaIiIgoqzGYISIioqzGYIaIiIiymiHdAyBKF00T2HbIhSZfCIU2EyaVOyDLUrqHRUREPcRghgakDVUNWL2+GtX1HoRVAaMiYXRxLq6ZNxpzxhSle3hERNQDXGaiAWdDVQNufXErKmtcyDEbUGw3I8dsQGWNG7e+uBUbqhrSPUQiIuoBBjM0oGiawOr11fAEIyh1WGAxKpBlCRajglKHGZ6gitXrq6FpIt1DJSKiBDGYoQFl2yEXqus9KLCZIEnx+TGSJCHfZkR1vQfbDrnSNEIiIuopBjM0oDT5QgirAial47e+WZER1gSafKFjPDIiIkoWgxkaUAptJhgVCSFV6/D2oKrBKEsotJmO8ciIiChZDGZoQJlU7sDo4lwc8YUhRHxejBACzb4wRhfnYlK5I00jJCKinmIwQwOKLEu4Zt5o5JoV1LqC8IdVaJqAP6yi1hVErlnBNfNGs94MEVEWYTBDA86cMUW4d/FkVJTZ4QtGUO8JwheMoKLMjnsXT2adGSKiLMOieTQgzRlThFmjBrECMBFRP8BghgYsWZYweWheuodBRES9xGUmIiIiymoMZoiIiCirMZghIiKirMZghoiIiLIagxkiIiLKatzNRAOWpgluzSYi6gcYzNCAtKGqAavXV6O63oOwKmBUJIwuzsU180azaB4RUZbhMhMNOBuqGnDri1tRWeOCIkuwmmQosoTKGhdufXErNlQ1pHuIRETUA5yZoQFF0wRWr6/GEV8IEVXA6Q9DCECSAJMiI6xqWL2+GrNGDeKSExFRluDMDA0o2w65sP2QC96gimBEgyxJMCgSZElCMKLBG1Sx/ZAL2w650j1UIiJKEIMZGlAaPUG4AmEIIWJBjAQpFtQIIeAKhNHoCaZ7qERElCAGMzSgHPGFoWkCsqwHMa1JkCDLEjRN4IgvnKYREhFRTzGYoQElP8eoByxCQAgRd5sQAprQA538HGOaRkhERD3FYIYGlKIcMxwWA2RJQlgTsaBGEwJhTUCWJDgsBhTlmNM9VCIiShCDGRpQJpU7MLE8D1ajARaDDE0IRFqCGotBhtVowMTyPEwqd6R7qERElCAGMzSgyLKEa+aNRmGOERajghKHBUPyrShxWGAxKijMMeKaeaO5LZuIKIswmKEBZ86YIty7eDIqyhwIhjU0+8MIhjVUlDlw7+LJrABMRJRlGMzQACYgIKD/TwAQ3f4EERFlHgYzNOBE2xnsqHWjwGbC0AIrCmwm7Kj1sJ0BEVEWYjBDA0q0nYEnGEFpS56MLEuwGBWUOszwBFWsXl8NTeMsDRFRtmAwQwPKtkMuVNd7UGAzAQD8IRXuQBj+kAoAyLcZUV3vYTsDIqIswkaTNKA0+UIIqwKhiIYapx/BiBZrNGk2yBiUY0ZYE2jyhdI9VCIiShCDGRpQCm0maELDIWcImgAMsgRJ0lN//WENh5x+OCwGFLbM3BARUebjMhMNKBWldqgCiKgCBhl6o0mppdGkrB9XhX4eERFlBwYzNKBU1rqhSBIUWUJEFYho2tEvVUCRJSiShMpad7qHSkRECWIwQwNKky8EWZJQmKsvI4VVEfsCgMJcE2RZYs4MEVEWYTBDA0o0Z+aINwRIgFGRYJQlGBUJkIAj3hA0TWPODBFRFmECMA0orXNmTAYJsnQ0nteEhlCEOTNERNmGMzM0oLTOmVE1QBN6SwNNCKgamDNDRJSFGMzQgBLNmRlSYIXFKEPVBMIRAVUTsBhlDCmwMmeGiCjLMJihAaXQZoJR0XcyAVKbW/XjRllizgwRURZhMEMDyqRyBwblmlDj9MMfikCRJRgN+rKTPxRBjdOPQbkmTCp3pHuoRESUICYA08Al6Tkz0PT/hgS9FDAREWUVzszQgLLtkAuNnpC+jCRa6sxoLXVmhL4M1egJsdEkEVEW4cwMDShNvhC8IRW+YASAXmcmStMEnP4wbGYDE4CJiLIIZ2ZoQMm3GhEIq9CEgKElkBEtS0sGRYImBAJhFflWYxpHSUREPcGZGRpwhAA0AQQj7RNkJBwNboiIKDukdWZm9erVmDJlChwOBxwOB2bPno033ngjdnsgEMDSpUsxaNAg5ObmYsmSJairq0vjiCnbNfvDkKXO83wFAFnSzyMiouyQ1mBm6NChWLlyJTZt2oTPPvsMp512Gs4991xs27YNAPDzn/8cr776Kl544QWsX78ehw4dwnnnnZfOIVOWc1gMCEa0Ls8JRjQ4LJy0JCLKFmn9xD7nnHPivr/nnnuwevVqfPTRRxg6dCieeOIJPP300zjttNMAAGvWrEFFRQU++ugjzJo1Kx1DpixXXe/pdve1aDlv2vCCYzEkIiLqpYxJAFZVFc8++yy8Xi9mz56NTZs2IRwOY/78+bFzJkyYgOHDh2Pjxo1pHCllsy0HnSk9j4iI0i/tc+lbt27F7NmzEQgEkJubixdffBETJ07Eli1bYDKZkJ+fH3d+SUkJamtrO72/YDCIYDAY+97lYr0QOirHqKT0PCIiSr+0z8yMHz8eW7Zswccff4xrrrkGl1xyCbZv3570/a1YsQJ5eXmxr2HDhqVwtJTtFk4ujevIJLX6an1s4eTSYzouIiJKXtqDGZPJhDFjxmD69OlYsWIFpk6digcffBClpaUIhUJobm6OO7+urg6lpZ1faG655RY4nc7Y14EDB/r4GVA2mTo0H8cV2WLfi1ZfUccV2TB1aP4xHhkRESUr7cFMW5qmIRgMYvr06TAajVi7dm3stp07d2L//v2YPXt2pz9vNptjW72jX0RRsizhnkWTkWvueBkp16zgnkWTIcttO2oTEVGm6nHOTCQSwdNPP40FCxagpKSkVw9+yy234Mwzz8Tw4cPhdrvx9NNPY926dXjrrbeQl5eHyy+/HMuWLUNhYSEcDgeuu+46zJ49mzuZqNeMigxA7eQ4ERFlkx4HMwaDAT/5yU9QWVnZ6wevr6/Hj370I9TU1CAvLw9TpkzBW2+9he985zsAgAceeACyLGPJkiUIBoNYsGABHnnkkV4/Lg1cmiaw4o1KOP1hKDIAvb+knjMjAU5/GCveqMTLS0/m7AwRUZZIajfTiSeeiC1btmDEiBG9evAnnniiy9stFgsefvhhPPzww716HKKord84sbPWE2tpEEfoQc3OWg+2fuPE1GH5aRghERH1VFLBzE9/+lMsW7YMBw4cwPTp05GTkxN3+5QpU1IyOKJU23ygGWFV67KdQVjVsPlAM4MZIqIskVQwc8EFFwAArr/++tgxSZIghIAkSVDV9rkIRJlA0zoPZKJEy3lERJQdkgpm9u7dm+pxEB0TrkAkpecREVH6JRXM9DZXhihd5G7nZXp2HhERpV/S+1D/9re/4aSTTkJ5eTn27dsHAFi1ahVefvnllA2OKNU0JLZDKdHziIgo/ZIKZlavXo1ly5bhrLPOQnNzcyxHJj8/H6tWrUrl+IhSymFJbDIy0fOIiCj9kgpmHnroITz22GO47bbboChHK6nOmDEDW7duTdngiFJNluVu51yklvOIiCg7JPWJvXfvXkybNq3dcbPZDK/X2+tBEfWVacPyYeimGJ5BljCN27KJiLJGUsHMyJEjsWXLlnbH33zzTVRUVPR2TER9ZlKZA4rSdTCjKBImlbGnFxFRtkgqMWDZsmVYunQpAoEAhBD45JNP8Mwzz2DFihV4/PHHUz1GopTZVuNCWO26hkxY1bCtxsWieUREWSKpYOaKK66A1WrF7bffDp/Ph4suugjl5eV48MEHYwX1iDLR5/uOoJtYBqqmn8dghogoOyS9ZePiiy/GxRdfDJ/PB4/Hg+Li4lSOi6hPHHL6U3oeERGlX1I5M7/+9a9jVYBtNhsDGcoaibYpYDsDIqLskVQw88ILL2DMmDGYM2cOHnnkETQ0NKR6XER94rArkNLziIgo/ZIKZr744gt8+eWXOPXUU3H//fejvLwcZ599Np5++mn4fL5Uj5EoZbbXelJ6HhERpV/SlcEmTZqEe++9F3v27MF//vMfHHfccbjhhhtQWlqayvERpZaUYM+lRM8jIqK0S0mZ05ycHFitVphMJoTD4VTcJVGfGDkoJ6XnERFR+iUdzOzduxf33HMPJk2ahBkzZmDz5s1Yvnw5amtrUzk+opQaXmBL6XlERJR+SW3NnjVrFj799FNMmTIFl112GS688EIMGTIk1WMjSjl/RE3peURElH5JBTOnn346/vznP2PixImpHg9Rn9ISTIVJ9DwiIkq/pIKZe+65BwAQCoWwd+9ejB49GgZD0vX3iI4ZSequZ3bPziMiovRLKmfG7/fj8ssvh81mw6RJk7B//34AwHXXXYeVK1emdIBEqRQIJ1YML9HziIgo/ZIKZm6++WZ88cUXWLduHSwWS+z4/Pnz8dxzz6VscESpNqksN6XnERFR+iW1NvTSSy/hueeew6xZs+Km4ydNmoTq6uqUDY4o1TzBxBJ7Ez2PiIjSL6mZmcOHD3fYj8nr9TLXgDJavTuY0vOIiCj9kgpmZsyYgddeey32fTSAefzxxzF79uzUjIyoD/hCkZSeR0RE6ZfUMtO9996LM888E9u3b0ckEsGDDz6I7du3Y8OGDVi/fn2qx0iUMpqaYNfsBM8jIqL0S2pm5uSTT8aWLVsQiUQwefJkvP322yguLsbGjRsxffr0VI+RKGXqElw+SvQ8IiJKv4RnZpYtW4Zf/epXyMnJwXvvvYc5c+bgscce68uxEaWcL8Et14meR0RE6ZfwzMxDDz0Ej8cDAPj2t7+NpqamPhsUUV/JNScWvyd6HhERpV/Cn9jHHXcc/vCHP+CMM86AEAIbN25EQUFBh+fOnTs3ZQMkSqWKklxs3NN9IF5RwjozRETZIuFg5r777sNPfvITrFixApIkYfHixR2eJ0kSVJU1OigzeUOJvTcTPY+IiNIv4WBm0aJFWLRoETweDxwOB3bu3NlhrRmiTLar3pPS84iIKP16vJspNzcX//nPfzBy5Ejk5eV1+BW1cuVKNDc3p3K8RL3i9IdSeh4REaVfUluz582bl1CX7HvvvZeJwpRRQgnuUkr0PCIiSr+kgplECSH68u6JeiyYYDG8RM8jIqL069NghijThMKJJfYmeh4REaUfgxkaUCIJzhYmeh4REaUfgxkaUAwJdnVP9DwiIko/BjM0oAy2W1J6HhERpV+fBjOnnHIKrFZrXz4EUY8EEsyFSfQ8IiJKv6SCmc8//xxbt26Nff/yyy9j0aJFuPXWWxEKHa3P8frrr6OsrKz3oyRKEVcgnNLziIgo/ZIKZq6++mrs2rULALBnzx5ccMEFsNlseOGFF3DTTTeldIBEqWRM8B2f6HlERJR+SX1k79q1C8cffzwA4IUXXsDcuXPx9NNP48knn8Q//vGPVI6PKKXCWmK7lBI9j4iI0i+pYEYIAU3Ti4q9++67OOusswAAw4YNQ0NDQ+pGR5RirmBiQUqi5xERUfolFczMmDEDv/71r/G3v/0N69evx9lnnw0A2Lt3L0pKSlI6QCIiIqKuJBXMrFq1Cp9//jmuvfZa3HbbbRgzZgwA4O9//zvmzJmT0gESERERdaX7bpEdmDJlStxupqj77rsPiqL0elBEREREiUoqmOmMxcJCY0RERHRsJRzMFBQUQEqwxHtTU1PSAyIiIiLqiYSDmVWrVsX+u7GxEb/+9a+xYMECzJ49GwCwceNGvPXWW7jjjjtSPkiiVDEAiCR4HhERZQdJiJ63B16yZAm+/e1v49prr407/sc//hHvvvsuXnrppVSNr9dcLhfy8vLgdDrhcDjSPRxKs1E3vwYtgfNkAHtWnt3XwyEiok705Pqd1G6mt956CwsXLmx3fOHChXj33XeTuUuiYyKRQKYn5xERUfolFcwMGjQIL7/8crvjL7/8MgYNGtTrQRERERElKqnUgOXLl+OKK67AunXrMHPmTADAxx9/jDfffBOPPfZYSgdIRERE1JWkgplLL70UFRUV+MMf/oB//vOfAICKigp88MEHseCGiIiI6FhIetPGzJkz8dRTT6VyLEREREQ9lnQwo2kaqqqqUF9fH2s6GTV37txeD4yIiIgoEUkFMx999BEuuugi7Nu3D213dkuSBFVVUzI4IiIiou4kFcz85Cc/wYwZM/Daa6+hrKws4crARERERKmWVDCze/du/P3vf491yyYiIiJKl6TqzMycORNVVVWpHgsRERFRjyU1M3PdddfhxhtvRG1tLSZPngyj0Rh3+5QpU1IyOCIiIqLuJBXMLFmyBADw4x//OHZMkiQIIZgATERERMdUUsHM3r17Uz0OIiIioqQkFcyMGDEi1eMgIiIiSkpSCcAA8Le//Q0nnXQSysvLsW/fPgDAqlWrOmxASURERNRXkgpmVq9ejWXLluGss85Cc3NzLEcmPz8fq1atSuX4iIiIiLqUVDDz0EMP4bHHHsNtt90GRVFix2fMmIGtW7embHBERERE3UkqmNm7dy+mTZvW7rjZbIbX6+31oIgygaaJ7k8iIqK0SyqYGTlyJLZs2dLu+JtvvomKioqE72fFihX4r//6L9jtdhQXF2PRokXYuXNn3DmBQABLly7FoEGDkJubiyVLlqCuri6ZYRP1yLZDrnQPgYiIEpBUMLNs2TIsXboUzz33HIQQ+OSTT3DPPffglltuwU033ZTw/axfvx5Lly7FRx99hHfeeQfhcBhnnHFG3OzOz3/+c7z66qt44YUXsH79ehw6dAjnnXdeMsMm6pEmXyjdQyAiogRIom3b6wQ99dRTuPvuu1FdXQ0AKC8vx/Lly3H55ZcnPZjDhw+juLgY69evx9y5c+F0OjF48GA8/fTT+N73vgcA2LFjByoqKrBx40bMmjWr2/t0uVzIy8uD0+mEw+FIemzUPxx382sJn/vqtSdj8tC8PhwNERF1pifX7x7XmYlEInj66aexYMECXHzxxfD5fPB4PCguLk56wFFOpxMAUFhYCADYtGkTwuEw5s+fHztnwoQJGD58eMLBDFGyJpUz+CUiygY9DmYMBgN+8pOfoLKyEgBgs9lgs9l6PRBN03DDDTfgpJNOwre+9S0AQG1tLUwmE/Lz8+POLSkpQW1tbYf3EwwGEQwGY9+7XMx7oOTIspTuIRARUQKSypk58cQTsXnz5pQOZOnSpfjqq6/w7LPP9up+VqxYgby8vNjXsGHDUjRCIiIiykRJtTP46U9/ihtvvBEHDx7E9OnTkZOTE3d7T7tmX3vttfjXv/6F9957D0OHDo0dLy0tRSgUQnNzc9zsTF1dHUpLSzu8r1tuuQXLli2Lfe9yuRjQEBER9WNJBTMXXHABAOD666+PHUuma7YQAtdddx1efPFFrFu3DiNHjoy7ffr06TAajVi7dm2sU/fOnTuxf/9+zJ49u8P7NJvNMJvNyTwtojiaJrjURESUBdLaNXvp0qV4+umn8fLLL8Nut8fyYPLy8mC1WpGXl4fLL78cy5YtQ2FhIRwOB6677jrMnj2byb/U57YdcnE3ExFRFkgqmNm3bx/mzJkDgyH+xyORCDZs2JBwV+3Vq1cDAE499dS442vWrMGll14KAHjggQcgyzKWLFmCYDCIBQsW4JFHHklm2EQ9wjozRETZIak6M4qioKampt127MbGRhQXFye8zHQssM4MtcY6M0RE2aEn1++kdjNFc2PaamxsbJcMTJStKkrt6R4CEREloEfLTNE2ApIk4dJLL41LtFVVFV9++SXmzJmT2hESpUllrZszM0REWaBHwUxenv7BLoSA3W6H1WqN3WYymTBr1ixceeWVqR0hUZo0eIPdn0RERGnXo2BmzZo1AIDjjjsOv/jFL7pdUvrwww8xY8YMbpWmrNTkiU8A1jSBbYdcaPKFUGgzYVK5g1u3iYgyQFK7me66666EzjvzzDOxZcsWjBo1KpmHIUorp/9oMLOhqgGr11ejut6DsCpgVCSMLs7FNfNGY86YojSOkoiIkkoATlSSDbmJMkKtS19m2lDVgFtf3IrKGhdyzAYU283IMRtQWePGrS9uxYaqhjSPlIhoYOvTYIYom5XlWaBpAqvXV8MTjKDUYYHFqECWJViMCkodZniCKlavr4amMXAnIkoXBjNEnThheAG2HXKhut6DApupXTkCSZKQbzOiut6DbYfYnZ2IKF0YzBB1YvKQPDT5QgirAial418VsyIjrAlWCyYiSqM+DWY6KqxHlC1kWUKhzQSjIiGkah2eE1Q1GFvOIyKi9GACMFEXJpU7MLo4F0d84XbvZyEEmn1hjC7OxaRytsogIkqXPg1m3G43t2VT1tI0AVmWcM280cg1K6h1BeEPq9A0AX9YRa0riFyzgmvmjWa9GSKiNEoqmKmrq8MPf/hDlJeXw2AwQFGUuC+i/iCa1DtnTBHuXTwZFWV2+IIR1HuC8AUjqCiz497Fk1lnhogozZIqmnfppZdi//79uOOOO1BWVsbcGOqXWif1zhlThFmjBrECMBFRBkoqmPnggw/w/vvv4/jjj0/xcIgyR9ukXlmW2HiSiCgDJbXMNGzYMCb3Ur/HpF4iouyQVDCzatUq3Hzzzfj6669TPByizPHRnsZ0D4GIiBKQ8DJTQUFBXG6M1+vF6NGjYbPZYDQa485tampK3QiJ0mT1+mrMGjWIeTFERBku4WBm1apVfTgMoswTbVPAPBkiosyWcDBzySWX9OU4iDJOKKKxTQERURZIKmfm9ddfx1tvvdXu+Ntvv4033nij14MiygQRTWObAiKiLJBUMHPzzTdDVdV2xzVNw80339zrQRFlAotR4Y4mIqIskFQws3v3bkycOLHd8QkTJqCqqqrXgyLKBKMH5zL5l4goCyQVzOTl5WHPnj3tjldVVSEnJ6fXgyLKBONL+F4mIsoGSQUz5557Lm644QZUV1fHjlVVVeHGG2/Ed7/73ZQNjiidNu13pnsIRESUgKSCmd/+9rfIycnBhAkTMHLkSIwcORIVFRUYNGgQ7r///lSPkSgtvm7wQNNY6ZqIKNMl1ZspLy8PGzZswDvvvIMvvvgCVqsVU6ZMwdy5c1M9PqK08QZV1pkhIsoCPQ5mwuEwrFYrtmzZgjPOOANnnHFGX4yLKO2EAOvMEBFlgR4vMxmNRgwfPrzDrdlE/YmK9p2ziYgo8ySVM3Pbbbfh1ltvZQ8m6teMMjtnExFlg6RyZv74xz+iqqoK5eXlGDFiRLvt2J9//nlKBkeUTmMG57DODBFRFkgqmFm0aFGKh0GUeU6bWJLuIRARUQKSCmbuuuuuVI+DKOP4g5F0D4GIiBKQVM4M0UDw6b7mdA+BiIgSkNTMjKqqeOCBB/D8889j//79CIXit68yMZj6g7CqpXsIRESUgKRmZpYvX47f//73OP/88+F0OrFs2TKcd955kGUZd999d4qHSJQexbnmdA+BiIgSkFQw89RTT+Gxxx7DjTfeCIPBgAsvvBCPP/447rzzTnz00UepHiNRWowZzEaTRETZIKlgpra2FpMnTwYA5ObmwunUG/L9v//3//Daa6+lbnREaVTjCqR7CEREGS0U0RCKpH9JPqlgZujQoaipqQEAjB49Gm+//TYA4NNPP4XZzKl56h827jnCRpNERK2EVQ2uQBj17gD2N/pw8IgP3gzY+ZlUMLN48WKsXbsWAHDdddfhjjvuwNixY/GjH/0IP/7xj1M6QKJ0cQbC2PqNM93DICJKm4iqwR0I47A7iANNPhxo8qHBHYQnEEFES/+MTFRSu5lWrlwZ++/zzz8fw4cPx8aNGzF27Ficc845KRscUToJAXy+/wimDstP91CIiI4JVRPwh1X4QyoCYTVrdnUmFcy0NXv2bMyePTsVd0WUUQ41+9M9BCKiPqNqAoGwCn9YD14yIf8lGUkXzfvb3/6Gk046CeXl5di3bx8AYNWqVXj55ZdTNjiidGNnJiLqTzRNwBeKoNETxMEjPuxr9KLOFYDLH87aQAZIMphZvXo1li1bhrPOOgvNzc1QVRUAkJ+fj1WrVqVyfERpVZ5nTfcQiIiSJoSAP6SiyRvCN81+fN3oRa0zAGeWBy9tJRXMPPTQQ3jsscdw2223QVGU2PEZM2Zg69atKRscUbpNG1GQ7iEQESUsGrwc8YZwqNmPrxt9qHH60ewLIRhW0z28PpNUzszevXsxbdq0dsfNZjO8Xm+vB0WUKSpK7OkeAhFRp4QQCEa0VnkvGoQYeCUlkpqZGTlyJLZs2dLu+JtvvomKiorejokoY7y6tSbdQyAiihMIq2j2hVDrDGBfow+Hmv1o8obgD6kDMpABkpyZWbZsGZYuXYpAIAAhBD755BM888wzWLFiBR5//PFUj5EobT7f34Ql04emexhENIAFIyoCIS2240gboAFLV5IKZq644gpYrVbcfvvt8Pl8uOiiizBkyBA8+OCDuOCCC1I9RqK08Qb77xozEWWmUORo4BIIq1BZibxbSQUzfr8fixcvxsUXXwyfz4evvvoKH374IYYO5V+w1L8MyjGlewhE1M+F1ZbgJaTnvGRSZd1skVQwc+655+K8887DT37yE4RCIXz3u9+F0WhEQ0MDfv/73+Oaa65J9TiJ0uI/Ow/j9IoGzBlTlO6hEFE/EVZbJeyGsjN4UTWBrxu92FHjxt4GLyRJwu++PzVt40kqmPn888/xwAMPAAD+/ve/o6SkBJs3b8Y//vEP3HnnnQxmqN9o9AZx64tbce/iyQxoiCgpEVVDIKJlXYuAKCEE6t1B7Kh1Y0eNC5W1buyqcyMQPvo8TAYZK86bDJMh6Vq8vZJUMOPz+WC361tW3377bZx33nmQZRmzZs2KVQMm6g+G5FtQ7w5j9fpqzBo1CLLMmsBE1LVs7W8U5QlGsLPWjR21LlTWuLGj1o0mb6jLnwlFNOysdWPy0LxjNMp4SQUzY8aMwUsvvYTFixfjrbfews9//nMAQH19PRwOR0oHSJROwbBAvs2I6noPth1ype0XlYgylxYNXrKwv1FY1bC3wYvKGhd21LpRWePG/iZfQj8rS8CoolxMHZaHmSMHoTzf0sej7VxSwcydd96Jiy66CD//+c9x+umnx5pMvv322x0W0yPKVs3+EEodVjg1gSZf13+ZENHAoGkCgYg+8+LPouBFCIEaZ6BltkWfddld70ZYTWy3VKnDggmldkwos6Oi1IGxJbmwGBUU2EwoSPNmiaSCme9973s4+eSTUVNTg6lTjyb8nH766Vi8eHHKBkeUbiFVQ1DVYJQlFNq4s4loIBJCIBDWYrMvoUh2VNl1+sPYWeuOzbrsqHXD6Q8n9LO5ZkNc4DKhzI6CDP4MTCqYAYDS0lKUlpbGHTvxxBN7PSCiTCIJgWZfGBVldkwq5xIq0UAQbREQnXkJZkHwEopoqKr3oLLWhR0teS7fNPsT+lmjImH04NyW4MWBilI7hhZYIUnZkyOYdDBDNBC4ghpKzMA180Yz+Zeon8q2/kaaEDjY5I9L0K0+7EEkweJ6QwuseuBS6kBFmR2jB+embRdSqjCYISKiAScQVhEMZ0eLgCZv6OhSUY0LO+rcCVcnz7ca45aKxpfY4bAa+3jExx6DGaJu+IIqt2YTZblof6No4m6mBi/+sIrdde7YjEtljQv17mBCP2s2yBhXkosJpQ5MKLWjosyBEoc5q5aLksVghqgbEU1wazZRlon2Nwq2LB1lYn8jVRPY1+g9GrjUuvB1gxeJDFUCMGKQDRVlRwOX4wbZYFCye7koWQxmiLrhD6swhWRuzSbKYJne30gIgcPuICq7qKLblaJcUyzHZUKpHeNL7bCZeAmP4itBlABvMIz8frjOTJStIurRrdKZ2N/IE4xgV8tsy44aNyoTqKIbZTUqGF9qj824TCi1Y7Dd3McjTpwsSTAaZJgUGSaDDJtJSfeQGMwQJSKiAV8ebMbUYfnpHgrRgJTJLQIiqoY9Dd5YMbodLVV0E1nYilbRjc64TChzYHihDUqG5OcZWwIWkyLHBTCZhsEMUYL++O8qjCzKQZ7VhCZfCIU2EyaVO5gUTNQHVE3Etkr7Q5kTvAghcMgZaKnlom+NrjrsSbgKcLSKrh68HK2im26SJMWCFpNBhrnlv7Pl843BDFGCmv1hXPvMZphlwBsWAATK8624/ewKzB1XnO7hEWW1aH+jQDizWgQ4fWHsqDtaz2VHjQuuQCShn41W0Y0GLuNL7ShMc9l/ADDILbMtBjkugMlmDGaIEhSMaAhGNEhAbPp4V50Hl/75U3xnUgkumjmCszVECWrd3ygQ0RAMJ1Y3pS+FIhp217tjDRd31LpwqDmQ0M9Gq+ge3V1kx5D89FbR7Wi2xajIGbOElUoMZoh6qO06uAbgrW11+GBXA/JsRowuzsU180ZjzpiidAyPKCNlWn+jtlV0K2tdqD7sTXgLd7SKbjR4SXcV3f4429ITDGaIUsQfUVFqNKOyxo1bX9yKexdPZkBDA1am9TfqL1V0JUmCUdFnXMyKEgte+uNsS08wmCFKEU0AwYhAqcOMWleQVYNpwAm0ynlJZ38jf1jFrjp3y5ZofXdRT6roji3Wl4uiuS7pqqI70GdbeoLBDFEK+UIRGBQJFqOMqjo3qwZTvxZtEZDO/kaqJvB1o/do4FLrzroqupxt6b20BjPvvfce7rvvPmzatAk1NTV48cUXsWjRotjtQgjcddddeOyxx9Dc3IyTTjoJq1evxtixY9M3aKIuOP1huAIRSAAgAR9UNXQazGiawLZDLm7zpqwRjOgzLtEZmGPdIkAIgXp3MNazaEcPq+gOyjWhIs1VdA2yDKNBis2yRGdcBkL/pL6U1mDG6/Vi6tSp+PGPf4zzzjuv3e2//e1v8Yc//AF/+ctfMHLkSNxxxx1YsGABtm/fDovFkoYRE3VNlgFFkqAKAVUT+OvGrzF1aF673JkNVQ1Yvb4a1fUehFWh74Rg4jBlmFBEb8wYCKWnv1HrKrrRrdE9raIbXSo61lV0OdtybKU1mDnzzDNx5plndnibEAKrVq3C7bffjnPPPRcA8Ne//hUlJSV46aWXcMEFFxzLoRIlSIKqCahCwKTICKsaVq+vxozhBXjtq1p80+yDN6TitS++gS+socBmgkmREVI1Jg5T2sX6G6WhRUBY1bA3FVV0W5aMjmUVXUWO3wLN2ZZjL2NzZvbu3Yva2lrMnz8/diwvLw8zZ87Exo0bOw1mgsEggsGjiV4ul6vPx0oUFVaPfvQGIhpEIIxNXzdh+j3vwheKQAMghL5WX+wwxyp/WmQFpQ6ZicN0TLXubxQMa8esym7rKrrRBN3d9e6435+utK6iW1HmwJjiY1NFt6PZFqMiDdhO1ZkkY4OZ2tpaAEBJSUnc8ZKSkthtHVmxYgWWL1/ep2MjSlQwolcKBjQYZMAgSQirAgJAnSsICVJs6luSJOTbjKiu9zBxmPpEuvobOf3h2GxLZQ+r6NotBowvORq4jC+1o8DW91V0OduSXTI2mEnWLbfcgmXLlsW+d7lcGDZsWBpHRP2ZLKHdrolo8m/bjR2qBkCKP3jYHcCgHCNkWf/LzqzIcGoCTb7E8gKIupKO/kahiIaqek+rbtHJVdGNJun2dRVdSZJgkCW9F1GroIWzLdklY4OZ0tJSAEBdXR3Kyspix+vq6nD88cd3+nNmsxlmc+a0Sqf+rcN8yNb9DtqIBjjRU1QBOP0RFLT0awmqGoyyhHyrEVsPOrnTiXqkdYuAY9HfKFpFt3XgkslVdDnb0n9lbDAzcuRIlJaWYu3atbHgxeVy4eOPP8Y111yT3sERdaGrUhtSy4xN61Oify0LIdDsC6Msz4z73tqJPYe504m61rZFQF/3N2pdRbeyxoWdtW54Q5lZRdeoyJxtGUDSGsx4PB5UVVXFvt+7dy+2bNmCwsJCDB8+HDfccAN+/etfY+zYsbGt2eXl5XG1aIiySgeBjkGR4A+raPaFochAvTuIGmeAO52onWjwEl066ssWAdEquq13F/Wkiu64klx9a3SpAxVlfVdFV5ElGNvMtJgNnG0ZaNIazHz22Wf49re/Hfs+mutyySWX4Mknn8RNN90Er9eLq666Cs3NzTj55JPx5ptvssYMZbTOVpk6W5EKhFUIAUwozYXTH0aNM4BShyX2YcydTgNX6/5GgUjftQhQNYF9jd5YLZfKWldGVtHlbAt1RhLp7Px1DLhcLuTl5cHpdMLhcKR7OJQm0Wq75/zxg3QPJY7douCnp47FyWOKoAmBa/5vE3LMhg63mfrDKnzBCB794QzudOrHWvc3Coa1lLcIEELgcJsqujt7UEW3KNeECX1cRVeWpHY9iTjbMvD05PqdsTkzRKnSutpuOnS04wkAiu0maELChuoGXD13FN6vakBY1YvtdYQ7nfqnvu5v1LqKbnRrdE+r6LZO0k11FV3OtlAqMJihfm1DVQNufXErPMHIMalN0RFNADL0Vgeqpgc3QwuscFhN8IfVWF2ZfKsRAgJHfCHYTAZYjPoHeiCsV2KNaAIGCSjs4nmw31PmC0VaVdlNcYuAsKphz2GvnuNSq+e77G/yJfSzsgSMGpyLitK+qaLL2RbqSwxmqN/SNIHV66vhCUbiclDSMhbouQU5ZgWD7RbkmvVfvehsywdVDfiwqgHuQAQhVYNBlmIXEVUTEELfBuuwGuH0d/xXNfs9Zaa+6m/U2yq6ZXl6Fd3orEsqq+jGEnJbJeYaOdtCfYjBDPVb2w65UF3vQYHNlBF//UkCKMo1xwIZQK8ro2ka/rrxa4RVDUW5ZtS7AoioInZRUmQAQv/LVgiB21/6qt2uprYzUNwFlT5h9ehuo1T2N+pNFd1csyFW/n9Cy9boVMxUdjTbYlJkzgbSMcdghvqtJl+oyxyUY00D0OAJItdsgNQSmBzxhqAK/QIYnT0yGSTsb/LH/oLXNMBmUlDssMBmlPFNcwD3vl6JexZPxuQheiJwRzNQ2bIL6lgujfXFY0VizRm1lLUISFUVXX1rtB1DC3pfRZezLZTJGMxQv1VoM8GoSAipGixy3zeh65bQ8198IRWyLKHZF4bJICMU0eJmjxRJhiwBsiLFCvCV5lmgCWBfkw+BsIbtNS5c8ZfPMKHMjgWTSjudgcr0fk/HcmksVY+V6v5GqayiW1Fmx6ii3lXRlSUJxlZBi9nA2RbKfAxmqN+aVO7A6OJcVNa4UepI71+QiqT/ZRtSNTR6QjAZZJQ4zJgxogCvb62Nmz2KaBqE0IvpQQARTcATjOCINwxVCCgyIFT9L/DKGjd21bkRCGmdLht0tAsqExKFj+XSWG8eq3V/o0AKWgS0rqK7o8aFHXVueIPpqaLL2RbqLxjMUL8lyxKumTcat764Ffub/AirfVvqvSuKIqEsz4TDnghsJhn+sIraZj9ecwXgDkRgMsgobOnPZJDlWNsDnYDLH4EqBIyyBAF9Z5TNZEChUcbBZn9LNVgV1g7qfUT7PUV3QWVConBnydl9sTTW08dKVX8jTQh8ddCFyloXDruDaPDotV0SraJrMsgYV5wb2xI9ocyedCI7Z1uov2MwQ/3anDFFuHjmcPzunV0IJlgUrC9EVIG9jXrOgzcUgSLpXXrzrEZoQqDG6YdRkZBrMUBAQJElhCIaJAkwKTIimr7DCRKgqgIWowKLSYYECUW5JhwI+dHgCWFogRJ3sYv2e6oos2NSuSNjEoW7Ss5O9dJYd4+VZzVgd50bG6obMXJwDkJJtghQNYGvG73YUePGB1UN+PKgE/4EeyVFq+hGk3MrSu0YWZSTVL2V6GxL21kXIDNm5Ij6AoMZ6tc0TeC93Q3INRtQYJVQ40rsr+KUj6PVtdEg6bNGgYiGsCeEwhwTDruDOHDEB5OiIKyq0DQ9YRgCMBplhFUVAqKlTo2EwXYzJOgXIbOiwGJUYDboswz5NiPMioygqqHZF0auWcE180YDyJxE4e6Ss3taILCri3TbxxLi6FZ3TeiBRFDV8E2zD+X5ibVKEUKgvk0V3V3HuIquJMV3gO5utiUTZuSI+gqDGerXWv9Vrv+xnZ5gprWIBhglARl6fownEEa+1YAmXwQBoUKWJCgyYJQl/fZQJNbXyWI0YLC9/fbuHJOCn357DN7aVovqeg+cmr4kVVFmj12sth50orreg3yrMVaIzyDLsBjlY54o3F1ydtulsa50d5EusBphkPV2ECaDrFfYbRVchlQBoyQhz9L5Y3mCEeys1RsuRvsXJVpFV5IAi0GBxagne48anIsHzp8KuQfLRQZZbr8FugdJvpkyI0fUVxjMUL/W+q/yDCg1A0C/joZaFTbzhjQEWvIyinNNsJoMsSBDCIGDR/yIaAJGRcaQAgtk6ehFrPUy0kUnDsdFJw7vcobCG1Th9IcRUvUkY0nSOxwPtltgMyrHrF1C2+TsrpbGutLZRXr7IRd++Y8v8cuFEzCx3IHyAhv2HPagKNcUm9ECAAEBdyCMUYNzMaYkB4C+TX5vgxeVNUcDl55U0ZUlCRajghyTHsDo7z39MQMRDXVOP6rqvBhXmtvu5zuabTEqcq+q8HaUMxSdncoxKWj2hfHIuqqM3bpPlAgGM9SvtZ4ByOSWqtHdva6ACpvZCKtJn62QJAmDHRYc8QZhMiioc4U6XUaKXog6m1U50OSDJxQBhIChJbgTAPxhDd8c8aPIbkp4NqS3Widnd7U01tXFtfVFuthuBiBBFQKSBBTmGNHgCWHNhq/xmyWTcdGJw/D7d3ahwROC3WKESZEQUgVc/hDMBhkTSnOxel01KmvcqDrsSTjpt3UV3QmlDrgCYfz+nV0YlGPqcObFpEhwCwFnINTr2ZZEtc0Z8gQjOOwOIBg5+jvxyd4jePqT/fjBrBEpf3yiY4HBDPVrrWcAckyZv+U0GFHxzRE/hhRY41oeyLKMH84agbe312F/oxcaAKtBjltG6oqmCbz5VW2s6aUkAVLLHIVRBsKahsPuIGaOLOx2NiRV5owpwr2LJ8eWiDpaGutMKKJh074j2FXrRo7JgIgq0HrtSIIEu8WIA41eVNV5MW14AZZ9Zxz+unEfvm7wtlReFlAF0OyP4KmPD3Q7XrvFEFf+f3xp+yq6u2o9MMoSwqqA2SBFB6O/1hIQjgiYFRnjSxwYPsiW1OvWU61nJz3BCL454ocqBAyyPiYNAuGIhof+vRujinKyYrmJiczUFoMZ6tdazwA0+8PpHk63FBlQhcBhdwA5phxIkhRrefD29jrUuwIQABRJQkmeFVfPHZXQxWfbIRf2HPag2G7BYXcQEVWvVxOdnYHQE44XfqvsmF4U5owpwqxRg7q9MIWjVXZDaizfZ3+TFyFVg93S8ceYQQb8ERWvbT2EFzapPa6iO6Y4V99d1NIGYEh+91V0J5TZcdzgHFTVe2EzmSHLesAYXdppCERQUWbHlGNYvDA6OxmMqDjsDsS2+EefiyQARRYIRrSMrhQdxURm6giDGer3ojMAj6yrxgdVDekeTpdUTQ9oghENgbAGi1FGvSuIkKri4BEfCmwmFNhMCKkaDh7xd9inqSPRv86L7SaYDDIOu4MIRlQIrSVB1ajAoMgYVnhsZgtak2Wp3dJYtEVAV/2N8iym2CyISQHCql6ZNxDRA55gy1LRq1/WdDsGRQLybCbMG1eE+RUlGD246yq6kiTBqOj5LWZFiS0VKbKEG04fh1tf3IrDnqNLgoGImvDyWapFZye3HnQiGNFaZmT0x9d3yAlYjAYU5ZoytlJ0FBOZqTMMZmhAiM4AjLr19XQPpUuaADRVQALQ5AtCgoSQqsKsyMizGBFuyf2xmGSUOswJb6dunTuUazYgx6zEggSDLAOSgC+o9ipfpjdT/xFVT4JOtEVAkzeEek8Asizjm2Z/bJt1InLNir4sJQEOixE5JgWaAFyBMD7Z24STxxTFBTKtc1uiAUzrpN62erN81heis5M/f24LnAEBowyIlqKMqiZiW/3NigKnFulRAvix7quVKaUFKPMwmCHKQAJAsy+MUUU5CKsaQqrA/iO+VjuQFAy2mxPeTt3R7iE9yViBEAK1rmBCu4c688Huw7j/7V16Po8ArEYZY0o6v3hH+xsFWnocdRW8+MMqdtW5Y32LdtQkXkXXIEux6rn5VhMKc0x4ecs3qHcH2+1sKso1ocETxvOfHcRpE0pgMSqx2ZaeSnT57FiZM6YI150+Fr/613aomgZNPTojF93q7w+rPUoA7265J9WBzrEstEjZh8EMDRjbDrnSPYSEyC1Vf81GGWFV78skAfoOJFn/izoQ1hOFy/ItCCewnToVu4c689h71fjdO7taKhZLkAGEIjK+OOCMTf3PGjXo6LJRFy0CWlfRrazVi9F93eBNeNYl+toVOyz43vQhWDipFFu/ceLpTw7gQONh+MMavMEIjAYZgbAW62CuJ0QDg3IlHGjy4eARf68viB0tn6XTRScOx5tf1eKrQ07kWQwwKkerSPdkOzzQ/XLPxTOH473dDQnntSQS+KS60CL1LwxmaMDIlg85IfQu2YosYV+jDwJ608noVl9JAiRFb5FQ7wqiwGaM/TXd1UVhzpgi/HrRt47OoKBnO6I68sHuw7FWEUaDBBl676hARIWiqmgQAg+8uwsrzpvcbqtyKqvoji+1wyDJCKoq8iwmjCnJgSxJ2Ly/GQ+8swu+kIp8mwlmowZvKIKIqqHOFYShQI4rQNifL4iyLOGnp45uCUJU5NsUCA0IqD3L5+luuefAER9+984u5JgUFOaYu81rSTSht9BmgkHWlwMVWYor+Aj0rNAipUYm7SpjMEMDRjZ8yEnQm0gaZL1SrdpSLE/VBGRZHN2BAgmyJBCMqCh2HO271NVFYUNVAx59bw/qXAGEW6Y67FYj5leUIKwJbD3ojP1VnsgHlKYJ3P+2PiNjkAG51ZKNQZYQ0QTCqoavD3tQVedFeYEl6Sq6VqOC8aW5LcGLvsNosN0cd44ixxecM8gS7tx8EIGIhvKWnUj+kAoluiVZEzjsDiLHrMSWm/r7BTEV+TxdLfdAAkIRgVBEw5A8KyxGvV5SZ3ktPUnodfpD8Ec0uNxByC2zadGCj9Hif71ZKqWeybRdZQxmaMDIpA85k6LXQWlsczHXt0nrbQyCLY0mB+WY0OgNIawJGGQ94BHQt3ADwIJJpfhoT2O30/5PfbwfR3whhCIi1v9pV8CDu17ZBotBhs2koDDHBItRRpM3HPcBdfXcUcizmmIBzsQyOzYfaMa+Ri8gAEmR0HYlSJaAcETDEU3gjle+wuEE81wAxCrfakIg16zgfxaMx/QRhQD0/AiDrDfqbF1wrm1Txq0Hndhz2Bt30bUY9aq6/rAGRdLr+gRCGqwmpcdLLdmqt/k8XS33BEIawqoKSZJi78+otnktk8odCSf0frSnEbe/9BWE0BOWhdADe39Y3+VnNRpQYDNgwaRSvF/VkPZZgv4uE3eVMZihASE6HZopwqrAkVZLGdGP3GiQ4glEENE0GBUZORYFFpM1rmqr3k1bgc0kY87oQbjvrZ1o9oWRZzXot8tHLwqHmv148N9V0FRN7wgNfclBtAo/AhE9eGry6bV4inKMKHFYEVI1fHHAicv/8ilyTIZYIDGs0IZpw/OhtszwaC3/L1p2FbW+jEVnQDpT6jC3bEVXMSjHBItRiS1JCQANnhD+vukbnDGxFBaT0uVOotY6uuhKkoTBdktL4TgNEEBIVYEw0rZ1Oh16k8/TVV+tiKa/P2VJn11sq/UyXqIJvVu/ccaCnmEFNnhDaqy0AIT++6IJvd7QI/+pyohZgv4sU3eVMZihfq/1dGi6GVoq8GpAXHuFtrMaDd4QTAowpMCGZl8EpQ4zbIU2OP0RhFUNRkWCL6RiYnkevjjYjE+/boKqaXqycKvdTgDgDamxmiuAfqGJqKLdY7b+vtEbhs1sACDBHwpDT2NRMbTAimBYw45aF7YfciKkCmgANK3tPXTMbjFgfIm9pVu0AxPK7DjsCuHOl7ciz2GBxahAkvQlK0nSL2qDck040OTD/qaeJeV2dtHNNRswpMCKWmcAoYgKdyACq1Gkbet0tumqr5Yi6TN0RkXPZ2mr9TJeogm9W/Y3xwU9bUsLeEMRNHnDOHjEj8F2S0bMEvRnmbqrjMEM9Wttp0PTTWv56o4EwKgY4AupUGRgf5MfYVVDRNVaAiEBk0HG8AIrHv53FUItFwlZlmK7nQ60NEfU2kz3J7IzSACodQb0wEfTA6BQRMO+Rh8iiW4tankec8YMwrxxgzGhVK+ia1DiexE1eZugCSDHZOjwL7lkk3K7uujmmBTYTArGl9pxw+ljMSjXzGWJBHW1M84ZCOv5Sh0EKG2X8bYdcnXbOd0gS/jiQDOa/WF9NtIgQZb0HVhWkwIBvQCkJgTyrKZuc3S6k0kJrZkqU3eVMZihfquz6dC0jqmbOECR9ERWTei7dbyhCIyKjEA4grAqEP1cNSj6cs8/Nn8DRUKsqmu0BxBkgWBEfzCDfPRxJQkJN9xs3dk7+jNdBTKtX139r3PgqlNG4ZI5I7vMbSl1WGAyyF1e1JJJyu1uO7rdYsBNC8bH8keYa5G4zhOJHZg7tghPfby/2xIA3XVOr2n2IxjR8OKWbyAA+EIqap0BDLZbYrOOgZCGYESFIkkwtnlf9XSWINMSWjNVV8uMQPqS6BnMJIkRfOZrOx3afmEl8yiKPrMiAHhDEXgCYQQiekXgaOKvBH1Zx6+qUIVeV8WkSPCH9b9kgfhk3GgOgyYSD2S6HackwWzUE3S/Pb4YNc1+1DgDCERUyJKeU/M/Z4zDKeOKu72vilI7ih0W7D3sQVGuGdZWu4t6m5Tb3e4dALhkzSe8gCWhbSJxvtUIAGj2h3HFKaPw5le12HO48x1TXQWbNc1+eEMqAL1PVnRZVBVArUvvrzXYbkZYVaFqgM3U8bJWorMEmZjQmqm6C0LTlUTPYCYJjOCzQ9vp0EAosfol6RSKHI02GjxHP4BlSf8gj0YpBkmfZdETWDVYjAYIobXMprSPWApsRjR6e9doU5EAh9UIm1FBjllBWAP8oQiunjsaU4bmJRXcR3+XDjR54Q5G4A5GYDYoKHaYYVTklCTlRi+6W79xYsv+ZggJmDYsH+5AGLe/9BUvYL0QTSTeUNWA+9/eGfeZOGpwDn767TEYVmjr9D3RUbBpkKVYjpc5uqwk6Z29o+/sencAOWYZzkAEsqzPwHQ085rILEGmJrRmqr4swNkbDGZ6iBF89mg7HdpRs8JsobaJTyKaiFvWcQcinf6sAJIKZAwtXbXD6tH7cfnD8AQjMPllKLKMqcPyMGVoXlK7Y1r/LhXmmJFrNuKwO4hARMXBJh/ybSZMLHek5I+Ej/Y0xv0BonfU1iCEwLACGy9gvdDZZ+KOWg8OHtmDexdP7vK90XaG54v9zVj17i4YFT2QAfSZQBhkPWdM6LOMjZ4wvjUkD05/CDXOYGy7dlSiswSZmtCayTKt/xjAYKZHGMFnl7bToR1tFc1mopP/bqsH+brt7t8k6xcQQM/liS5X6YnJGuaOLUrqvd7R75LFqMBuMcAfUnHYE8KwQhvWXPJfMHTRvToRHV1sXYFwrPiaN6TGVQHmBSxxqfpMbB0Mbz2oz561PV2RJMgGvYBkRBX4f1PL8b/fnRSrsZTsLEGmJrRmukzrP9a/Pt37WE8ieEq/6HRorllBrSsISJmfM9OXpOjSFPS8G4dFQandBGObDx+bScHgXBNsRgMimv69zaQn+kX7QdpM+lLTe7sbYjVmeqKz3yVJkmAzG1DsMKPeFUBlrTup5xrV9mJrMSqQZaklMNOLrx12B9vlU5kVOaGeVwPdtkMuVNW5YTUq8AQj8IdUiJbErGQ/E4fk2yCj4yA8mkslS8D04QWQZSk2S1BRZocvGEG9JwhfMIKKMntCM+WtZ3A70t+rQvdGNAidN24wJrfM0KYLZ2Z6gBF89mk7HTrQRJOGFehVdX0tfY8kCfCHNaia3j5BFkf/Ei5zWGAzG+ALRrC30QtFVjB6cA4CYb2uh0GWYTHpjRqTnb04Vr9LnQVNBlmO1bFpXQU4ihewxHxQ1YAGbwhoSVpv3WIg12xI6t/xnCllWP6vbXD6wpAlLbbUBACa0BBRBfJsRpwzpSx2vDezBJma0Eo9w5mZHmAEn53mjCnCXy47Eat/MD3dQzmmZAkwGvQt3K0v1IqMlnwECYGwimBEQJGkli3TR8vQqy05CGFVRSCsX+ztFiOsJn23UW9mL47V71JnQVO0rYEmBDRNxOVTRS9go4tzeQHrwoaqBvx149d6FeiW8gCypO+q++aIH55gJKl/R4NBxtJTR0ORJYQi+r+NJvRAOhQRUGQJS08d3W75MdlZgrYzuP6wCk0T8IdV1LqCA6YqdLZjMNMD0Qj+iC8cm0qN4gdgZpNlqV3X5v5OE3qlX0WW4AmpsVkZVdPbKQgg9pqomn5Rl1qVoTfIMmTo27k7Sp7u6YVKa2lmuX7XYWhCYNTgnD7/XeosaIq2NZBbKtZGnz8vYImJLt+FVQ1Wo6Jv+W9ZxlQk/f1S6/TjiDeY1L/jlXNH45cLxyPPZoSmCYRV/d8nz2bELxeOx5VzR6f0+fR2qYrSj8tMPZCpW9IoMQNx+U8TiG1zjRbPi36FIxoMiv5e1aBf0K0mAywmPZixGPUCd9GiZK3pAUcIQwtsaPAGYx23O3vvd1TOYFCuCYqMPv1d6q4KsNVogCzrz73eE4ztyLh67ijYLUas33U47YmNmaj18p3dIvDNET/CES2uTYc/rEEVIukk8SvnjsZlc0bi1S9r8E2zD0PybThnSlmvE8I7k+xSFWuOZQYGMz2UiVvSKDEDfvlPAAZFjtXrENBnblobbDcDAvCHVYRVDbIEmAwynIEwJFmKBRzRppcHmry46YUvu6y11NnW3RpnEIoMlOWZ0egJ9cnvUnd/gBTmGPHrRd+K6wju9Ifw6Ht7elxHaiBd1Fov31mMEgpzTKhzB9oVZTRIMp76eD8mlecl9e9pMMhYfMKQFI26ez0tMcCaY5lDEm3nePsZl8uFvLw8OJ1OOBypW/4ZSB9c/YWmCYy69fV0DyOtjLK+zNRRpoqpZbbE5Y8gGNH/qpYlCcMLrcg1G9DoCSHcshzjDUVgUmSUOI429jvSMqPSelpe0wQuWfMJKmtc7VpKCCFQ6wpiQqkd/7NgPJr94T77XYq76LQETR1ddDoLvDp6bp3e/wC4qG096MTVf/sMOWYDzEYZXzf44A+rMMjRCtb6ZWV4gQ3OgL5c85fLTuxXn5HJvlcocT25fnNmJknJFAmj9OpPH6TJCndRNzCkCtQ4gwD0JSmbUUG+zQR3QE+I/Om3x2BovhWr1u7GgSYvyvKs3dYVSaScwZ7DHsiShHnjBvfZ805kCSHZmimJFNLMpHocqdB6+S7PYkAwosYSgAUEwhpgNcp6srgs9buaPaw5lnkYzNCAsaGqId1DyAoygDyrEfk2k74LSuh5LW9tq8UvzhiPelcAhTnmhKqlZlI5g+7+AEmmEmwiF7UVb1Qiz2rEnsPefjNr03r5rsEThKYJyEpL0rmm744bbNdfj/5YsoJVgzMPdzPRgKBpAiveqEz3MLKCBr39wdcNXlTXe3HYE4TFKKOqzo0t+5u7DU5ab9fOpnIGiQRebbeid3dRMxtkbK9x46tvnLCZ9ArHgL5Mc8s/v8zqADuaPzhycC4AIKIBmhCwGmUMKbDGqipn0r9xqiTzXqG+xZkZymptc5cqSu2orHW3m87f+o0Tu+oGXtG83lBbEoH9YTXWyqC6wQOjIsEVCENpqaQL6LuBIi35NBCIdVDOpoJkbXt5tdX2oqxpAp/vOwJvSIXFqEBAxCrUAnreSLMvBE0ImA0Kal160rQQ+hZmbyiCFW9U4uWlJ2ftUsScMUX4+3GF+N6jG7HnsBeDc/XZvOi/c6b9G6dKT98r1PcYzFDWapt0qQkNqmjp4SJJMCoSRg7OwZnfKsPG6sbYFmXqOVUTEBLwry9rEIyo8IfUWNXXtjsIjLKE+97agZ+eOgZzxhRlTTmDngRe0fdeZY0L7kAY3mAYFqMBg+3m2IxEIKQhGFEhA2j0hiCgF5aTpOhOMg3ba9x4+pP9+MGsEWl5zqlgMMi4acF43PriVjgDkbhdb5n2b5wq2RSkDxTczURZqW3SZSii4ZtmHyKaXvk2x2xAOKIhwACm16ItEUyKpHfrlvRaIp21ZJIlwG4xoMBmiu3o6Gg30ajBOVj4rTIMK7RlTFLs0feV2mHgde/iyQAQe+/lW42ocQYQaGktrshHl1hc/hD2N/lj/bBMSvxFTxN6RduJ5Q68em32zs5EJbpjrL9I5L3SH5/3sdST6zeDGco60e2+2w45YTcb4AupaPKGoPbrd3L6GWUJmhCQZb1BY0dxol7PTM8VsZoUVJQ5YltyWy8JHmjy4c2varHncOZtZe7qojxr1KB2W809wQi+OeKHKjRAABajgrJ8Cxo8Ibj8YQCAUZHbVaDWhICqaSiwmfHnS/+rXySKDrSSFQMtgDvWuDWb+p1AWMW2Qy5sOdCM9TsPY0N1I1RNoMkbTujnFelozyFKTrhlKkbTRLviaFERDTAqQEjVUGg0xe3oiO4m2lDVgMff39PlVuZ0Xgi62sa99aCzXcJvrtmAIQVWHHYHEAhr8IdVOH0RfKs8DweP+LCvyYeWNoyxxxAQUDU9lwboP9WpB1rJit40uKTUYjBDGUfTBPY0eLDlgBNbDhzBFwecqKxxIdLZukYb0Y+RApsRBTn6xXJ/kw/ekNp3gx5AuosJVU0vtidJaLejI1vqc3R2Ue5sF0uu2YAcUw58IRWN3hCWnjYGP5o1Ak9/sh93vbINEVXAoBxdoou+Rvk2E4QQTBTNYgMtgMtUDGYo7epdAWw50IwtB5rxxYFmfHHQCU8wktDPKrK+pBEI6/2DFFlvHKgJgcIcM6wmBf6wCqNBBhjMJE1pqRzcHQl6Lo3cctFuu6Mj2+tzdLWLRZIkyLKEHJOC6cMLIMsSLjpxOJ77dD921LqhahoAPcizGBUU5ZrgCapMFCVKAQYzdEz5QhFsPejElgPN2LxfD2BqXYGEftZilDF5SB6mDs3HB1UNqHMFUZ6nF2/7utELf1iDDAFV0y8WFpMc21kwqcyBD6sb+/jZ9V89bTiuyPq27ooyR9yFOpOK6CWjp7tYZFnCLWdW4JYXt8LpD8NmVGAxKpBloNkX6Zc7fYjSgcEM9ZmIqmF3vQeb9x/B5v3N+OJgM6rqPZ3ugmlNAjCmOBfHD8vHtOEFmDosD+NL7DC0XASjOwnq3CHk24wYlGPGIacfoYiAIus9hgLhVltDTx2NjdWNHfYkogQIPZgMR7QuE62jNxkVGblmQ7sLdbbX5+iucWVHwcmcMUVY0ao5rTsYYXNaohRjMEMpIYTAIWcAn+87gs37j2DLgWZsr3Eh0FUzoFaK7WZMGZqHacPzccLwQkwemher19GRtt3Lw5qAw2KI1ZnxhVQYZS12wbBbjJClzrcTk85mUpBnNeCwOwghAFmSUJhrgt1shMUowxOKYH+jL24pqV2dGUXC8cMK8NNT21+o+0N9jrbvvUS6fTNRlKhvMZihpDh9IXy+vxmf7z+CLw86sfUbJ5q8iS0N2EwKJpY7MHVoPmaMKMAJIwpQ4rD0eAwdXSA6qwC8ftdhWAwSPOGBG80osgQZgKJIMMoySvPMOG1CMewWI3xBFV8cbMbeBr1/kCLL0ITAkHwr7BZj7D5yTQbkmhUEIhrMioI8mwFGWc9ZcodU5BgVXHf6WFx04vAOL9TJzGxkomSCEyaKEvUdBjPULX9IxbZDTny+7wi2HGzGtm9cLdtNuydLwOjBuZg8JA/HD8/HjOMKML7EESuD31sdXSA6umAU2kwwmwzwhBPbyp1NZMRX4pWAWC0YTQAmg4Tzpg3BhSfqVWab/eFOu0a3rgPz2HvV8ARVGBQ5LuAozDHj4pnD8d7uBlTXe+CJqDDKEqYNy09o2SSZmY1MxOCEKHMwmKE4wbCK6gYvtuzXt0R/dciJXXVuhBOsSFfqsGBiuR1Thubr+S7DCpBnM3b/g31sUrkDZXlWNCZYl+ZYsxllFOSY4A5EYsXV8qxGlOdb8Pn+IwhF9Ne/7b+CLAFWo4J8mwmeYBjBiAabUYYGfRZm+KAc/OKMcTh57OBux9D24jyqKKfLgOPyk0clvWzCZRciSiVWAB6gNE0gpGqocwWweX8zvjzYjG2HXKisccEVSGxbtN1iwIRSOyaWOTBlWD5OGF6A8nxLrBBYpvm/j/bh9pe+Sstjy9C7USsAcswyIMmxmZNBOUasXDK104v7B7sP4/63d+FAkw+aELAYFdhMCiKagC+obzePVtC9eu4o5FlNKQsQBlpFVyLKHGxn0AqDGSAU0RBSNbgDYXx10IUvv9EDlx21LhxqTmxbtFGRMHpwLirKHJhY5sDUYfkYV5wLq1nJ2OClLU0TGHXr68fksSTou38UWUKO2YDrThuL4wbZ8Oh7e5Iqfd5RUAGAgQYR9VtsZzBARWdbghENgbCKqnoPth50YnuNHrhUH/ZCTXA7z9ACKyrKHLGZl4nlDuRZjbAYFZgNcruCZ9mgNxf64QUWRDSBBk8IkZYlN7MCFDvMkGQZDrMRQwosqDrs0zskCwGrUWkXrMwZXZRUANJZfgZzNoiIGMxkrVBEQ1jVYrMuh5r9+Oobvez/jlo3dta6Ey7fX2AzYnypHRVlDlSU2jGhzIGiXDOsRgVWU/YGL6nyv+dOwg9m6smz3QUi3S3LMGmUiCj1GMxkuNazLdHAxekLYWedG5U1buyodWFHjRv17mBC92c2yBhXkosJpQ5UlOmBS6nDArNR0YMXowKLcWAHL60V5ZowbVhBLCDpLhBhsEJEdOwxmMkgsZmWlqAl1LJc9HWjNy5w+brRm3AV3eOKclpmW+yYUOrAyKIcvZ+RUYHFIMNqUmAxKMy16IQEKWNL6xMRkY7BTBpEZ1tCqoZg+OhykappqHcHY4FLZY0bu+vcCEQSq6I7ONfcslykLxmNK8mFzaT/E5sMcsusiz77wuAlMUYlc0vrExGRjsFMH+totiXc0n7YE4i0LBfpeS6VNS4c8SVWB8VqVDC+1I4J0VyXMjuKcs2x241Ky6xLS/CSqiJ1A4kEYHRxbkaX1iciIgYzKdN6tiUWvEQ0aC0738Oqhj2HvbHAZUetG/t7UEV31ODcWHLuhFI7hhfa4gIUoyLrgYtJXz4ydNKVmBJnM8pZUVqfiGigYzDTC4GwCqc/HDfbAhxturijxoXKWjd21Liwu96TcBXdsjwLJrQELhWldowpzoXFGF/LxSDLsJjkWNIug5fUu6aDRolERJR5GMz0QjCiwRuMwOkLY0edqyXXRQ9eelpFN7pcNL7UjoIOcjQMsgyLUYbFpAcvRgYvfW7e+JJ0D4GIiBLAYCYJR7wh/HPzN/js6yZsOdCMGmfiVXTHFOdifMnRPJch+dYOt0ErsgRLq4Rdk4HBy7HGXBkiouzAYCYJwYiGX/1re7fnDSuwxnJcKsrsGFWU22lQIktSbJu0xSRnTYuA/oy5MkRE2YHBTBJK8ywodVhQ6zo6I1NgM2JCqQMTyuyoKLVjfKkddkvn3aJlKTrzIsdmYIiIiKjnGMwk6fszhqLZH8Zxg2yYUOZAid3cZdVcSZJgMR6t9TLQWwQQERGlCoOZJC07Yzyc/jAaPR23EZAkCeaWQnXsb0RERNR3GMykiCRJsSq77G9ERER07GTFFpmHH34Yxx13HCwWC2bOnIlPPvkk3UMCoO9OyrMaUZpnwYhCG4bkW1GYY4LVpDCQyVCvX39iSs8jIqL0y/hg5rnnnsOyZctw11134fPPP8fUqVOxYMEC1NfXp3tosJkMGJRrhs1k4M6XLDGxfHBKzyMiovTL+GDm97//Pa688kpcdtllmDhxIv70pz/BZrPhz3/+c7qHRlnq65Vn9+p2IiLKLBkdzIRCIWzatAnz58+PHZNlGfPnz8fGjRs7/JlgMAiXyxX3RdTW1yvPbreU9Pr1JzKQISLKQhmdANzQ0ABVVVFSEl9WvqSkBDt27OjwZ1asWIHly5cfi+FRlptYPpjBCxFRP5DRMzPJuOWWW+B0OmNfBw4cSPeQiIiIqA9l9MxMUVERFEVBXV1d3PG6ujqUlpZ2+DNmsxlms/lYDI+IiIgyQEbPzJhMJkyfPh1r166NHdM0DWvXrsXs2bPTODIiIiLKFBk9MwMAy5YtwyWXXIIZM2bgxBNPxKpVq+D1enHZZZele2hERESUATI+mDn//PNx+PBh3HnnnaitrcXxxx+PN998s11SMBEREQ1MkhBCpHsQfcnlciEvLw9OpxMOhyPdwyEiIqIE9OT6ndE5M0RERETdYTBDREREWY3BDBEREWU1BjNERESU1RjMEBERUVZjMENERERZLePrzPRWdOc5u2cTERFlj+h1O5EKMv0+mHG73QCAYcOGpXkkRERE1FNutxt5eXldntPvi+ZpmoZDhw7BbrdDkqR0DyftXC4Xhg0bhgMHDrCIIPh6tMXXoz2+JvH4esTj6xEvla+HEAJutxvl5eWQ5a6zYvr9zIwsyxg6dGi6h5FxHA4Hf/Fa4esRj69He3xN4vH1iMfXI16qXo/uZmSimABMREREWY3BDBEREWU1BjMDjNlsxl133QWz2ZzuoWQEvh7x+Hq0x9ckHl+PeHw94qXr9ej3CcBERETUv3FmhoiIiLIagxkiIiLKagxmiIiIKKsxmOlnmpqacPHFF8PhcCA/Px+XX345PB5Pl+dfd911GD9+PKxWK4YPH47rr78eTqcz7jxJktp9Pfvss339dJLy8MMP47jjjoPFYsHMmTPxySefdHn+Cy+8gAkTJsBisWDy5Ml4/fXX424XQuDOO+9EWVkZrFYr5s+fj927d/flU0ipnrwejz32GE455RQUFBSgoKAA8+fPb3f+pZde2u69sHDhwr5+GinTk9fjySefbPdcLRZL3DkD6f1x6qmndvhZcPbZZ8fOyeb3x3vvvYdzzjkH5eXlkCQJL730Urc/s27dOpxwwgkwm80YM2YMnnzyyXbn9PQzKZP09DX55z//ie985zsYPHgwHA4HZs+ejbfeeivunLvvvrvde2TChAm9G6igfmXhwoVi6tSp4qOPPhLvv/++GDNmjLjwwgs7PX/r1q3ivPPOE6+88oqoqqoSa9euFWPHjhVLliyJOw+AWLNmjaipqYl9+f3+vn46Pfbss88Kk8kk/vznP4tt27aJK6+8UuTn54u6uroOz//www+Foijit7/9rdi+fbu4/fbbhdFoFFu3bo2ds3LlSpGXlydeeukl8cUXX4jvfve7YuTIkRn5/Nvq6etx0UUXiYcfflhs3rxZVFZWiksvvVTk5eWJgwcPxs655JJLxMKFC+PeC01NTcfqKfVKT1+PNWvWCIfDEfdca2tr484ZSO+PxsbGuNfiq6++EoqiiDVr1sTOyeb3x+uvvy5uu+028c9//lMAEC+++GKX5+/Zs0fYbDaxbNkysX37dvHQQw8JRVHEm2++GTunp69xpunpa/Kzn/1M/OY3vxGffPKJ2LVrl7jllluE0WgUn3/+eeycu+66S0yaNCnuPXL48OFejZPBTD+yfft2AUB8+umnsWNvvPGGkCRJfPPNNwnfz/PPPy9MJpMIh8OxY4m8iTPBiSeeKJYuXRr7XlVVUV5eLlasWNHh+d///vfF2WefHXds5syZ4uqrrxZCCKFpmigtLRX33Xdf7Pbm5mZhNpvFM8880wfPILV6+nq0FYlEhN1uF3/5y19ixy655BJx7rnnpnqox0RPX481a9aIvLy8Tu9voL8/HnjgAWG324XH44kdy+b3R2uJfObddNNNYtKkSXHHzj//fLFgwYLY9719jTNJsteBiRMniuXLl8e+v+uuu8TUqVNTNzAhBJeZ+pGNGzciPz8fM2bMiB2bP38+ZFnGxx9/nPD9OJ1OOBwOGAzx3S6WLl2KoqIinHjiifjzn/+cUCfTYykUCmHTpk2YP39+7Jgsy5g/fz42btzY4c9s3Lgx7nwAWLBgQez8vXv3ora2Nu6cvLw8zJw5s9P7zBTJvB5t+Xw+hMNhFBYWxh1ft24diouLMX78eFxzzTVobGxM6dj7QrKvh8fjwYgRIzBs2DCce+652LZtW+y2gf7+eOKJJ3DBBRcgJycn7ng2vj+S0d3nRype42ynaRrcbne7z5Ddu3ejvLwco0aNwsUXX4z9+/f36nEYzPQjtbW1KC4ujjtmMBhQWFiI2trahO6joaEBv/rVr3DVVVfFHf/f//1fPP/883jnnXewZMkS/PSnP8VDDz2UsrGnQkNDA1RVRUlJSdzxkpKSTp9/bW1tl+dH/78n95kpknk92vrlL3+J8vLyuA/jhQsX4q9//SvWrl2L3/zmN1i/fj3OPPNMqKqa0vGnWjKvx/jx4/HnP/8ZL7/8Mv7v//4PmqZhzpw5OHjwIICB/f745JNP8NVXX+GKK66IO56t749kdPb54XK54Pf7U/I7mO3uv/9+eDwefP/7348dmzlzJp588km8+eabWL16Nfbu3YtTTjkFbrc76cfp940m+4Obb74Zv/nNb7o8p7KysteP43K5cPbZZ2PixIm4++6742674447Yv89bdo0eL1e3Hfffbj++ut7/biUmVauXIlnn30W69ati0t6veCCC2L/PXnyZEyZMgWjR4/GunXrcPrpp6djqH1m9uzZmD17duz7OXPmoKKiAo8++ih+9atfpXFk6ffEE09g8uTJOPHEE+OOD6T3B3Xt6aefxvLly/Hyyy/H/aF95plnxv57ypQpmDlzJkaMGIHnn38el19+eVKPxZmZLHDjjTeisrKyy69Ro0ahtLQU9fX1cT8biUTQ1NSE0tLSLh/D7XZj4cKFsNvtePHFF2E0Grs8f+bMmTh48CCCwWCvn1+qFBUVQVEU1NXVxR2vq6vr9PmXlpZ2eX70/3tyn5kimdcj6v7778fKlSvx9ttvY8qUKV2eO2rUKBQVFaGqqqrXY+5LvXk9ooxGI6ZNmxZ7rgP1/eH1evHss88mdOHJlvdHMjr7/HA4HLBarSl5z2WrZ599FldccQWef/75dktxbeXn52PcuHG9eo8wmMkCgwcPxoQJE7r8MplMmD17Npqbm7Fp06bYz/773/+GpmmYOXNmp/fvcrlwxhlnwGQy4ZVXXmm39bQjW7ZsQUFBQUb1IzGZTJg+fTrWrl0bO6ZpGtauXRv313Vrs2fPjjsfAN55553Y+SNHjkRpaWncOS6XCx9//HGn95kpknk9AOC3v/0tfvWrX+HNN9+My7/qzMGDB9HY2IiysrKUjLuvJPt6tKaqKrZu3Rp7rgPx/QHo5QyCwSB+8IMfdPs42fL+SEZ3nx+peM9lo2eeeQaXXXYZnnnmmbht+53xeDyorq7u3XskpenElHYLFy4U06ZNEx9//LH44IMPxNixY+O2Zh88eFCMHz9efPzxx0IIIZxOp5g5c6aYPHmyqKqqitsqF4lEhBBCvPLKK+Kxxx4TW7duFbt37xaPPPKIsNls4s4770zLc+zKs88+K8xms3jyySfF9u3bxVVXXSXy8/Nj22l/+MMfiptvvjl2/ocffigMBoO4//77RWVlpbjrrrs63Jqdn58vXn75ZfHll1+Kc889N6u23vbk9Vi5cqUwmUzi73//e9x7we12CyGEcLvd4he/+IXYuHGj2Lt3r3j33XfFCSecIMaOHSsCgUBanmNP9PT1WL58uXjrrbdEdXW12LRpk7jggguExWIR27Zti50zkN4fUSeffLI4//zz2x3P9veH2+0WmzdvFps3bxYAxO9//3uxefNmsW/fPiGEEDfffLP44Q9/GDs/ujX7f/7nf0RlZaV4+OGHO9ya3dVrnOl6+po89dRTwmAwiIcffjjuM6S5uTl2zo033ijWrVsn9u7dKz788EMxf/58UVRUJOrr65MeJ4OZfqaxsVFceOGFIjc3VzgcDnHZZZfFLkRCCLF3714BQPznP/8RQgjxn//8RwDo8Gvv3r1CCH179/HHHy9yc3NFTk6OmDp1qvjTn/4kVFVNwzPs3kMPPSSGDx8uTCaTOPHEE8VHH30Uu23evHnikksuiTv/+eefF+PGjRMmk0lMmjRJvPbaa3G3a5om7rjjDlFSUiLMZrM4/fTTxc6dO4/FU0mJnrweI0aM6PC9cNdddwkhhPD5fOKMM84QgwcPFkajUYwYMUJceeWVWfPBLETPXo8bbrghdm5JSYk466yz4uplCDGw3h9CCLFjxw4BQLz99tvt7ivb3x+dfR5GX4NLLrlEzJs3r93PHH/88cJkMolRo0bF1dyJ6uo1znQ9fU3mzZvX5flC6NvXy8rKhMlkEkOGDBHnn3++qKqq6tU42TWbiIiIshpzZoiIiCirMZghIiKirMZghoiIiLIagxkiIiLKagxmiIiIKKsxmCEiIqKsxmCGiIiIshqDGSIiIuqx9957D+eccw7Ky8shSRJeeumlHt+HEAL3338/xo0bB7PZjCFDhuCee+7p8f0wmCGifu3DDz/E5MmTYTQasWjRIqxbtw6SJKG5uTndQ4s57rjjsGrVqnQPg6hHvF4vpk6diocffjjp+/jZz36Gxx9/HPfffz927NiBV155pV0n9kQYkh4BEVEWWLZsGY4//ni88cYbyM3Nhc1mQ01NDfLy8tI9NKKsduaZZ+LMM8/s9PZgMIjbbrsNzzzzDJqbm/Gtb30Lv/nNb3DqqacCACorK7F69Wp89dVXGD9+PAC9eWsyODNDRP1adXU1TjvtNAwdOhT5+fkwmUwoLS2FJEkdnq+qKjRNO8ajJOp/rr32WmzcuBHPPvssvvzyS/z3f/83Fi5ciN27dwMAXn31VYwaNQr/+te/MHLkSBx33HG44oor0NTU1OPHYjBDNMCceuqpuP7663HTTTehsLAQpaWluPvuu2O3Nzc344orrsDgwYPhcDhw2mmn4YsvvgAAOJ1OKIqCzz77DACgaRoKCwsxa9as2M//3//9H4YNG5bQWA4ePIgLL7wQhYWFyMnJwYwZM/Dxxx/Hbl+9ejVGjx4Nk8mE8ePH429/+1vcz0uShMcffxyLFy+GzWbD2LFj8corrwAAvv76a0iShMbGRvz4xz+GJEl48skn2y0zPfnkk8jPz8crr7yCiRMnwmw2Y//+/TjuuOPw61//Gj/60Y+Qm5uLESNG4JVXXsHhw4dx7rnnIjc3F1OmTIm9FlEffPABTjnlFFitVgwbNgzXX389vF5v7Pb6+nqcc845sFqtGDlyJJ566qmEXiuibLJ//36sWbMGL7zwAk455RSMHj0av/jFL3DyySdjzZo1AIA9e/Zg3759eOGFF/DXv/4VTz75JDZt2oTvfe97PX/AXrWpJKKsM2/ePOFwOMTdd98tdu3aJf7yl78ISZJiXZDnz58vzjnnHPHpp5+KXbt2iRtvvFEMGjRINDY2CiGEOOGEE8R9990nhBBiy5YtorCwUJhMplh39iuuuEJcfPHF3Y7D7XaLUaNGiVNOOUW8//77Yvfu3eK5554TGzZsEEII8c9//lMYjUbx8MMPi507d4rf/e53QlEU8e9//zt2HwDE0KFDxdNPPy12794trr/+epGbmysaGxtFJBIRNTU1wuFwiFWrVomamhrh8/liXYCPHDkihBBizZo1wmg0ijlz5ogPP/xQ7NixQ3i9XjFixAhRWFgo/vSnP4ldu3aJa665RjgcDrFw4ULx/PPPi507d4pFixaJiooKoWmaEEKIqqoqkZOTIx544AGxa9cu8eGHH4pp06aJSy+9NDbmM888U0ydOlVs3LhRfPbZZ2LOnDnCarWKBx54oHf/sERpBEC8+OKLse//9a9/CQAiJycn7stgMIjvf//7QgghrrzySgEgrsv8pk2bBACxY8eOnj1+Sp4FEWWNefPmiZNPPjnu2H/913+JX/7yl+L9998XDodDBAKBuNtHjx4tHn30USGEEMuWLRNnn322EEKIVatWifPPP19MnTpVvPHGG0IIIcaMGSP+v//v/+t2HI8++qiw2+2xIKmtOXPmiCuvvDLu2H//93+Ls846K/Y9AHH77bfHvvd4PAJAbCxCCJGXlyfWrFkT+76jYAaA2LJlS9xjjRgxQvzgBz+IfV9TUyMAiDvuuCN2bOPGjQKAqKmpEUIIcfnll4urrroq7n7ef/99Icuy8Pv9YufOnQKA+OSTT2K3V1ZWCgAMZiirtQ1mnn32WaEoitixY4fYvXt33Ff09+XOO+8UBoMh7n58Pp8AEPvjKlFMACYagKZMmRL3fVlZGerr6/HFF1/A4/Fg0KBBcbf7/X5UV1cDAObNm4cnnngCqqpi/fr1OOOMM1BaWop169ZhypQpqKqqiiX4dWXLli2YNm0aCgsLO7y9srISV111Vdyxk046CQ8++GCnzyUnJwcOhwP19fXdPn5rJpOp3WvS9r5LSkoAAJMnT253rL6+HqWlpfjiiy/w5Zdfxi0dCSGgaRr27t2LXbt2wWAwYPr06bHbJ0yYgPz8/B6NlyjTTZs2Daqqor6+HqecckqH55x00kmIRCKorq7G6NGjAQC7du0CAIwYMaJHj8dghmgAMhqNcd9LkgRN0+DxeFBWVoZ169a1+5noBXfu3Llwu934/PPP8d577+Hee+9FaWkpVq5cialTp6K8vBxjx47tdgxWqzUVT6XT59ITVqu1w4Tg1vcdvb2jY9HH83g8uPrqq3H99de3u6/hw4fHPqiJ+gOPx4OqqqrY93v37sWWLVtQWFiIcePG4eKLL8aPfvQj/O53v8O0adNw+PBhrF27FlOmTMHZZ5+N+fPn44QTTsCPf/xjrFq1CpqmYenSpfjOd76DcePG9WgsTAAmopgTTjgBtbW1MBgMGDNmTNxXUVERAD2omTJlCv74xz/CaDRiwoQJmDt3LjZv3ox//etfmDdvXkKPNWXKFGzZsqXTnQsVFRX48MMP4459+OGHmDhxYu+eZB864YQTsH379nav3ZgxY2AymTBhwgREIhFs2rQp9jM7d+7MqJo3RIn67LPPMG3aNEybNg2AXgZh2rRpuPPOOwEAa9aswY9+9CPceOONGD9+PBYtWoRPP/0Uw4cPBwDIsoxXX30VRUVFmDt3Ls4++2xUVFTg2Wef7fFYODNDRDHz58/H7NmzsWjRIvz2t7/FuHHjcOjQIbz22mtYvHgxZsyYAUDfEfXQQw/Fdh0UFhaioqICzz33XMIFtC688ELce++9WLRoEVasWIGysjJs3rwZ5eXlmD17Nv7nf/4H3//+9zFt2jTMnz8fr776Kv75z3/i3Xff7bPn31u//OUvMWvWLFx77bW44oorkJOTg+3bt+Odd97BH//4R4wfPx4LFy7E1VdfjdWrV8NgMOCGG25I2SwV0bF06qmnQk+X6ZjRaMTy5cuxfPnyTs8pLy/HP/7xj16PhTMzRBQjSRJef/11zJ07F5dddhnGjRuHCy64APv27YvlhwB63oyqqnG5Maeeemq7Y10xmUx4++23UVxcjLPOOguTJ0/GypUroSgKAGDRokV48MEHcf/992PSpEl49NFHsWbNmoTvPx2mTJmC9evXY9euXTjllFNif6WWl5fHzlmzZg3Ky8sxb948nHfeebjqqqtQXFycxlETZT9JdBVWEREREWU4zswQERFRVmMwQ0R94t5770Vubm6HX131cyEi6ikuMxFRn2hqaup0p5LVasWQIUOO8YiIqL9iMENERERZjctMRERElNUYzBAREVFWYzBDREREWY3BDBEREWU1BjNERESU1RjMEBERUVZjMENERERZjcEMERERZbX/H7fM0QLZs4TYAAAAAElFTkSuQmCC", + "image/png": "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99J6otlscvjwYb74xS/2eymb4qmnnuLIkSM8++yzPPnkkwRBwIc//GGazWa/l9YTY2NjfPazn+X555/n+9//Pr/4i7/IL//yL/PjH/+430vrueeee44vfelL3HTTTf1eSk/dcMMNnDp1qvvxzDPP9HtJPbO4uMidd96JZVl861vf4ic/+Ql//ud/zsDAQL+X1jPPPffcmr+/J598EoCPfexjfV7Zpfvc5z7Ho48+yhe+8AVeeeUVPve5z/Fnf/ZnfP7zn+/vwtQOcNttt6kjR450fx1FkRodHVWPPPJIH1e1OQD1xBNP9HsZm2pmZkYB6qmnnur3UjbNwMCA+tu//dt+L6On6vW6uvrqq9WTTz6pfv7nf1498MAD/V5STzz88MPq8OHD/V7Gpvn93/999b73va/fy9hSDzzwgDpw4ICK47jfS7lkd999t7rvvvvWfO5XfuVX1D333NOnFSW2febD932ef/55PvShD3U/p+s6H/rQh/if//mfPq5MXKylpSUASqVSn1fSe1EU8dWvfpVms8kdd9zR7+X01JEjR7j77rvX/L+4Xbz++uuMjo6yf/9+7rnnHo4fP97vJfXMv/7rv3LLLbfwsY99jKGhId797nfzN3/zN/1e1qbxfZ+///u/57777uv5cNN+eO9738t3vvMdXnvtNQB++MMf8swzz3DXXXf1dV2X3WC5XpubmyOKIoaHh9d8fnh4mJ/+9Kd9WpW4WHEc8+CDD3LnnXdy6NChfi+nZ1566SXuuOMOOp0O2WyWJ554guuvv77fy+qZr371q7zwwgs899xz/V5Kz91+++185Stf4dprr+XUqVP8yZ/8Ce9///t5+eWXyeVy/V7eJTt69CiPPvooDz30EH/wB3/Ac889x2//9m9j2zb33ntvv5fXc1//+tepVqt84hOf6PdSeuKTn/wktVqN6667DsMwiKKIT3/609xzzz19Xde2Dz7E9nLkyBFefvnlbXWmDnDttdfy4osvsrS0xD/90z9x77338tRTT22LAOTEiRM88MADPPnkk7iu2+/l9NzqnyBvuukmbr/9diYmJvja177Gb/3Wb/VxZb0RxzG33HILn/nMZwB497vfzcsvv8xf//Vfb8vg4+/+7u+46667GB0d7fdSeuJrX/sa//AP/8Djjz/ODTfcwIsvvsiDDz7I6OhoX//+tn3wUalUMAyD6enpNZ+fnp5m165dfVqVuBj3338/3/zmN3n66acZGxvr93J6yrZtrrrqKgBuvvlmnnvuOf7qr/6KL33pS31e2aV7/vnnmZmZ4T3veU/3c1EU8fTTT/OFL3wBz/MwDKOPK+ytYrHINddcwxtvvNHvpfTEyMjIWUHwwYMH+ed//uc+rWjzHDt2jP/8z//kX/7lX/q9lJ75vd/7PT75yU/ya7/2awDceOONHDt2jEceeaSvwce2r/mwbZubb76Z73znO93PxXHMd77znW13pr5dKaW4//77eeKJJ/jud7/Lvn37+r2kTRfHMZ7n9XsZPfHBD36Ql156iRdffLH7ccstt3DPPffw4osvbqvAA6DRaPDmm28yMjLS76X0xJ133nnW1fbXXnuNiYmJPq1o8zz22GMMDQ1x991393spPdNqtdD1tVu9YRjEcdynFSW2feYD4KGHHuLee+/llltu4bbbbuMv//IvaTab/OZv/ma/l9YTjUZjzU9Zb731Fi+++CKlUonx8fE+rqw3jhw5wuOPP843vvENcrkcp0+fBqBQKJBKpfq8ukv3qU99irvuuovx8XHq9TqPP/44//3f/823v/3tfi+tJ3K53Fn1OZlMhnK5vC3qdn73d3+Xj3zkI0xMTDA1NcXDDz+MYRj8+q//er+X1hO/8zu/w3vf+14+85nP8Ku/+qt873vf48tf/jJf/vKX+720norjmMcee4x7770X09w+W+NHPvIRPv3pTzM+Ps4NN9zAD37wA/7iL/6C++67r78L6+tdmy30+c9/Xo2PjyvbttVtt92mnn322X4vqWf+67/+SwFnfdx77739XlpPrPdsgHrsscf6vbSeuO+++9TExISybVsNDg6qD37wg+o//uM/+r2sTbWdrtp+/OMfVyMjI8q2bbV792718Y9/XL3xxhv9XlZP/du//Zs6dOiQchxHXXfdderLX/5yv5fUc9/+9rcVoF599dV+L6WnarWaeuCBB9T4+LhyXVft379f/eEf/qHyPK+v69KU6nObMyGEEELsKNu+5kMIIYQQlxcJPoQQQgixpST4EEIIIcSWkuBDCCGEEFtKgg8hhBBCbCkJPoQQQgixpST4EEIIIcSWkuBDCCGEEFtKgg8hhBBCbCkJPoQQQgixpST4EEIIIcSWkuBDCCGEEFvq/wfPqAyP2kEskwAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -614,15 +552,12 @@ ], "source": [ "# similarly, for fever\n", - "\n", - "local_symptom_data[\"search_trends_fever\"] = \\\n", - " local_symptom_data[\"search_trends_fever\"].astype(float)\n", - "sns.regplot(x=\"new_confirmed\", y=\"search_trends_fever\", data=local_symptom_data)" + "sns.regplot(x=\"new_cases_percent_of_pop\", y=\"search_trends_fever\", data=weekly_data, scatter_kws={'alpha': 0.2, \"s\" :5})" ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 63, "metadata": { "id": "-S1A9E3WGaYH" }, @@ -630,16 +565,16 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 16, + "execution_count": 63, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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QsRsERpJavzGsWrVKsyZMweAf9K8RYsW4c0334TL5cK0adPwhz/8oclmprPZ7XZYrVbYbDY2OREREWlEa+7fMU1mOgKTGSIiIu1pzf27U3QAJiIiIooUkxkiIiLSNCYzREREpGlMZoiIiEjTmMwQERGRpjGZISIiIk1jMkNERESaxmSGiIiINI3JDBEREWkakxkiIiLSNCYzREREpGkRJzOVlZV49dVXsXjxYlRUVAAAduzYgePHj0ctOCIiIqKW6CJ50bfffospU6bAarXihx9+wLx585CcnIx33nkHR44cwWuvvRbtOImIiIgaFVHNzMKFCzFnzhwcOnQIJpMpuP2yyy7DZ599FrXgiIiIiFoSUTLzzTff4I477miwvXfv3igtLW1zUEREREThiiiZMRqNsNvtDbYfPHgQPXv2bHNQREREROGKKJm58sor8eijj8Lj8QAAJEnCkSNHcN999+Gaa66JaoBEREREzYkomfntb38Lh8OBtLQ01NTUYNKkSRg0aBASExPxv//7v9GOkYiIiKhJEY1mslqt+OSTT7B582bs3r0bDocD5513HqZMmRLt+IiIiIiaFVEyEzB+/HiMHz8egH/eGSIiIqKOFlEz01NPPYW///3vwefXXnstUlJS0Lt3b+zevTtqwRERERG1JKJk5o9//COysrIAAJ988gk++eQTfPjhh5gxYwZ+/etfRzVAIiIiouZE1MxUWloaTGbef/99XHvttZg6dSr69++PMWPGRDVAIiIiouZEVDPTo0cPHD16FACwfv36YMdfIQR8Pl/0oiMiIiJqQUQ1M1dffTVuuOEGDB48GOXl5ZgxYwYAYOfOnRg0aFBUAyQiIiJqTkTJzPPPP4/+/fvj6NGjePrpp5GQkAAAKCkpwS9/+cuoBkhERETUHEkIIWIdRHuy2+2wWq2w2WywWCyxDoeIiIjC0Jr7d9g1M+vWrcOMGTOg1+uxbt26ZsteeeWV4R6WiIiIqE3CrpmRZRmlpaVIS0uDLDfdb1iSpE7VCZg1M0RERNrTLjUzqqo2+nciIiKiWIpoaDYRERFRZxHRaKZHH3202f0PP/xwRMEQERERtVZEyczatWtDnns8HhQVFUGn0yE7O5vJDBEREXWYiJKZnTt3Nthmt9sxZ84czJo1q81BEREREYUran1mLBYLli1bhoceeihahyQiIiJqUVQ7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EWlH4L4dzgwyXT6ZqaqqAgAuOElERKRBVVVVsFqtzZbp8pPmqaqK4uJiJCYmQpKkWIcTc3a7HVlZWTh69CgnEQTPx9l4PhriOQnF8xGK5yNUNM+HEAJVVVXIzMyELDffK6bL18zIsow+ffrEOoxOx2Kx8H+8eng+QvF8NMRzEornIxTPR6honY+WamQC2AGYiIiINI3JDBEREWkak5luxmg0YunSpTAajbEOpVPg+QjF89EQz0kono9QPB+hYnU+unwHYCIiIuraWDNDREREmsZkhoiIiDSNyQwRERFpGpOZLqaiogI33ngjLBYLkpKScNttt8HhcDRb/q677sLQoUMRFxeHvn374u6774bNZgspJ0lSg8eaNWva++NE5KWXXkL//v1hMpkwZswYfP31182Wf/vttzFs2DCYTCbk5ubigw8+CNkvhMDDDz+MjIwMxMXFYcqUKTh06FB7foSoas35eOWVVzBhwgT06NEDPXr0wJQpUxqUnzNnToNrYfr06e39MaKmNedj9erVDT6ryWQKKdOdro/Jkyc3+l1w+eWXB8to+fr47LPPcMUVVyAzMxOSJOHdd99t8TUbN27EeeedB6PRiEGDBmH16tUNyrT2O6kzae05eeedd/DjH/8YPXv2hMViwdixY/HRRx+FlHnkkUcaXCPDhg1rW6CCupTp06eLkSNHii+//FJ8/vnnYtCgQeL6669vsvyePXvE1VdfLdatWycKCgrEhg0bxODBg8U111wTUg6AWLVqlSgpKQk+ampq2vvjtNqaNWuEwWAQf/7zn8W+ffvEvHnzRFJSkjhx4kSj5Tdv3iwURRFPP/202L9/v3jwwQeFXq8Xe/bsCZZ58sknhdVqFe+++67YvXu3uPLKK8WAAQM65ec/W2vPxw033CBeeuklsXPnTpGfny/mzJkjrFarOHbsWLDMzTffLKZPnx5yLVRUVHTUR2qT1p6PVatWCYvFEvJZS0tLQ8p0p+ujvLw85Fzs3btXKIoiVq1aFSyj5evjgw8+EA888IB45513BACxdu3aZst///33wmw2i4ULF4r9+/eLF154QSiKItavXx8s09pz3Nm09pz86le/Ek899ZT4+uuvxcGDB8XixYuFXq8XO3bsCJZZunSpGD58eMg1cvLkyTbFyWSmC9m/f78AIL755pvgtg8//FBIkiSOHz8e9nHeeust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hRdE3759hcFgEBdeeKH48ssvg/smTZokbr755pDyb731lhgyZIgwGAxi+PDh4t///nfIflVVxUMPPSR69eoljEajuPTSS8WBAwc64qNERWvOR79+/Rq9FpYuXSqEEMLpdIqpU6eKnj17Cr1eL/r16yfmzZunmS9mIVp3Pu65555g2V69eonLLrssZL4MIbrX9SGEEN99950AID7++OMGx9L69dHU92HgHNx8881i0qRJDV4zatQoYTAYxMCBA0Pm3Alo7hx3dq09J5MmTWq2vBD+4esZGRnCYDCI3r17i+uuu04UFBS0KU6umk1ERESaxj4zREREpGlMZoiIiEjTmMwQERGRpjGZISIiIk1jMkNERESaxmSGiIiINI3JDBEREWkakxkiIiJqtc8++wxXXHEFMjMzIUkS3n333VYfQwiBZ599FkOGDIHRaETv3r3xv//7v60+DpMZIurSNm/ejNzcXOj1esycORMbN26EJEmorKyMdWhB/fv3x4oVK2IdBlGrVFdXY+TIkXjppZciPsavfvUrvPrqq3j22Wfx3XffYd26dQ1WYg+HLuIIiIg0YOHChRg1ahQ+/PBDJCQkwGw2o6SkBFarNdahEWnajBkzMGPGjCb3u1wuPPDAA3jzzTdRWVmJc889F0899RQmT54MAMjPz8fKlSuxd+9eDB06FIB/8dZIsGaGiLq0wsJC/OhHP0KfPn2QlJQEg8GA9PR0SJLUaHmfzwdVVTs4SqKu584778TWrVuxZs0afPvtt/jpT3+K6dOn49ChQwCAf/3rXxg4cCDef/99DBgwAP3798fcuXNRUVHR6vdiMkPUzUyePBl33303fvOb3yA5ORnp6el45JFHgvsrKysxd+5c9OzZExaLBT/60Y+we/duAIDNZoOiKNi2bRsAQFVVJCcn46KLLgq+/q9//SuysrLCiuXYsWO4/vrrkZycjPj4eIwePRpfffVVcP/KlSuRnZ0Ng8GAoUOH4vXXXw95vSRJePXVVzFr1iyYzWYMHjwY69atAwD88MMPkCQJ5eXluPXWWyFJElavXt2gmWn16tVISkrCunXrcM4558BoNOLIkSPo378/Hn/8cdx0001ISEhAv379sG7dOpw8eRJXXXUVEhISMGLEiOC5CPjiiy8wYcIExMXFISsrC3fffTeqq6uD+8vKynDFFVcgLi4OAwYMwN/+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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGdCAYAAADAAnMpAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAqohJREFUeJzs/XlspPl534t+3v2tvbh3k71M9+yjmdZmWRrZkpVcL3F8z7VwLozA/8hGHAM5UIIYOUgA5Qa4cYJkAjiBE+AAsgMjUXIAHd2bc46cC8NLFOfItqDN2mdGM6OZ6Z07WXvVu7+/+8fLqi6yi+xik2ySzecDcIaset+3flVsvs/396yaUkohCIIgCIJwTOjHvQBBEARBEM42IkYEQRAEQThWRIwIgiAIgnCsiBgRBEEQBOFYETEiCIIgCMKxImJEEARBEIRjRcSIIAiCIAjHiogRQRAEQRCOFfO4FzAOaZqytLREqVRC07TjXo4gCIIgCGOglKLdbjM/P4+u7+7/OBViZGlpiYsXLx73MgRBEARBeAju3LnDhQsXdn3+VIiRUqkEZG+mXC4f82oEQRAEQRiHVqvFxYsXB3Z8N06FGOmHZsrlsogRQRAEQThlPCjFQhJYBUEQBEE4VkSMCIIgCIJwrIgYEQRBEAThWBExIgiCIAjCsSJiRBAEQRCEY0XEiCAIgiAIx4qIEUEQBEEQjhURI4IgCIIgHCsiRgRBEARBOFZEjAiCIAiCcKyIGBEEQRAE4Vg5FbNpjgKlFOvtgE4QU3RMZkrOA3vnC4IgCIJw+JxZMbLeDvjB3SZJqjB0jWsXKsyW3eNeliAIgiCcOc5smKYTxCSpYr6aI0kVnSA+7iUJgiAIwpnkzIqRomNi6BpLDQ9D1yg6Z9ZJJAiCIAjHyr7EyGc/+1muXbtGuVymXC7z8ssv80d/9Ee7Hv+5z30OTdO2fbnuyQiFzJQcrl2o8PRckWsXKsyUnONekiAIgiCcSfblDrhw4QL/8l/+S55++mmUUvzH//gf+cVf/EW++93v8p73vGfkOeVymbfeemvw80lJEtU0jdmyy+xxL0QQBEEQzjj7EiP/w//wP2z7+Z//83/OZz/7Wb7+9a/vKkY0TePcuXMPv0JBEARBEB5rHjpnJEkSvvCFL9Dtdnn55Zd3Pa7T6XD58mUuXrzIL/7iL/L6668/7EsKgiAIgvAYsu+szVdffZWXX34Z3/cpFot88Ytf5IUXXhh57LPPPsu///f/nmvXrtFsNvlX/+pf8dGPfpTXX3+dCxcu7PoaQRAQBMHg51artd9lCoIgCIJwStCUUmo/J4RhyO3bt2k2m/zv//v/zu/93u/xZ3/2Z7sKkmGiKOL555/nl3/5l/ln/+yf7XrcP/kn/4Tf/M3fvO/xZrNJuVzez3IFQRAEQTgmWq0WlUrlgfZ732JkJz/90z/Nk08+ye/+7u+Odfwv/dIvYZom/9v/9r/teswoz8jFixdFjAiCIAjCKWJcMXLgPiNpmm4TDnuRJAmvvvoq58+f3/M4x3EG5cP9L0EQBEEQHk/2lTPymc98hp//+Z/n0qVLtNttPv/5z/PlL3+ZP/mTPwHgU5/6FAsLC7zyyisA/NN/+k/5yEc+wlNPPUWj0eC3fuu3uHXrFn/rb/2tw38ngiAIgiCcSvYlRtbW1vjUpz7F8vIylUqFa9eu8Sd/8if8zM/8DAC3b99G1+85W+r1Or/+67/OysoKExMTfPCDH+SrX/3qWPklgiAIgiCcDQ6cM/IoGDfmtB9kaq8gCIIgHC3j2u8zO5BFpvYKgiAIwsngzA7Kk6m9giAIgnAyOLNiRKb2CoIgCMLJ4Mxa4P7U3uGcEUEQBEEQHj1nVozI1F5BEARBOBmc2TCNIAiCIAgnAxEjgiAIgiAcKyJGBEEQBEE4VkSMCIIgCIJwrIgYEQRBEAThWBExIgiCIAjCsSJiRBAEQRCEY0XEiCAIgiAIx4qIEUEQBEEQjhURI4IgCIIgHCsiRgRBEARBOFZEjAiCIAiCcKyIGBEEQRAE4VgRMSIIgiAIwrEiYkQQBEEQhGNFxIggCIIgCMeKiBFBEARBEI4VESOCIAiCIBwrIkYEQRAEQThWRIwIgiAIgnCsiBgRBEEQBOFYETEiCIIgCMKxImJEEARBEIRjRcSIIAiCIAjHiogRQRAEQRCOFREjgiAIgiAcKyJGBEEQBEE4VszjXsBxoZRivR3QCWKKjslMyUHTtONeliAIgiCcOc6sGFlr+fzF2xt0g5iCY/Kxp6eZq+SOe1mCIAiCcOY4s2Ga27Ue1ze6BLHi+kaX27XecS9JEARBEM4kZ1aM3EMd9wIEQRAE4UxzZsXIpck8T84UcCydJ2cKXJrMH/eSBEEQBOFMcmZzRmbLLh97emZbAqsgCIIgCI+eMytGNE1jtuwye9wLEQRBEIQzzpkN0wiCIAiCcDIQMSIIgiAIwrEiYkQQBEEQhGPlzOaMPE5IN1lBEAThNHNmxUiapry50ma9HTBTcnjuXAldP52OovV2wA/uNklShaFrXLtQYbbsHveyBEEQBGEszqwYeXOlzR+9ukKUpFhGJkJemK8c86oejk4Qk6SK+WqOpYZHJ4ilSkgQBEE4NZxOV8AhsNbyafZCJvI2zV7IWss/7iU9NEXHxNA1lhoehq5RdM6sxhQEQRBOIWfWapmGTq0XstIKsE0N0zi9umym5HDtQkUauAmCIAinkjMrRs6VHd57oYptaoSx4lz59BpwaeAmCIIgnGbOrBgp52yuzBQHSZ/lnH3cSxIEQRCEM8mZFSMS2hAEQRCEk8GZFSMS2hAEQRCEk8G+sjY/+9nPcu3aNcrlMuVymZdffpk/+qM/2vOc//yf/zPPPfccruvy0ksv8Yd/+IcHWrAgCIIgCI8X+xIjFy5c4F/+y3/Jt7/9bb71rW/xV//qX+UXf/EXef3110ce/9WvfpVf/uVf5td+7df47ne/yyc/+Uk++clP8tprrx3K4gVBEARBOP1oSil1kAtMTk7yW7/1W/zar/3afc/9jb/xN+h2u/zBH/zB4LGPfOQjvO997+N3fud3xn6NVqtFpVKh2WxSLpcPstwB0kJdEARBEI6Wce33QzfXSJKEL3zhC3S7XV5++eWRx3zta1/jp3/6p7c99nM/93N87Wtf2/PaQRDQarW2fR02/Rbqb692+MHdJuvt4NBfQxAEQRCEB7NvMfLqq69SLBZxHIe//bf/Nl/84hd54YUXRh67srLC3Nzctsfm5uZYWVnZ8zVeeeUVKpXK4OvixYv7XeYDGW6hnqSKThAf+msIgiAIgvBg9i1Gnn32Wb73ve/xjW98g//pf/qf+JVf+RV++MMfHuqiPvOZz9BsNgdfd+7cOdTrg7RQFwRBEISTwr4tsG3bPPXUUwB88IMf5C//8i/5t//23/K7v/u79x177tw5VldXtz22urrKuXPn9nwNx3FwnKPt+3GS+4xIPosgCIJwljjwQJY0TQmC0fkWL7/8Mn/6p3+67bEvfelLu+aYPEr6fUauzhSZLbsnythLPosgCIJwltiXZ+Qzn/kMP//zP8+lS5dot9t8/vOf58tf/jJ/8id/AsCnPvUpFhYWeOWVVwD4e3/v7/FTP/VT/Ot//a/5hV/4Bb7whS/wrW99i3/37/7d4b+Tx4jhfJalhkcniM90czbxFAmCIDze7EuMrK2t8alPfYrl5WUqlQrXrl3jT/7kT/iZn/kZAG7fvo2u33O2fPSjH+Xzn/88//gf/2P+0T/6Rzz99NP8/u//Pi+++OLhvovHDMln2U7fU9SfI3TtQoXZsnvcyxIEQRAOiQP3GXkUHEWfkZOMeAK2c329w9urnYGn6Om5Ildnise9LEEQBOEBjGu/z/aW+4Qic3O2I54iQRCExxu5qwsnnpNc+SQIgiAcHBEjwoE56rCSeIoEQRAeb0SM7MFpzd141OuWBFNBEAThIJxZMTKOwT6tRvZRr1tKkQVBEISDcOCmZ6eVtZbPX7y9Pvhaa/n3HXNa59c86nVLgqkgCIJwEM6s1bhd6/HuepdqzmK11eXSZJ65Sm7bMafVyB71und6laaLtiSYCoIgCA/N6bCuR8ruuRT9Ko62HxHEKW0/Gjx+knNHjrr6ZLcwkIRmBEEQhIfhzIqRixM5pgs29W7IdMHm4kTuvmP6VRwAN05R7shRV59IjoggCIJwmJzZnBFN06jkLabLDpW8taen47TmjhwVpzV8JQiCIJxMzqwV6QQxcaKYK7s0exGdIGZul2PF+G7PEynYBi8tlOmGieSICIIgCAfm7FnVLfwo4a3VNr0wJm+bvLiwe8/8nTkY00WbtZZ/6vqPHIRReSIyH0YQBEE4DM6sGOluhV4qroUfp3T3CL3szMFYa/n3GeaZknMqG6SNi+SJCIIgCEfFmRUjmqZRcEyqOZuGF+5LOIwyzMCpbJAG4zWAO8pQ1WntdHsWkd+VIAhHwZkVI5cm81ydLtANYq5OF7g0mR/73FGG+TR7Dsbp2HqU5cKntdPtWUR+V4IgHAVnVozMll0+/szMQxnXUf1HgjhF1ziVSa7jCKkHlQsfZMd8moXcWUN+V4IgHAWnx2KeIEb3H4GFiRyuZZy6CpPDCMEcZMcs1UqnB/ldCYJwFJzZO8lhuJt37hJdyziVFSaHEYI5yI75qDvGCoeH/K4EQTgKzqwYOQx38+OySzyMjq0H+SyOumOscHjI70oQhKPgdFrPQ+AwhMRh7hJPe5WC7JgFQRCEh+XMipHpos181WW9HTBTcpgu2vu+xmHuEk97lYLsmAVBEISH5czOptnohCw1fPwoZanhs9EJRx6nlGKt5XN9vcNay0cpte/XGucap3n+zWF8RoIgCMLZ5cx6Rtp+RK0TUs6Z1DoRbT8a6Yk4DI/FONc4zfknp92rI5wuTntIUxCE+zk9Fu+QCeKUO/Ue0UaKZei8eGH0bJrDSHQd5xqnOf+k//7OV1zeXG7zxnILQIyEcCSI+BWEx48zK0YcU+fCRI5K3qLZi3DM0RGrw/BYjHON05x/0n9/by63uVPvoVBEiRIjIRwJ0nhNEB4/zqwYKbkWU0WHJFVMFR1KrjXyuMPwWDzqSpNHfbOeKTm8tFDm69c3cSyNubKDH6diJIQj4TSHNAVBGM2Z/SvuG9DbtR6QhTaUUveFFQ7DY3FYXo9xwy/j3qwfJpyz2zmaphElil6Y8s2bdZ6cKYiREI4EKSMXhMePM2st+ga06cXEScqtzR6Xp/Jcniqc2FyHccMv496sx73esADxo4SlhkeSsu2c/mt9+MoUNzc6XJrMH9hInMZExdO45tOGlJELwuPHmRUjcC+ckbNNfrDYpBvGNL34xOY6HHb4ZdzrDYuW9baPZei8MF/Zdk7RMTENHT9KWJjIRN1BjfBpTFQ8jWsWBEE4bs60GOmHM25udAB4YqqAFyXc2uyeyJ3tuOGXcQ3iuNcbFi3NXkSUpvedcxSu84OKr+PwUkhypSAIwv45s2KknyNSyZmkqUvBMfCihG4Q0/Fjat3oWHe2owzpuAZ/XIM47vWGRctEwRo5nfgoXOcHTVQ8Di+FJFcKgiDsnzN7p1xvB7y62BoYqhfmK7iWwWYnYLMTPpKd7V47990M6YMMvlIKP0pYb/s0exETBWtXgziugBglWsbxMBzUM3FQb8txeCkkuVIQBGH/nFkx0vJCbmx0CKKEbpBQftbg+fPTFB2Tphc/kp3tXjv3hzWk6+2ApYaHZehEacrCRO7ABvFhvR4H9Uwc1NtyHF4KSa4UBEHYP2dWjKy0Ar5xY5P1ToihabiWzhMzpZE726PKPdirc+nDGtLsmgwSTF3LOLacl+POnxAvhSAIwungzIqROEkp2CblKQsvTon6TbpGhELWWv6R5B7s1bn0YQ3pScpZOO61iJdCEAThdHBmxchs2WWq6LDY8NA1jcmiM1Y1ycPu8PdKSH1juYVC8fx8meWGv6soGoeT5A04SWsRBEEQTi5nVow8M1vg/Rcr2DpMF13+2ntmx6omedgd/qj8if7r5W0D08iub+r6gTwIj7rb66NYiyAIgvB4c2bFyI/WuvxotQuaTtOPafgJ87sY28PY4Y/yrgD84G6TOElRCqYK9qAD7HEjzbsEQRCER8XoUbVngLWWT6MXMpG3afRC1lr+rsf2d/hXZ4rMlt2HSggd5V3pC5SFifxgcN/DXv+wGRZPSaoG4kkpxVrL5/p6h7WWj1LqmFcqCIIgnHbOrGfENHTqvZDbtR66Bu0gHjko77DYzbtyFAmehxFi2S00JR4TQRAE4bA5s2LkXNnhyZkCtzZ69KKE2xvZTn+ukjuS1xuVP3FUCZ6HIRh2W9txl+uOiwysEwRBOD2cWTFSci3iVLHZC6nkLNY6EbdrvYcSIw9r+I4qwfMwBMNuaxsnmfcgQuCwRIR4cARBEE4PZ1aMKKXoBCHNXkicpLim8dD5DyfN8B1lf49xvDkH+TwO67M8LR4cQRAE4QwnsN6u9djsxqQK1jsh3Sii8JBGe7dkz+OiLxienituKyE+KON6LQ7yeRzWZzksyHQN/CiRpFtBEIQTypn1jDR6EY1uiGUamIbOTMHFtYyHutZxdBrdSxgcRvinXzVzu9YD4NJkHmDbcMHdvBYH+TwO67Mc9uD4UcJSwyNJeaSeK8lbEQRBGI8zK0aqeYvz1Rzr7YBemFDMGQc2fG0/IohT2n40ePyojM9Rh4bW2wF/8fYG1ze6ADw5U+DSZH6s0MdeoZwHGejDSuodFmTX1zskKY88ZHPSwneCIAgnlTMrRi5N5rkwkaPeCZjM2UzmHj6U0Td8ADcekfE56pyIThDTDWKqORu4Fy4Zx2uxl2fmQQb6KJJ6j2tGjuStCIIgjMeZFSOapmGbBtMll3MVl5Jr0Q2TA13zURqfozawRcek4Jistrc8I8XMM6Jp2qF3oj1qA31cM3KOe1CgIAjCaeHM3h07QYyhgW1p3NjoYhsaBXv/OSPDYQc/StA1Rhqfw84fOGoDO1Ny+NjT01yeynJFLk3mB91hDyIejsNAH9eMHBkUKAiCMB5nVoz0qyuur3fRNbgyVXio62wPO8DCRA7XMu4zPoedP3DUBlbTNOYquYduAreb+DpLBloGBQqCIIzHvkp7X3nlFT70oQ9RKpWYnZ3lk5/8JG+99dae53zuc59D07RtX657/El83SAmiFOqeZupooMfJby50t536ef2UlRwLWPkDJuTVv571PTF19urHX5wt8l6OwAOZ87PwyAzdQRBEE4u+xIjf/Znf8anP/1pvv71r/OlL32JKIr42Z/9Wbrd7p7nlctllpeXB1+3bt060KIPA03TKOdsynmLXpiw1g5YafrbDOc4jBt2OIzwxGkyqCdNfO0mjgRBEITjZ18W8Y//+I+3/fy5z32O2dlZvv3tb/Pxj3981/M0TePcuXMPt8Ij4tJknhfny7y71gGlOF/O8dz5EivNYF9JleOGHQ4jPHGaSkVPWvKmVLYIgiCcXA5kIZrNJgCTk5N7HtfpdLh8+TJpmvKBD3yAf/Ev/gXvec97dj0+CAKC4N7OtdVqHWSZI5ktu7xnoUKYKKbKLs1eyFvLHSaL9r4M57h5AXsd9zCdTQ9iUA+aTDvO+SctN+SkiSNBEAThHg99R07TlN/4jd/gJ37iJ3jxxRd3Pe7ZZ5/l3//7f8+1a9doNpv8q3/1r/joRz/K66+/zoULF0ae88orr/Cbv/mbD7u0sdA0DdcymC46nK+6vLHUYq7i8Pz58iM3nON6PIqOia7BG0stwiTh4mQOpdS+8y4O6mEZ5/yTlrx50sSRIAiCcI+Hnk3z6U9/mtdee40vfOELex738ssv86lPfYr3ve99/NRP/RT/5//5fzIzM8Pv/u7v7nrOZz7zGZrN5uDrzp07D7vMPenvlpcbPlPFTIg8yqTKPuPmV8yUHBYmckRpimXoLDW8feU+9HNO3lhuUeuEnK+4D5XPcdLyQcbhuBJnBUEQhAfzUJ6Rv/N3/g5/8Ad/wJ//+Z/v6t3YDcuyeP/7388777yz6zGO4+A4R79zPazd8s6wxXTRZqMTjh0GGTeEMOzNeZhQTd+jsdkJuFv3AHYNS+0VipGQhyAIgnCY7MuKKKX4u3/37/LFL36RL3/5y1y5cmXfL5gkCa+++ip//a//9X2fe5ikacobyy3eWeuQswyuXag89LV2hi3mqy5LDf++MMZh9N44iBDoezSeny8D7BmW2isUM7zegm2glOL6ekeGwQmADAgUBGH/7EuMfPrTn+bzn/88/+W//BdKpRIrKysAVCoVcrmsOdanPvUpFhYWeOWVVwD4p//0n/KRj3yEp556ikajwW/91m9x69Yt/tbf+luH/Fb2x5srbf6P7yyy2PDQNY27dY//+3vnH6o6ZWdi6Xo7GJloupuB309+xUG8ObuFpcZ5T/3hf8OvO1t2WWv5p6bCR3g0nKaqL0EQTgb7EiOf/exnAfjEJz6x7fH/8B/+A7/6q78KwO3bt9H1e6ko9XqdX//1X2dlZYWJiQk++MEP8tWvfpUXXnjhYCs/INm03pj5So6mF1Hvhg9dnbLTWzFTclhq+Pd5Lw6jGuYgiaH78WjsfE9BnI4cAvgoSmZlp326kDJqQRD2y77DNA/iy1/+8raff/u3f5vf/u3f3teiHgXTRRsNeHu1jW3qvOd8+aFzH3Z6K6aLNtNF5z7vxVHnWjzIaA8LmQd5NHa+p7YfjTQwjyJ/RHbapwvJKRIEYb+c2bvEVMHm6dkSOUsnZ1t8+OoE00WbtZa/7x34KG/FKO/FUZeX7sdo77V7HSVqgJEGZj/v6WE9HMNrXWz0uLXZFS/JCUbKqAVB2C9nVox0w4SiY/HjV6ZpeTE522SjEx7pDrwvWma2jPKNje6hGtT9uMf32r2OEjW7GZj9hI0e1sMxvNZuENPxY2rdSLwkJ5ST1mNGEISTz5kVI0GccrveYflWQBgl5Byd58+VHkms+6jCDvtxj48KLfW9QpudgChJyNsmNze7VHL3ElbH+Tx284A8bC7B8Fo32j7X17soFJudkLYfiRgRBEE45ZxZMWIbGn6Ustb0iBLF197ZYCJvH0mse6dx3i3/4qD0jXbbjwjidFABM8rzsnP3OpxD0vYj2kHEejsEoGD3uDxVGNvo7ya2HjaXYHitfpSw2PC5udnDMnRe2kdJtiTCCoIgnEzOrBgJE8Vqy6flJcyWbYJYESfpkTRBU0rx6mJrWx+SvYzywxrNvtEGRla+7MW2vIy6wjI0dDSemC7ihfG+BNNuHpDDyCVwTJ2LE3nKOZOWF+OY4zcRlkRYQRCEk8mZFSOOqXNlpkgUKxpehGNEmIY+CEcchJ1Gr5Iztxlnx9T3NMo7z39poTwIc4zT4fVhwiHDXgvT0Lk0lWep4eNHCaah78tLtNMDUrCNbYnBV6YLD+2RKLkWk0WbJFVMFm1KrjX2uVJyKgiCcDI5s2Kk5Fq8tFBGB95d73BlpogXRryx3MK1jLE8EuPmRsD2SpSSa+2Zf7Hz/Nu1Hk0vJk5SOkE88AoUHRPT0O/r8LrZCWh5EY1uSJSmYw3UG7c8eRx2XksptatHYr9eoMNo+iYlp4IgCCeLM3s3nik5vPfCBLZhMF/J89z5Em8st1ht1ZkpuWO58cfNjbg0md/m2XiQAd15PkCcpARxyndu1chZBo5l8OGr0/hRcl+H1zhN6YQRfpQymbdZbPQA9hRZ45Ynj8POa11f7+zqkdhv6OSwmr5JyakgCMLJ4cyKkT5520DXYanhEacqEydjuPGVUtza7LJY792XVzHK6GmaNrYBnS7azFdd1tsBMyWHybzFrc0e375Vp9aLuDRp0g1jbm50WJjI39fhdaGaZ7XpkSaKSs7i5nqPlYbPbDk3dq7EsMeiYBtAvxx6/4mfe3kkHhQ6GcdzMq53RUpOBUEQTiZnVowMexE0DaaKNpem8izWvfuM5ihjt94OuF3rsdoOWG0HXJ0uDI4fx+jtZUA3OiFLDZ84SfnhUotLkznKrsl81WGu7NALE85VXF6YL3N5qjCyw2sKbHZD2ncadIOYy5OFfeVKDHsssqocRZJCnCref6nK8+fLYwuSvTwSDwqdjOM5kcRUQRCE082ZFSMtL+T6epsgTuiGCReqLs+dK43Mkxhl7DpBTMEx+fCVSW5udrk8ld+X238vA9r3FuRskx8sNumG2XrOlfMoBWGS8IHLE/cJgmGjP5E3yZkG1YLNnbqHY9xv8PuCqF8K7Jg6JddipuTQ9iNqnZByzmS16aNQuLbJRjtAKcV0cfxE373E2YNCJ+MknUpiqiAIwunmzIqRxXqPP3p1mfV2gGsb2JrOlZnSSKM5ytgVHRNT1/GjlIVqnstT+6sQGXXNmaEE1LYfsdzo0Q0iHDNHlKRcnS4wXXLHyvsoOiYtPyFJFVem8sxXc9tyRuCeINrsBLy10mayaHG+kuMnn5omiFPu1HtEGylhnJJzdDpBwkzJwTaMQzP4D/IijZN0KompgiAIp5sze9d+9W6TpYZPmCo6QcJf3trgJ5+ZHhj54TCKHyUYW3klfWM3bjLkbuGYUQZ0Z+gob5sYus6NjR62oXPtQpWrM8U939ewt2O+6m7zduwUL31BpIDFpodpwHo7xNQ1zldcFqou1YJNoxsyUbBYb4fYhsFEwXpkBn+cz1kSUwVBEE43Z1aM1HsBYZoSxVmVyq3NHq8tNgcejlubXW5t9gaiYWEid181yjjJkLuFY2ZKDi8tlLldyypdlFLbElCzfiQaoO2rwdd+8if6722j7aNrGnGiWGsH3Kn3iFOFaeigwDR0JvI2FycL28TNo2Ccz1kSUwVBEE43Z1aMPHOuTMleZzMKsQ2dyYJNN4wHPT0WGz1WWwEfvjKFHyW4lvFAr8Qodstn0DQNTdNoetnzTa+1ozNrJgLCJOF2PSRJUt5d71B0TGbL7jYvx7D3ZbMTEKfpQNDsFU7pC6IkSfCihF4YUXQMpvI2tW7ITNEmqyxW1HoRLT+R5FBBEATh0DmzYuTjT8/w7VsNvne7jmnoTOYtTMNAKcVmJ8AxdbphxM2NNvPVPH6UcH1LDOyntHU/Za3DnVn9KOFurYcXJry53MLUddp+TNuP+djTM9sEwXB4Z7nh4ccJzV7EVNG+r/vp8Nr7gsgwdC5NFrhd6+KFIa8vtWh4Ee+7MIFrJ1iGPpa4OS720zjtsOfTHPe8m+N+fUEQhMPgzIqRc9U8v/LRJ7g8VaDjx5Rcg48/PQ3A3bpHGKcY6JyruORsg+/cqmEbJtW8yYXJ/NhdWnfLZ1BK4UcJG52ARi9kaqu1eT/ccH29g0Lj4lSed9c7VHI21bxNJ7jXz6RviN5YblHrhMyWbdbaAQXbIEpS5qs5gD3DNpkgghfmK3hRTM4yKLkm76x1WZhwafsJUZqe6OTQ/YSmDrsMeD/XOwrhIGXNgiA8Dpw8y/KI0DSNF+YrzJTcbcbh+npn2yC2ibzN22td7tZ9ZkoOLS9ivRMyXXQO1DF0vR2wWPew9CwUM1/Njey/sdkJydsmQZzS8CKeLN7rZzJcDXO37rHW8dA0jZcuTAxCS90w2bPsddhzU3Itio5FkiqqeZu2nzBRsLbly0wX7V09LcfFXpVJD2rVf1BPz36udxTCQcqaBUF4HDizYmTnLnW6aLPeDqh1Q3Q9e17X4c2VFktNH8fSWWsH5EyNUi7/wJv/g3bBnSAmVfD8fJmlhodrGSN7hrT9iBcXynSDGE3TuDiRzZm5vt7J8kOSlOfnywC4lk6UKLww3jbcbq+y12HPTb/TaieIeelCZWQlzlrLP3E78b0qkx7Uqv+gnp6CbdD2I75zy6PgmIPPcBRHIRykrFkQhMeBM3vn2mms5qtu1vV0q6zW0DXaQcSN9S7rnYCiY3J1psBLC1X8KHngzf9Bu+AHGZGBR2WHoR8WA50gQilYbvhMFZ37pvvOlByUUoPW8lMFi7WWxxvLLWZKDs+dK6Hr+n2em7k9PrfDMKiHHa4YFQq7sdEduc6jKAPWsqInHvQWjkI4SFmzIAiPA2dWjOw0quvtYHtZraWjaxoL1TyVnI1SKU9OFzlXdggT9cAS1weFDgq2wUsL5W3zXva77sWGYqpgM1V0dp2Bs94OWGr4JKnimzfqvL3WRpH1MPl/fmCB9yxU9/W5PYxB3Sk+lFK8utg6NO/KqFDYbus87DLg7Pdn8cxc5uHqhsmuxx6NEJKyZkEQTj9nVowUHRNdU3z93Q26YcyTs0VcUx8Yr5mSw0YnYLXVBWC64NAOYt5d741lQMcNHYxTLjzcyGy56bPe9gdJr5enCnuuY1i8fOP6OndqHk/PlVhseLyz1tm3GHkYg7rzfVdy5tjelYf1ojwqj8F+xJkIB0EQhNGcWTEyXbTxo4Rv3txER6feDfnpF+YGU3CnizZTBZtLk3kA0jTlxmYPhWKzE9L2oz1FwH5CBw9iOFH1Tr1H1bXB5r6k11EMG8ucbWGbOk0vQtc0ctbu+Q278TAGdaeXCPbOYxnmYZM+H5XhlzCJIAjCwTmzYmSjE/L9u82sMqZgc6vmUeuGfOyZe+ZrrpJjrpKVx/5wqclivcbNjR6WofPShcqe199P6OBB9I15JW9xY0NxYTKHpmn3Jb2OYthYLlQdJvM2jV7IRMHm2gPew34Zt/X9pcn8fbktD3rv41TKHAfi7RAEQTg4Z1aMdIIYU9fIWwb1boSuQRCnKKVGGjbH1LeV/PZbs+8njLBXz5G9rjFc5msZOi0vZrJojyVmho2lUorZcm7frz8ue7W+3/m++7kt4773cSplBEEQhNPJmRUjBdtgpuhgmzrdIObiRA6NzKCOMmwl12KyaJOkismtBmWwvzDCXj1H9rrGcJnvzpLbUexm4B/29cflYSptxn3vhxHuEgRBEE4mZ1aMAFTyFk9OF6jnbT7+zAyuZexq2HbzahxGqeuDrrFbme9u7FdcHFb/i93CUMPt6rtBzKXJPJenCsyUnPHf+xivIwiCIJxOzuxdvBsmlFybjz87yzdu1Gh6EUXX2tWw7eZV2GkY95oF02en56JgG/syruM0VNuPuDgs4/4gwZazDH5wt0nHj2l6MdcuVB7qtSVpVBAE4fHizIqRgm3QCSJWmhEzJZvnz5d4Yro4MGxpmvLmSpv1drCtQdhOdhpGpdQDvRI7PRcvLZT3ZVwP2lDtQe/hYY37gwTbzc2sTPqJ6SJ+lNAJYq5MF/b92pI0KgiC8HhxZsUIgFIAGiXHvK9fx5srbf7o1RWiJMUyMhHywvz91Sc7DeP19c4DvRI7PRfdMOHqTHEs46qU4tZml8V6jyemi3hhfN9r7Fdc7Ne4P8gzM6rV/rULFSo5k4Ld29auXoSFIAiCcGbFSNuPqHUDgijh3bUOhqb46FMzzJZdNE1jvR0QJSnPnivz5kqLt1fbY03qHccrcZCwyHo74Hatx2o7YLUdcHW6cN/5+zHwD1NJ8yDPzF5VNZenChJeEQRBELZxZsXISivgmzdq3K536fgJb6+3qfdifuHaeeYqWTMxy9B5a6VFlKRsdkLeXu08MCF0HK/EqGPGFQWdIKbgmHz4yiQ3N7tcnsofyKg/TCXNg3JSdnv+JHtBDntejiAIgjA+Z1aMxEmKrmuYmk4YRyw3PP7i7XUmCxYffWqGZ+eKwDnW2wF+FFPvRKRpyp1Nn60WI9sM1k5jdmW6gKZpKKVGJrTOlt1B867r6x2Wmz43N7pYhs5U0ebahepIUVB0TExdx49SFqpZVcpBjObDVNI8yLNzGqtdpHeJcBSIyBWE8Tj5VuKImC7axEnKRjsgjFNCQ2OxmU20TRRcmsxzaTLPVMHm+3ca/Gi9QxinNHohSoc4ZZvBWmv5fOWdjcFN5yefmmaukttm5HQNFiZyg3BPmqZ85Z1NVpoe19e7FGyDyzNFFFleyKgb2GFXkhxFNctprHY5rPJmQRhGRK4gjMeZFSMAJcek6BjESlF1TSo5m8mizbvrXTp+zK3NHpoGHS8iSVPmyjYasFBx2OwEvLHcAjLje7vW4931LtWcxWqry6XJPHOV3MDIna+4fPN6jdeXmsxX8kwULJRSXN/o0PIiFhs9Lk4VtnJVTG5t9tjshvf15Rg31DHujuxhhMNua9jNO3QaOI3eHOHkIyJXEMbjzN5xN7sR56p5npor8Rc/2mC6YDNXdQnjFIDLUwVeW2wQxClPzZbItwMALEPnB4stwigFBVGidsx42W58+0buzeU2N2sdNDSKrkXTCwHFRjugG8QEiaLtRTw5XeDJ6SKpYmRfjr12VcNiwAtj3lhu093KMfnY09ODOTvDHGYex2neBZ5Gb45w8hGRKwjjcWb/MmZKDrah0wkSXrxQ4cNXJrg4WaDjR9yueay2fGrdiISUt5abuJZB2bVQaIRRgq9gruISxCmdLe/F1ekCHS/EMTQW6z0Kjsmzc0WuXajwxnKLy36BbhDzg7t1TE3nufOlLcMNH3pigrJj8mNPTHJpMs+ri62RfTn2Eg3DYuD6epuVVsBCNc9qO0t0navk9l2W2zfK4ybX7ncXeFJi6ic5uVY4vYjIFYTxOLNi5Nm5IrXuJHdqXYquxdXpApW8w7NzRYquxffv1Cm5BufKBW7XPCwjM5B+lJX7vrXS5tZml4WJ/KCXxgvzZb51Y5Pllk83THhnrUvtySnmq1l1zlrL5269R5qCbmnMFB1qxZAkTXlqusRk0R6EY14CUpXSC2JWGt5Yg/GGxcA7q22iJEWh6IYRSw2PtZaPUopXF1sDETRfzW0rWR7l3QDG8ngUHRNdgzeWWoRJwsXJ3K6DB/ucZm+KIDwIEbmCMB5nVoxsdEJWmv5WyW6Xnp8wVXKYr7osNXw6fsK7613q3QgFTBUcnpgusdrepNENuDqdp+xaVHLmYHe/1PBZ74Q0ehHPnavw7lqbb92s8fz5CroGlZzF1ekiH7hk8e1bdf7yZo1qwWaumufqbGFbXoimaeiaxmTBIU4VCxO5B+6qhl3Cs2WHBMVa0yOMUrwo4ft3GiilWG76PDFdZLnRY6XpM1NyB0JglHcDGMvjMVNyWJjIsdYOsAydpYbHdNHZ11ycth8NHpfqA0EQhLPBmRUjt2u9QfLorc0e5youlbzFejsgSRUXJnPcqvWYKTt0g5gwiekFEVenC1yeylNwTBbrHrVuRNNrUcmZJKniqbkS7653eHO5ialrFFyL8xWXN5fbOJZGwTGxTZ3zFZdEKV5aqNILM4PfN/z3BshlXV+XGh6uZTzQKA+7hL0whq2QUiuIs86tGz2iNKEdJKy2A0quyVTe2SYydotxjxP31jQN1zKYLjoPORcHlps+X79ew9S1PUucBUEQhMeHMytGALphTMOL6AQJP1xqMlmwuTSVZ6nh0+jE2IbORiek4BjkHJOpos1l18IxdWrdkEQpFqp5lhoekBls29D40JVJJvM2U0UHL4p5c7nNnXqPhQkXy9CZLmadSBe3PBO1XsBK0+d8NYep6w89QG7YJXx9vUM5Z/H0XJl3N1b4/t0mpqZxaSrPs3NF3lnrMFWwqebMba+xW4x73Lj3Qebi+FHCt2/WWGz4TA8N2RMXtyAIwuPNmRUjlybzzBVdlus+c2WLomtwccLdanYGpp7VxXSCkKmiS6OXhV+aXkyqsnbymsbA6F6azKNpGp0g5oOXJ7clfr6x3EKheH6+zHLDZ6rocGW6AMBqs06SKNa8gKszRfwofegBcsP0RUGjGzBbsnn+fJm2FxOnKW+tdgDQNbgwmb+vzf2oGPe4ce+DzMW5vt7BMe/lruS21iUIgiA83pzZO/1s2eXiVJ5v3thEKY26FhGlWcnvUsPPEioNDdAHxrsXJUzlXZ6fL7NYV0wVM+/HNkM+4nUgKwFebvgYukbBNlhvB6y3A2zD4MWFKt+8WePmZpeFav5QBsj1RUElZ1LMWRQck6mCgyLLGbk8mWel5bPeDnj+fPnQcjN2dpe9sdEdO/ej6JhMFCwAHFPn/ZeqUn0gCIJwBjizYkTTsmm9FycLXJjMc7fWI05S2n7EZiegkreIkpTJgoVhaDwxVWCp0WOz6/OdW1nvjvdfyvIZHmR0d3oLlFL84G6TzU7A3bqHQg1yUfpJrA/LzlLZD16e2DacTimFrrVYbQUs1n10dKKkeehVLA9TJTNTcnjvxaokrwqCIJwxzqwYgcxrUc3b1DpZ9UeYpCw3s/LbGxsKy9D58NVJim6KH6VYhoFrp6BB30bu1gZ+mGEvh1KKb92ssdjocXkyj0JxruIe2DvRFyG3Nrvc2uxlM2wMfSACZoeOu6ZpvLHcQkPjufMllpv+oedmPEzPkYN4gx5Fv5KT0hNFEAThceNMi5HnzpUAeHu1Ta0XkqSKG+sdSq5JOWex0QmwdHh6oUw3TNjsBGx2w0HSav+xUW3gdzNcmWDosdoKWG0FPDlT4Pnz5QN7JfqeiMVGdu0PX5ka2Sitb/ABoqTJctM/ks6Qj7rz5KPoVyI9UQRBEI6GMy1GdF3nhfkKrmXw9mqH+WqOlhez0vK5sdHDMQ1u13yuzJS4Ml3AC2O+davDO2ttzpVdCrbB5uBq23fIuxmuth+RpIpLk3k22j4Xh/qH7CZgxtmR9z0RT0wVWG0F3NzoDBqyjeKoO0M+6s6TD/LEHIZXQ+aMCIIgHA1nVoykacqbK23WWj7tIKbRDWn0QvQt+6SUopo3SVM16P/xw6UWK81sym/Bzj66ixM5pgs29W7IdMHm4kQWotnNcPlRwlsrbXphTN42KWwlq8LuAmacHXnfE+FFCU/OFLYN1xvFXiGRwzDcR9F5cq91PcgTcxheDZkzIgiCcDSc2bvpG8st/o/vLLLR8en6Me+ZrzJbdpgtO1xUeSYKDuutrAfIZif76gYxC9U8oNB1jW6YkLf0LH9kKI8Edjdc3SAmIaWSt/DjhO6W0IHdBcw4O/JRnoiHzWc4qeGIvdb1IE/MYXg1ZM6IIAjC0XBmxcg7ax0WGx4F22CjF2GZGjMll4m8ha5paIREBRvX1Hl3vUO9FxJGKX6comkaTxYLFB2Tmxsdbm70SJTibs1jvuoyV8lCLy8tlLld6wHZrr4/p6VgW1RzNg0v3CYYdnYj9aOE6+sd/CjB0NlzR36YnoiTGo7YbV3jeHIOw6shc0YEQRCOBn0/B7/yyit86EMfolQqMTs7yyc/+UneeuutB573n//zf+a5557DdV1eeukl/vAP//ChF3xYuKZOGKdstP0sFONHg+Zl1y5U+dCVSX7s8gQ526ATJDR6MZah8f6LVf7KszP85FPTzJQcGl7EnXqXd9c63Kp1+cFik/V2MJgv0/Riat2IVxdbrLeDQVin7W0P68C9nffTc0XmqzkW6x5vr3ZYrHvMV3M8vTUB+Kh35Cc1HLHbuvoek7dXO/zgbvb572T4s30Un6EgCIIwPvsSI3/2Z3/Gpz/9ab7+9a/zpS99iSiK+Nmf/Vm63e6u53z1q1/ll3/5l/m1X/s1vvvd7/LJT36ST37yk7z22msHXvxBWJjIMVt0MA2dmaLNlak85yvOID/kynQ2uC5OFLc3uhga2IbJk7NFPnRlirlKDk3TqOYsyq5FybWYr+bImcbgGsM7+WQr90TTNCp5i+myQyVvbdvBa5rGTClrorbeDqh3I85XXFIFrmVwdabIbNkdJLWutXyur3cG03j77PXcOJxUw73bukZ9zjvpezWGP8PTwkF/n4JwlpG/n9PBvra8f/zHf7zt58997nPMzs7y7W9/m49//OMjz/m3//bf8tf+2l/jH/yDfwDAP/tn/4wvfelL/C//y//C7/zO7zzksg9OzjZ56lyJqa5D24uoexFvrrQpOtYgH2Gm5PDEdIE3l1u0/Rhd0wjidNt1Lk8VuHahyjtrbUxDp+CYbHYCio5J3tJp+xHfueVRcEwKtkE3TCg6Fs/MlQflwcNhBj9KWGp4bHZC7tazmTeTRXvQsGz4uMW6R6q4L39inN4nw4wKc5zEcMRuYZKT6sk5LE5qDo8gnAbk7+d0cKC7drPZBGBycnLXY772ta/x9//+39/22M/93M/x+7//+7ueEwQBQXDP1d5qtQ6yzJEUHZM4Sah1A86VXZIUOn7EdNHh5maXSi4zyucrLi9dqFLOmdxt+Ky1fKYKNkopbtd61Hsh81WHhYnsH3fbT9jshDS9mPMV577k1lGGc/iPZb3tYxk6z8+XAZirOIOGaMPHZT1QsuN25nXcrvVG9j7ZjYP8sZ6ERmCPe2LpSc3hEYTTgPz9nA4eWoykacpv/MZv8BM/8RO8+OKLux63srLC3Nzctsfm5uZYWVnZ9ZxXXnmF3/zN33zYpY2NYxhoaNR7EZcnc6TAN27UQCmSJHPlFbam9W52QhqdgO/2Ir7y9jq6ruGFCRvdiNlSNur+0mQeiAb/6Dc64cALstjocbvWY7JgM191cUydkmsxU3K4sdEd/LE0exFRmg4G6g03RBv+o2r0QsIkeYA3YDxRcJA/1pOw6zjMxNKTIK528rh7fgThKJG/n9PBQ/9WPv3pT/Paa6/xla985TDXA8BnPvOZbd6UVqvFxYsXD/U1OkE2X+a9F6tstH3eM19G0zTeoE01b/PmSosfLrc4X3bJOyYKRRClrPsBi02PME65NJEnZxm4Q3kihq6xWO/RCWKavZRbNY+3VhqAhqlrTBdzTBQs3nuxOjDaw38sEwWLhYnctkm6fYaPmyrazFdHH3dpMs/V6QLdIObqdGFLJO3OQf5YH7ddx0kQVzt53D0/gnCUyN/P6eChxMjf+Tt/hz/4gz/gz//8z7lw4cKex547d47V1dVtj62urnLu3Lldz3EcB8c52n8wO5uPFV2LmZJLy09YrPfQtGw43mozYL0TYuoaqx2PlpdwaSrPrY0u9V4Amk6appyrulycyHF5SufWZpfllseNtS53Gx6WoWPoMFmwcazsI2/7EbAlimyDF+dL3NnKEZkq2COTLHeWCw8f10/S6l/vY09Pb+WnbP/jG7XzP8gf6+O26ziJ4kpKigXh4ZG/n9PBviyHUoq/+3f/Ll/84hf58pe/zJUrVx54zssvv8yf/umf8hu/8RuDx770pS/x8ssv73uxh0nHj2gHEbqm0Qoi7tS6uJbBfNWl7BoUXRMvSgiTBNPQmCs7VF2bZq9Lx4uYKTqcqzj4UYprG3hBwnrbR9d13llrc2OtixfF5Cydgm3Q9BPiJOWNpRbzFYeco9PshdiGOfCGNL3MEDa9FteGZsj0GS4X3nncqB391Znife97t53/w/6xPm67jsdNXAmCIJwG9nWn/fSnP83nP/95/st/+S+USqVB3kelUiGXyxIkP/WpT7GwsMArr7wCwN/7e3+Pn/qpn+Jf/+t/zS/8wi/whS98gW9961v8u3/37w75reyPhhexueXx6IYxX31nk9VWSMEx+cmnpnhiukgniLk0mef1xSb/11trtPysSVmSgmZAwbbw44icZdLyY/74h6v0/ISVls+PllsYpoZjmcyVbECj5Sf0ggjL0rj+2jKppnG56nK3brLR8dG1LCF1ubH7FN2DdGnd6/w++82ZeNx2HY+buBIEQTgN7EuMfPaznwXgE5/4xLbH/8N/+A/86q/+KgC3b99G1++1L/noRz/K5z//ef7xP/7H/KN/9I94+umn+f3f//09k14fGRqAoulFXN/oUMnbrLQ8yq7JU3Mlio7JE1N57tS6NHsRXqTY7ISUpyxafkwnjFlteKy3fJ45X6LZC1ls+PhRTDtMqOgmJdvg4oSLbZoUHZMbmz1QsNkJCRNFrR3iGBpeVMKPUlpBzJWp3Qfc7bZzH3dH/6Dj1lo+f/H2Bt2tnJqPPb13WfB+OInJoTt53MSVIAjCaWDfYZoH8eUvf/m+x37pl36JX/qlX9rPSx05E3mbixN5kjRlvR3hxynLrQBNU7y70UGhDcIYmqYRJilBlNANYxpewGY3Jk1TUgUFy+CdtQ5xnLLS9Kh1A+JUYRgaXpzS6MWU8wZ+FIGCMFZUchbrnQDXAMPQBgP6ul7I+cokSimur3fuM9q77dzH3dE/6LjbtR7XN7pUczar7S6Xp/YuC94Po0JE/ZLlkyxQTiunQfwJgiDAGZ5Nc2kyzxNTed5d63BhwmW25GQJn67JRD6rVFls9Li12aXrRxiaTsk1KLsWYZxQyVvkLINeFKMb8M5qC1PXiGLFZNHB9mNKjkmiIFGKas4iiGNmy3k0NGrdED9KyTsmXT9ioxfx/FwJlMY7ax3eWG5TdExMQx+7okMpNRjqp5QamQQ7/s7/8LsUjgoRASeueuVx4SRWBgmCIIzizIoRTdMouRbnKi6moVGwTUquyZNzJRxT442lFptdn6W6QZQkeFFM3jayRmY6NDoR37/bJggjUnSiNKWac0hVSkWDSs6i6BrkHYsr00Xq3ZBUKZ6YKmIaOvPVHOfKec5XbL57u0mYJLS9mFil+ElML0z58JUp/CjZVnmzW+fV9XbAV97Z4N31rDX/1ekCH39mZt/G59JknidnCnSCmKuFPHnbGOmhSdOUN1farLcDZkoOz50rbQvPjWJUiOiwc1iEe5zEyiBBEB4NSaqI05Q0zTbESapIU0WcKtKtn5Oh7xcmcjimcWzrPbNipBPEpCk8OVskTBWaUpyv5nFNnZxtstYKafoRzV6PK9MFzpVdnporcbfW5RvXN7jd8Ol6CaYJcZK1iN/sBliGhmkYaFqMGxs4VvZcNW8B2VyatpcwP5mj6NrUOiGuYzCXz9H1I6qOzUzJ4RvXa7x2t8Ez50oEccqNPTqvzijFrc0uNze6mJpGwTHp+BG3Nrv7NuKzZZePPT2zrTV9kt7fcv7NlTZ/9OoKUZJiGZkIeWG+sue1dwsR7ZXDIrv7h0cqgx49Ip6Fo6IvHpJUbRMXibonMobFxWnjzN6dgjjlTr1HtJHSDWIuTxZ4Yb7C3VqX6+sdbm508OOEpbrPestnqugSJelWImuKY+hEZkqcKFIFpg5Rkt2MojgBx8APE2xdp94LeeZcmeWGx/duNwnjBEipFmxsEy5Uczw/X+ab12ustX3u1ntYpoauQ842WGv5bHYCnp8vj+y8ut4OuF3r0Qoi1lohsyWHy5N5btd61LrRvoz4cBjn+nqHJGXkznq9HRDGKeerOd5cbvL2apvnz5f3XXnzoBwW2d0/PFIZ9OgR8SyMy07PRLLV+bsvLhKltuzL6RQX++XMihHH1LkwkaOSt7hT93CMbAe51PB4dbHJ7VqXphczkbeYzDu4FtS7IUGcEKUKlaakKHQdXD0rzJksWpCAYWYGuRcmVPI2SmlstHyqBYeSY/CdW3W+e6fBRMFhpmRTcixeX2zw6t0GjV6Ibmj8P67NEyVwa7OHpeuDoXmjOq/e2OhScEz+yjOzvLbU5OJEnvMVl81uiGsZ3NzoDGbt7GeXttfOeqbkECUpX7u+ga5lOTDr7WDfN94H5bBka4AfLjWJU8XFyRxKKdltjoFUBj16RDyfXcYRF8PeDWE7Z1aMZMmhGqstn4mcyfPny+Rsk9WWhxclTBcdbtd6BFGW1LrWDglqPZSCOE3JOSa6rjFZsMlZBnfqHkmiyFsGkwWLqZJLL0hwbZ2JgsVmN6QbJQRuNhV4puhSdE104PJUnm9c3+BH6x00pehGKd+5U+e5cxUsQ+e58yVg+9C8YWNcdExMXSeIFc+dq3DtQhYuuV1b59XFbMhgsdbj8lRhX2Jhr531c+dKfOTqJK8uNnlqtoht6kdy450pOcxXc6w0fWzDYLHuMV10DrTbFFe6cFRIaOzx4b78iqFwSLotVJLlZIxTbSrszpn+S1GKraIRjemiw1wlxztrbQxdoxPEGLqOaWgsNXxUmhKlbCWzpuQsHdc2cQyDjW5EGKckChSKOStHyTG4PFmk3gu4sdElTlKKroVt6Jwru/hxSsMLBwa+3smqa6YKNp3ARwM+cHmCpYbHctNnsmhvG5o3zG6i4fJUnm4Y88RUAS9KRoqFvQzzXjtrXdd536UJdF0fuKSP4saraRquZTBTcg9ttymudOGokNDYyea+pM4tz0X/seHnRFw8Ws6sGOmGCSXX4tlzWSJoN0wAuLZQYbHu8a2bNaYKNpW8haFptHohkR8TJIogSgjibKZNJ4hJ4gTXNvHCBJVCz4+Jyw4/dmWC62vZnJqJvEPRNXFNbZBbUe+FNHsxm52QWClypk6cpkwULF5cqDBVsOlulb9emszvemPbTTRcnirQ9GL8KMXU9ZFi4SCG+VHdeA97tymudOGokNDYo2dYYAz/fzgs0n9MOLmcWTGym4Gbq+T48NUpemGCrmmstX1c26DgWvSiFDtO6ClQKeiaRhjG2KaJH6Z4UQqmTt2LcOsebyy1uTCRI+8arLdDml6IXcw8D5enCkzkLb59M8sTSZKUy9M58pbJE9MFXjhf5tXF1kAk9OfS9Bkn1DCOWDiIYX5UN97DFj3iSheEk4tS2ytGRlWRSO7F48eZvQvvZeA6fkSapMwUHerdgJmiRZKa6LoijCw6YQsvgno3wjZgumiy3g7Jm1kJb5SkKDTeWG6ioXjufIn5SuZtaHkxG+2AW5s9lFJ8906dlaZHy49ZqOZ4arbEJ56bxTF1kmY4EAnDvUaKjolSaptYGeXRGEcsnAbDfNiiZ+fvfrpoDyYeSw6JIBw+ewoMSe4UOMNiZJSBU0rxw6Umf/jqCj+426DtR9iWwazKhulZhkkQBhRsC02LCGOFH8FKK8AxDSYKNkmaousmlqFxu96j3g1o+gkvLpS5PJVHoRFECd+5XcM1dfw4ppyzyTsW56s5Co45qJQZFgnDvUYMXaOSMw8l1PCwXoejSgJ9FMmlO3/3ay1fckgEYZ/0BUY/ybOf2Dmc4HmWSlOFg3Fmxcgo1tsBf/bWOj9abVLvBrSDGNc0eO1ug0rOJG9b9CJFN4zpBopk67xOkBImKXGqqOZMJooOdS/C0DRc28QPI1aaHiXX4MZGl7eWW3hRVqZqGwahSgGFHyc0vJCvvL3OXNlhoZojZ5uUXIu2H5GkivMVlzeWWyzVu3SjlHo3YLrkPLRH42G9DkeVBHocyaWSQyIIGfeVoO6oIImHvBepJHgKh4iIkSE6QUyqFKau0/ZjWkGMpyWEysqERK1HGERESToQIgCRgjQCTU9wTJuLEy63NlN6YcKdzQ71bsBq26fRDUgU+FHKxck8OjBdtMjbJhpgGzpvr3V4Y6mFrmt86Mokv/DSPLNlF6UUbT/i7dUWd+setqmhawYoxUsXKrsO1jvKz+ooDPhxCIPTEKoShIdhN3Fx7/vtVSVSQSIcF3LXHaLomMwUbbwwIUpSyjmLkm0QJglr7YC1Vohl6ETx/eemQBAq7tZ7GIZO3jaJkpgwUfTClOvrXe7UelycyNMJIvK2jm0apApcK6HoWjS8GD9OOV/N0/Iiap1wmzHWtKxzbBAnzJQKlFwLx4TFusf37jQxdY2pos21C9WHnoY7bpjkqAz4cQgDKccUThM7Bca28MhQuES8F8JpQsTIENNFm7xjohs6xZxFEKZEaYqpGTT9iDiFJN3uFemjgFhBL8raqM9W8lh6JjjCVBHGCV4npunFmAb0ogRD0/DDhNlKjvderOLHCb0gYqMTopGSszX+4kdrvLPappIzKTgmP/bEFC0/ZqMTkCjFTNHmnbUOLT9mesuIPsw03L4IyWbc9EhTRZSmfODyxMg270dlwI9DGEg5pnDc7GywtZvAiFPxXgiPJyJGhlhvB3zvdoMkTlio5Njs+BQcg412QNtPRoqQYRTZjJpEQa3jYes6UQpoYOtZPXw7iHBNg9UkwDYMYqVor3cxdY1yzkLXNPwwouSa/Gi1w92aT84xeW6uyIXJPCh4aaFCOWcykbdRSnFjo4djGay3A3KWQcE2uLXZZbHe44npIl4Y31eNs9Pj0c/VWGz0uLHeo5o38aMUTdNGdjw9KgMuwkB4XNgpJkZVkIjAEIQMESND3K71WOuENLyYhpeV7eZskyge7Q0ZRZhkc2rSVNEjIWfqKE3RjRVRApoOUZKSphoaGgXLQGmZx2WjE6Cjsd7JZuC0g4Qnp4vkTIM4Sbk0mWeq6GwTE2stn6YXo6HhmDrvv1QdvJfVdsBqO+DqdOG+apydnpJ+rsYTUwXeXG6z0ox5aq6EudWNVsSBIGyfP7KbwJD8C0HYPyJGdpC3dMquRS+MyFkmK02Pund/5z69/38t84QYgG1CmoJpaXQChQY4aFiGRqISHAsMTSdKU1zTIElT/AQKtomORt0LUUC9F+IFGikaNze7aLq2VRq8fbaM2rrhVfMW1bzFpck8s2WX6+sdoiTl0mSOjU7ApQmXjh9xt95lIm/T6IX3Dc7r52p4UcLTcwU2OxF+GFPNWRRs48g/d0F41Az3vhgkcg6Vpcr8EUF4dIgYGeLSZJ7n5sts9kKiNMU2oO3HGHpCskOPpIClQ96EBA2UwjQ0/EjhBdkNSyPLDcmhc76ap+tFoEGcGFyZKdDxIhxbR2k6mgYdPwunRHGKbehMFmwuTuX4xLMz/OwLc/flT/RDK/VuhB9FrLR8So5JO4i5s9kjUWAZOmEKK02PGxs9vtGuM1u2KTjmtp4m00V7kKtxcSLHG8stumGC9P4STgvbxMMOEZGkktwpCCcZESNDzJQcfvyJSXSleG2pTaPrcWvTY7d7VpJmSaupUgQxaJHC1DNviVJgaoAG58sOFyoudSsLtyhgruziRwlJqrAtjXYQo28NhUs1DR0wDI3nz1f4ufecY66SA7ZXu2x2AjY7ASstnx8utWj7EecqObpBRCln8WOXJ9A1PRvS52STiYOozvPnysSp4ju36syU3G1hm1myBNySa/Psudy2uT2C8CgZdyS7eC0E4fQjYoTtlSS3az0c26TkmvixSc7WiZLM4xGM8I50M2cHGlnoRtfAMjTQNCwdJgo2f/W5OVY7PgqdXhDRiRLeXm0Rp6CSBIXOxQmXhhfhxQkqVcRKwzV1pvL2ttccbgrWCSJu13pc3+jSC2M2uxF522Sl5ZPrRhQdayAylho+OhozJRcNjThV2IYxsp/HYZfXPoquqsLJRg3lWOzmtdgpPgRBODuIGGGokqTeY7XlM5G32Wj7bHQi4iTFNEysNMLQwE8yETKM2vrSAMvUeXG+QjeMIVWEqeK7d2pMFF0uTLjUugaqE9DohTiGTqXkEsQppq5RckwSpdH1I2xL4+m5CkXX3uaZ6Ceanq+6/HApwNQ1cpZONZen3g1ZbflU8haXJguUHTMLPZ0rMV10aPsRL14o45g6QZyyWO/xw6UmcZp1g1VKoWnaoZfXrrcDvn+nQb0bESbJruXCIlpOD6O8Fjuback4dkEQxkXECEOVJNNFbmz0WG42uVP3uL3ZpRclWQgGsI37hcgwOQvytkHB1VHKYK0VEClFqqDopvhRymY3IIoVlYKF56cUHJP5CYtzJScr8av3CCKN85UcjqkRbYVY+vS9Fm8stVisZzkitqGDUlydLWIAUZqiqZSJgs2lyTy6rmchmB3JrwCrrTq2YbBY9wYlvIddXtsJYurdiHYQsd4Odi0XPo5W8EJGnKT3JXLeJzjEayEIwhEhYoShSpIwZq5sM1XMqlveXu0QxPcEiPeA1IkwBT9MWGv6GJpOyw8xDBPXSKjmLZ6YypGkmSDp+gmpSrOpvnNFnpotstYO0DSdgmOStw1ytsnlqTxJkvDN6xs0ehEV18A2oNkLiJOU6WKOt9fAi2Kmig6TeYswUeRsg3Iu+/Xu5nFwLYOZkrvrZODD8kwUHZMwSVhvZ3N0disXlhkxh8fOJlo7Z4zIEDNBEE4SIkbY3vXz0lSepYbHWivAMDTSaPzrhAkYmuL6Rhel9K0JvgrbtPGihF6UEiQptW5ErRtiGnBjs0s3jLmx0UGplPecL2/lg8RUciZvr7b52rubrHcCWl6EZer0/JgUhYbGctMDFFemixRdi7WmR94xeWmhihcldMNkV4/DgyYDH5ZnYqbk8IHLE2iaNmhZPyoP5aC5Ko9zmGfnhNR+zkWcphIWEQRhLFKliOKUKFVESUqcqGzIa5LS8iPKrsXVmeKxrE3ECNu7fiqlmMxbvLHYYCJn0vT3V0kSxQoP0EkxAEtLKTsGOVPn1Tt1rtc8ap0AgErOou2HbHZCrm/0mCnaFB0LTVMUXZtyzubN5RarLZ8oViQoom7CZickZxmU8zZlpagWLDY72TA+TWUVPt+4UePqdB4/SrhT61HrhDx3vsRy0x94HHbmhvQnA+/XM/EgEaBpGs+fLzNddPbMQzlorsppC/Pc1yxrRwvwnaESQRBOPumW17Fv5KMkM/zbjX/22PD3UbolFIa+j9Ps/DDOpsL3rxMlinjr/P730bbXGnrN9J4AeZAX9KeemeE//s0ff0Sf1HZEjOxA0zRqvYjNbojxELvq/gy9dOtLJZmRbAQx3V6EF6ckKmuOttGO0A2YKtgYOli6RtuPSNMUP0z58zdX0Q0dXdNY7Xh4fkTOtXBMHT9J8ds+Boqia+CaBn6suDyd45m5Mq8vNekFMT9cahEnKXcbHi0vwrX1bcmqO3ND9uuZUErxxnKL79zKck8mClkFj6Zp94mTB+WhHDRX5UFhnkfhOcm8F+k2T0WcptsERyYuEO+FIDwkfYMf3WeE7xn8UcZ/27FjGPydxj8TEOp+kbF1rTBJOc1RzzDeKyvyaBExMoL1dkDLiwn38XvRyLqw7hzoaxrgRwnNICZV98p/bQssXacXprR7IaZpoKFA6diWRkqCbRkUdIVSMJGzKNkmeUfD0AwaXkjONjF0nds1j2fmynhxQNOLWWsHBLGiFcSstEM+9MQEKy2f2/UOFycKLNZ72xJI+0a67UfMV10cU6fkWmN5JtbbAd+93eBu3Rscf7vWo+nFj9xD8aAwzziek52CZapgk8LAO5GqHR4NaQEuPKYopQYGd2DU05Qo7hvzre/Te4Z/u7G+36BvP25YJGw9l6aEsbpn4He5juQ5HS6WoeGYBq6lP/jgI0LEyA6UygzVrVqHza4//nlsFyKGBqkCTUGiNOIkExUp2XyavGNRcU1q3RDH1FGApenEmoYXJnikvHC+RCXn8MZKg7afUHR0pgoOFyby1HohGjqGBu+sd1lseFl5sQaupXNhIsdc2eGbN+u8vthksxMCGrquUe/G27wGBwlvdIIYU9eYLjmstwMcM/vHfByJqA8K83SCmChJmSu7LNY91tsBrm1sa6S12vJ5falFnCg0DZ6ZKzFVtHd5RUE4GPs1+FGced7CkTv18Qz+duO/ddyWVyAeOicWg39o9Ns+WLqGZeiYRvZ/e+h7y+g/t/W9rmfnbH0/6jjb0LaOv/e9qWvYpr792K3Xtcyt19S1wXoMXUPTNBYmcjjm8Y3+EDGyg/V2gBfFFF2LNM08GeM4SPpNzzQt+940sg6sidJIVea6U4BjZOEYXaX0ooSya26FWMC2NYgV1ZybdWO1DVAJSarRi2PqvYRGL+LSZImnZsr4UYwfKgq2T5oqnj9fZr6aY76ao+lttYd3DQq2iW3qdMOYt9faXJ7Mb5s3c5AqlqJjDox1zjJ4/6UqUwWbptc6tKZpe7Gz5NS1DWxTJ0kV651gW7ik1g3Z7IastwN0XaMXJmy0g23Xq3VDwjhltuSy1vbphTFTiBg5zfQbrj3UTn3L2Pe/j5ItYRAPf98XCPe+33n94bDBTjEgHB7WwGBnxtse+t4ytsTAkCgYPs4cMt47Db61y/PWDuPfN/K7GXxhd86sGNktfyAzzHCu4mLqEA3lrxps5YGMuJ6xNTCv76VPY8AEHUXONgnizG9iGRoqVbTDBPwEpYFrQt61iL1MvCxUHJ6eLWFbOvVeRDeIcHUNx7UwdI2KqxOnKZsdn16UcnEyTxAlFB1zqxW9wrUMojhlKu+iaZmhbvQidLhP/R6kiiXzRlTv80Zc25EzMs7vo59LMagW2TnA7ID9LibyFs/OleiFMXnbZLJg3XdM3jbRdY21to+ua+TtM/snsi+2Gfzhnfx+d+pb34f3Gf+9Df7OnIFBnH8rH0A4PIYN8U7jv83gG9s9ATsNvrnjOqa+ZfiHDfuO19j5vbnlXbAMMfinnTN7p92r3LXjRyzWPDRNw9DVYEjeXnU18Y77XQKDtqyJUjhbE32V0ggShaFls2eCSBHG4MfRVojFouFHtIOENExwDR2FhhemGKaOUorXFtu0guyYXhjz3JZHZKZkkyiyBNxOiGOaPD9fZrHewzI0ur6DYxn0gphbm91Bg7ODVLHslnQ6W3aZ2hIOfpRuKzsd/v5RDi3TtKyseC9Px2ThwYLluOgb/J2Z+iOz9tMsIW9gkOMtd35/V39fad9oYRDuEBPRNoEgBv+o2OZeN/XMZT/SEN9z04/2Cmw9v1MgmDrm1jVt857hH/5+ZzhBDL5wlJxZMbJbaGKm5FB2LfKOSd7SMw/GQ+Il2VTfNFUUHRMvjElVimNmXhQ/UoPpv2GchXYsA3KWyWKzR72TJam2PB/bMLk4kSNvm6SkGIbGubLDj1ZD3l1r4+gaFyou6+2Q6ZJD14/I2QZLDQ/T0Jl2XX5wt8VSs4WuQSFn8sR0cV+Jpfd6XaSsNgNafoRj6qRK0fZjXMugmrdR6nT3uijnsqZzYZJS64YjjfxwrD3aYeRHufZHGfy9svOHDf5wiEE4PHY1+EPfjzL4pq5jmdqOY4YM/667+R1Gvn+dHceZYvCFM8iZFSN7hSZSlQVjLMtgb3/I3uhAwTYJEkUnSLBNg6m8zVTeYrXj0+zFWROaRIG2leyaKOpeBKmiGyYst32SFGw9wmr5fOyZGXK2yd3NHmstD7U1/C5V8J3bde7WPTQNFqo5fuHaPAsTeYqOScsLmSxYmEaWTLvRCnhnrU3ZNVnb8hJFSUoniJivZHknEwV7UD0yHBbZ7IS8tdomTRXdIEbT7oU3nh0j4bMvarYZ36Ea+1EGf2fMP052y9ofw+APGflRIkE4PEa52u+57Pd4bofB3ykQrG0iYYRBH3psZJKgGHxBOFGcWTGyW2hivR3wo9UOt2sem+3wQK9h6qDrGgXToNEN6AYJlbzCtjTec76MqcFbq13WOwE528DUMxFRci3afoJpxLT9rMFZJW9l+RKx4sXLRZ6cyvHVdzcIkxQtVby71iZKFb0wJW/prGg+HT9C16Dei1hp+nhRyo2NLvVuQNG1aHgxP1pps9LyudvwcAyd5aZPzjYoOBZTBRvL1O8z+I1eRL0XYuk6m72AJFXkLZNelGAZ2U1+VLOf4ZwBMfmHx7gGP3PL3zPyIxP8RsTxs4S8ISM/fB1DH+zws5j/9muJwRcEYRzOrBjp5zrkg5gkUbT8LMF0uemx1vJBZaVYXvLwTWCSrVk1fhKRKogU3Kn5NHshE3mH6ZKF0jQSBZ0wwdIN/ATCXky9F9H0IsIkmztT8zJD/9/eWuPP392k60d0woQwTrOSYXWvD0afL7+9edCPSdjC0LX7yuTsHYl2tnnP4KdK4UUJhqZhGtlgwJJr7pmpbw+HALaM/LBgGDb41uC1xOALgnD6ObNi5PWlJv+fv7xDoxfiR+lg197sRdyudekGCQf12CdAc0fntBRo+CkN3+NGzdtxRkr97m7DcLKwxu37znl8MHRte6Ldzhr5XbLpHxSfHzb+LS+i3g2ZKji0g4gLE1lIai8PgWlo6Ps0+HdqPW5t9gYlwpen8lyczB/RJycIgnC6ObNi5G7d4z997dZxL+ORY2gaug761tC6JM1CJv0dfNExMQ0NDY1yziRJs4TOSs4aZOL3eyPkbIOya2KZxr26/a26+r5I2O62370070EGXylFrRtlVS5WVprci5JBxcu43oHhfJdzujvIcRm+vmNqVHLjX3MUUiIsCIIwPmf2Dmmbj67tbd+kqaHvLUNjIm/jWvqgvXGqsq6fhq4TJwll16KSs2j5MV6QTep9z0KFas6mkrMI45TNXsDdWo87NY+cbeCFCVdn8vy1F+dxDI2bmz3iOCVIUybzFkXHpuGFOJaOBtzY6OKYJlES0+jFXJrMU81bFByTgmMNklInCxa1bsRSozfIKzF0fayE1cOg1o0GIqIXxigFBWf8pNk+u5XuDl9/v9fcz+sIgiAI93NmxcjlyTy//rErhEkKikH8/0crLb55s0YvSO7rHfIwmEDR0VDoREkCmk6aplyYzPHxp6f5xLOz9IKIL/9og41OyFurTbpBRNGxeWI6z/mKS9dPWO8E+FGMrmnMlhx+4dp5nj9fZqMT8v/9y1v8wQ+W0YCibfCTT03zxFSBt1Zb3NjsUbAN6t2Q1bbPpYk8aZo1ALsyXcDQNJabHm0/zbwhrkUQpzx3Ls/V2eKgw+p6O2C15bPS9FlvB3zw8gR+lGIZGpMFeyhvBRRq8LPa6jybbnlT1NDzivGHxfXCmDRVzJZcXl9ugIIr08V9d0ndrdfI8PUPo/PqOD1NBEEQhIwzK0auzhT5f/3CCySpylq565nP4ge3N/l///8CXltsHcrrKCCIFYnKSoRtQ2HZBqSK6+s92sESjqlzt9bl1mYva3ZGSi+IuLnRJYpTyq6JYxt4UcJK0ydv67yx3Gam5DJbdvmpZ2Z4e71LvRsyUbB57nyZVMF00SGIYtIkJVWKtVZAGKVcmCxgGQZTRYeFiQTT0DEMjzhWVPI2TT9iruLywnxl8D5WWwGmoXGu4nKr1uX6RofnzmXN1qr5BxvcvSbmqq3mZ2tbz+dtY8sr0X8+CyO1/ZggTjhXzkqZvSimkrMGa1AqCzmlW0qn//1ugmh4cq6EVQRBEI6PM3/HNfTteQF+rKi6FkXXoOUlB+gykpGQNT8btJJXipyhaPkRNze73Kl10VCUXZtumBDEWY8Tpae0vJiCEzKRt1naaBPEiqmizWTOYaXp8cZyJphytslHr05TyVs0exFTBYeWn6Bt9SBZbXmkKRQci4WJPI6hk7OzMJVSGk/OFgmSlJ4fUe8FWdM320ApNRAMRcekG8S8s9bBMgx0tK2ur+N1a91rGJ+maWx2A15fau06rO/SVJ6cbdAJ4sFcnW6Y3CdsHgalFJcn8yxMuHT8mMJg3o52n3BRgEp3eH/Y4fHZec6IxwRBEIR7nHkxshNN04hJsxJKM8GLH3zOuBha9qUpiJKEtp9N2A3irHeHYZhUrRQvSNH0LHS02QlxdI+5kotl6DS9mKWmh9IYhEzKbpZ0CjBVdLg8VUDTNNp+RDVv8vZqB8c0uFPvMVOymSy4vP9SFaUUd+o9GoshtV7I+ZJLJ4iZKrksN/2B5wWyviyXJvN0/Jgnpot4YdZxdZQIGOUFedAwvgc9v1vb+cNA0zQMQ2O+erTVLsOfS8E2mC46oGlD3pvRwiVVwI7ybbXl+klHCB/Y7hES8SMIwklHxMgOLk3muTpd5J3VNslB3SJD9GfVKAUqTLOeFFqKSk2Kjo4OuLaRhSSICLYskKUbFFyDF+ZLdIIEXfOJkpRmNxyEds5VHSquhWObTBds1tt+ZujIvCHVvI1paLyUr1DJWUwUbKYKNm0/K22dKtpoax0uTOZYbgaUHIPFhkclZw28DpqmcXmqQNOL8aMstLPbQL1RXpAHDeM7yLC+k8ZuIandvEMGR98npC9q1JAnpz8PaFgIDXt62PbzdhG0Vwhs+BxBEIRxOL13/CNipuTwgUsT/F9vrqG0ePSI3gOQALaeDclLEkUvDck7DtPFLA+i5BokFZe3Vzq0/AhTT0hSl8m8gx95TBZt3lzu8M5ah+/caWKbGn6cYBk63TDh4kSeH9xtcXEqR94yuVPrUclZREnKdMmh6WUN2JpezHzVZarosNkJqORt4gTCOOE7t+pZ2W+iuDSZZ66SG3w24wzUG+XluDJdGJxb2AoBXV/vDK5zkGF9sHdOyqNmN9HxIO/PUaJpGpnz7NF+Jml6v5gZ9v7cF+oaFjgP8BhJ+EsQHh9EjOxgreXz7ds1vDBBPXzzVSC77Y+6PUZp9p9SzkKprMx3vurQ8mOuXahQ64S0vZC8b9ALY2rdgDv1HmGieGetS5Sk2JaOUpCzNTphwkROB5W1k//hcpMwSXhxoUIYZ2/ibr3HRscnSTVemC+jofHEVI5rFyq0/YiXLlSwDY3v3Grw3Tt1posu622f799p8NRQbsY4oZK+l2Ox0aMbxGx2gm3nr7X8bcb6pYUymqZtEyo3Nrr7EhWZAGiw2QmJU8X7L1V5/nz5kQmSYTG02QmI05SFan6b6HicvD/jog9ysh7d7yEdquTqe38G4a2hvJ++CBqIniHBtFPw9ENkgiAcDY//3XCf/OBuk1fvtuiGMQdNFxl169K3ntB1OFe2afkJvTDlrZU2fpiy2Q2xjaz3SNuPiBPFZhpza7PHX3lulju1Hp0gJmcZBHGKbRi4RpaMGkQJ319sEkYxrm3yxkqLIExZbwf04oTpos1ywyOIE2ZKLi9eKGfiYihRtN6LuFnrUXRNFhsh37/bYLnlU3RMfvKp6YGXZC/6Xo5bm106fsxmJ6Tpxbt6CG7XejS97LG2H6FpUHSskYmsu5GJgJB2ELPRDlBKMV10xjr3MLwqw96Q/nvYKToO6v05TRyXp6rvATqq0NdwuGunp2ebuEnvz/vZWfKeKvHuCEIfESM76AYRm52A3gGVSP+DVWxV0Wz9nJIJkjiGtVaAY5mUXJMoSWj6EZ0wq+bI2wa6DrZuYGrQ9GOWmz7zFZckTVlrB2hAwdJJSYkSjamSDWnKfDXHBy9P0Ohlg/KaXkjLT7hb6+GYGu+7WEXTNJwRjd8uTeZ5cqaQGRHXpOPFVHIpq60ulybzzJbdBxqZfrJpJ4ipdaP7whI7PQTAQJx855YHGjwzV95XKKPomMSpYqMdMF108IKEr727wXw1N1j3bsZwr0qfcRkWWIv1rOppquhsEx1HmYR70jiMz/Qk8ijCXelWA8TtXpsR4kap+0RQP6l58Fh6zzM07CkShJOGiJEdGHoWLjgopgZKgzgF18xuJkkCEaBpmSjJ2wbnqjnWWz6dIMaPU1AKxzLQUEy4NkrTMHSNvJW1WS/YJtFW9Y2GhmEEOKbBZF6jlLPoBglxqvjRWpcnZwo8f77M64stNrstXMvIrq1pTBUdSu79XUFnyy4fe3qGThDzzmqb795poBR0g5ilRlZOvNTwSFIeaGSGRYeugR8lvLvWxo8Sym5WnltwTDp+1tl1pdkbtJ/fbyhjpuQMKoT8MGWp6XG36fHWaocnZwp87OmZXdd5GLkcw+/VNHQuTxUeC+P7sBxnfsxpR9c19CMUO7t5d7YlJPcrs0aVtu/8fsf5IN4eYf+IGBkim08S7nso2ih8BYYC28zKeRMYhH0ile2rco7JBy9V+fr1GqlSNLcmB9e7IbbhcGW6wGY3RinF+Yk8QZLy1mqbpZaPuTX/pRskoDTeXu9wu9ZlruTy8tVJQOPSZJ7nzpXoBjG9KOby5DQrLZ+5cha+aPvZUL5h78bw7r1gG7T8mJWmh6FpeFGW3GoZOi/MVx5oZIbDEn6UsNTw2OyE3Kn3qLo2YZKQsw1ylsGdmsdk0eJc2eX582XcrTDUqDWOQtM0nj9fZrro8MZyCz+OsczMWd8J4j3XeRi5HP332vajfa37ceUs5secFo4jmXk4MXmnCIL7S9H7eT07uzXvVdG1M6lZSttPF/u+Q/z5n/85v/Vbv8W3v/1tlpeX+eIXv8gnP/nJXY//8pe/zF/5K3/lvseXl5c5d+7cfl/+SFlr+dzc7I31J6qTeTf2QgFenDU829pkDDA1aHQCvvyjDeJUZc3QTIOSa9INEwwNnjtXYrXt40cK19B4bbFBy4uIk5QgVthKUdhqRhZECU7OIkqh5Uc8f77K5akCuq5zcSLHa0tNvn2rzmTRZrpos1j3qHcjgjjmykyR8xV3YDCGm4l97Olpvn59E+hxrpJjtekTp2osIzMsbK6vd0hSqOQtXl+MSNMt4afDtQsT2KbOU7MlNDRytknRMbkxws2/Vy5C//UgCxNc3+gC8GSxsOc6DyOXY/i1R637rHGW8mOEB5O1Bxj89Mhff1Rp+6jKrlFeofsqvnb0+JHy9sNh32Kk2+3y3ve+l7/5N/8m/+P/+D+Ofd5bb71FuVwe/Dw7e/KctrdrPZJUUXZN1h+QNDJOoU3/mFHtSiwDTNNgsxNg6lByLYJEoekaE1tD1b5+o44GtIOYME7pBgmmkTVDq+RMyo6BrmvUOgGu4+AaoGvQ9WNcS2dq6zqb3ZA7mx69MKYbJMyXXRq9mOWWx431Lt+70+RDVyawjKxCp+TeSx7VtGxKby9K+eaNGlem8rz/UhXXMkaW6AIjxUJ/p7zZCUlRtP2YcxWXuhey0fazhm69aJBnsZubf1QuwkzJ2faa00Wbjz09zeWprInZpcn8nsbwMHM5JDyRcZbyY4STz3GVto/K69m1tH2PpOedAmfUY6edfYuRn//5n+fnf/7n9/1Cs7OzVKvVfZ/3qCm62ayT1XZAGCnCI3iNvqdksxOgaTqJUmx0QkqujWMqTN1gsmCTKMV606ftxxg6BFFKGKUUXBtL18g5Jn6osC2TjW5ITwfQWGz2+O9vrDGRt3jPQpWNTohl6jw3VebNlRarLZ9uGPPmSptUKcKtkEInyPqqPHOuxA+Xmnzt3ezxKEn58ScmuFXr8cR0YVAyu9r0+Iu3N+gGWdLtx56eRtO0kWJBKZUJKNdgvupya7OHaWhcmMgSTIuuhWPqlFxrIBx25ptcX+9kZbNJysLEvbJZ4L7XnKvkxqr8GeYwKkAkPCEIQp++R+goc4BgdH+e4bL2UaJnp8fHOOZw8iO7U77vfe8jCAJefPFF/sk/+Sf8xE/8xK7HBkFAEASDn1utwxla9yAuTuSYylvEaZZEaugJYXD4ilMDwjgL9VhmOvS4Yr7qAjqOodMKY0zTIFIRXT8hVRoTBQcNMA2dat5mOfTRyCpjbB2iVJGzbRabHu+sdXhhvoKha6y3fd5ayZJYo0QxXXSZLPi4psFyy2OjHVBwTfwo5Rs3aizVPNKtwJKha2hozFdyFBxz0APk1maX6xtdqjmb1XaXy1N5porOfZ4BgFcXW9v6ijx7rryn0R/OwVhu+nznVg3bMNF1BWw39ofljTiMCpAHhSdOUnM2QRAeD4bDYI+io/NRcORi5Pz58/zO7/wOP/ZjP0YQBPze7/0en/jEJ/jGN77BBz7wgZHnvPLKK/zmb/7mUS/tPjRNoxcl9MKIKE3pHVCIDOeVaIClZQmtALECS9fwt7JZDV2j6cWsNAOuTBeo5Cw6YUySxNiGxlQhhx8nXKy6xKnGpakc9W5IrRuQKo1qzqKSt6l1QurdENPQyVkGay2f5aaHbeiYmsbLV6eYLTlZC3gNlpseObvA7JZRzJuw3PJwLA1D02j6EUGscEyN+aq7rZImTfvv7t7nNMozMEosjKrk2fm76AuBr1+vcbfuM1PKQjhXZzLR0w8TbXYCOkHEYkNh6ru3qX8QhyFqHhSeGCfMJAJFEISzxpGLkWeffZZnn3128PNHP/pR3n33XX77t3+b//V//V9HnvOZz3yGv//3//7g51arxcWLF496qXTDhChW2IZBmqgDNz2r5jSCWFGyDWbLLlemi3z3boONTohSCsvQMQxFEityjkHHj9GA5ZbPRjskQZF3THTDZLZoESWK3Nao+zhJWGt5xCn4UYTSIGclXJrOM192mSjYVHMm37/T4Pp6l5mSQ9OPafsRCxN5Lk8VKDgm6+2AvKWz3vaxLJNLk3laYUwYJ/xwpUOjFzGRt6h7Eb0wIUnhfNXljaUWjqkxU7LRgSdnCoPcjFGegWGBEsTpA5NT+5N531xp0wtipoo26+0Ax8zKZmdKWdXMd283MLTMUzRVsAfPjeJBXomjCrHc1511jDDTWUx6FQTh7HIsAe0f//Ef5ytf+cquzzuOg+M8+uz7gm1gmRp36h6d6OBekYaXXcM2UizDIGcbnK/kCKOUbhSja3C+nMOLFY1eiFJQ60WobkjOtgjjLBHVsUwsQ6NoW6y1PSoll9VWQKw0XFOjHSgqBlTyJlem8pwruyg0NnsRNze79MKE+WqO2bLNxck8Ly1kicTvrHVYbYWcr7i8u9qlEyVcX+uQtw2enilQ64aUXIPpgou29XEYusYbSy3u1j0uTOQoORaXp/IDETDKM7BToLT9iCRVnK+4vLnc5o3lLAynlBqEc1peSCeI6QYxtW7IubLLxcksebbvSfjOrTp36h45W8fUNS5P5e8TGMNCwI8SFhs9ap1oZMv4o6oAGfaGdIIIpTiSMJMgCMJp5VjEyPe+9z3Onz9/HC+9K5nR8umFWbKoSRZiGadqxuD+ipnh8+q+4m6jR6VgEUYJmgbTRYc4VpyvOKQKvt320XVoeTG2CUGcoNDIWyZBrPDjlCiJ8WK4knfYiBWoCNsyydspUaRYaQa0ejF5x8S1TT54aQJT05gr2biWzrWFKh+5OjVIMr1T67HW9ii7JmGakCYplZxNEKcYhk7etmh4MSstj4tTWaKppmn8cKlJ24spuyZtP2Ein4Vcbmx0Bx6N4fLgUQLF0DXeWG7x1mqbtY7FRifg4kRuYJTfXm2x1PS4PFUkUTBXcfnI1anB62x2AixDJ2fpvLHcZipvcavcu6/ZWF8IxEnK9fUO7SDGMXW8ML2vZXx/nTNbAma/83F2Y1t31oZiqnB/d1ZJehUE4Syz77tep9PhnXfeGfx848YNvve97zE5OcmlS5f4zGc+w+LiIv/pP/0nAP7Nv/k3XLlyhfe85z34vs/v/d7v8d//+3/nv/7X/3p47+IQWG8H/MU7m9zc9Jgs2Ky2s+m2D8ICCg40gr2P2+jEXF/v4oUJLS9C92I0TXG7puNaJroGtmngRwnRlrJRKFJSUHr2XKhwbYO1pkfRNXhytkijG4LK+o50/Kz7ajOISVPo+hFPzZb4v70wx3w1NzB+Nza6JKnixQtV1jshCsXCZJ6On4VDGl6IFyVMFEzOVaoEccJ7zpeZKTlsdELCOGWp5bHeDbANnfkJl5ub3tizZfoeiK+9u0GSpli6wbvrXUquiaHrWxU0GpaZ5aAXHJP5am5bpU7bjzLRaGhM5m0+fHUKx9Rp+xFKKW7XetlnqBRxkpKzTVbbAZudgChVPH+ujG0YI70Qh93KfFt3Vv3+7qzSk0MQhLPOvsXIt771rW1NzPq5Hb/yK7/C5z73OZaXl7l9+/bg+TAM+Z//5/+ZxcVF8vk8165d47/9t/82shHacdL2I5pehALiJEXXQUtGD7sbRtcgTjVM9s4xSYG1tr9VTgVxqtA0WG37WLpOkChIU2wTXMOgnDMJU8W5Sg7LMJjMWzS1iMKWz6Zomzw5U2C9E1LvRdyu9bhd6+HFMbZhcGEyt5WUCrahcWW6MNjd942jHya8tFDh8lSevG3wxnKLbpgwYzjUuxFrbR/T0AfnvrnSZrHusdzwSNKUZ+aKaGjESbqv2TJ9D8R8Ncdbq53Buqo5iyemi3SCmAsT7mA9TxazfJS2n80NquQt4iTl6kyBy1MFbpV7OKaOaegEccp3b28MGp7NlGxKjsVK00MpxdWZIrc3uxiaYqJgjfRCHHbY5EFiQ3pyCIJw1tm3GPnEJz6xZ4OVz33uc9t+/of/8B/yD//hP9z3wh41QZzihzHtXkS9F6EpMHWIHhCnCRToicLUsgqZPY+NsmumChIFOVMjToB0q2mNBpah4ToGBddmztF538Uq6BqtbkCYKOpdH4VGnGrMT+SZK+dA01hueswUbaIkpZq3mSrY9OKUolK8vtRC07RBXsduxnGm5A5m0qy2fKaLLrdrXTY6AZudkB+tdrB0nSdmSqy2AzY6ARN5B9PQiZKs3XvBMUdOrIV7+Rv9lulpmjJdsOkGESXXpLC1ln4ya389/TVuLme5Kjc2uliGzrWLWc7H5anCtnyUbhBTzdmAQgcuT+WpdQPeWm2z2vTI2QbPnCvx3ovVkV6Iw05kFbEhCIKwNxKc3sI2NMqOxWzJZrNj0fRjojHLabwxEksUmXckK4vNxEiSKAxDR+kKx9CZyDlYpsZM0WIib2PoGq6VVdnc3PRY6wQopWHoKV6UcH2jw4cuT/LMbDZ/Jm+ZdIKID17O2qvfrXssVF2+e6dJrRNyebrHx56eZq6SG2kc+4bZixK8KGUib5OzTXK2wfxEjjsNj67vk6qU6aJDECdYuo4XxixM5AddWWF7zkiffvhjsxMMEmA1fasSJu+w1PCZKbmDCbvDa1RK0fEjHFNjYaKABjimPtLQ522D6xstwjjz3lyazKOUwjZ1XNPAjxMm8vauoRcJmwiCIDxaRIxsESaKzW7ISjtEKQ3X0omTNJtBwPYmwuPU2eycR6O2rmGZ4FomXhiTt3Wmizb1XowXxvhRTDnnoOsG3UjhhwmLjU0MXePGRpdumGLqmREu2BampnNlpshTMwUMQxsYz598qt8JtcG3btW4udHl6dkS19c7XJ7K79qZdL0d8P27da6vd1hq9EhVypPTOQzD4M9/tEatE3K+kiNOFRcmc6QKFqpZiaprGVydKe75mfTDH5W8xY2NLpWchR8n5G2T5+d3D+v013an7tGLUm7XelydLozsVTJTcnhhvsxGNyBJFSU3+yeuaRoFx6Kay3JiHjR0TzwZgiAIjw4RI1s4ps5U0eFWrUMvTomSlFTdq4rZb6FvQjYMT6lsDk2UZA3PkgR6KivrDSJFrZslneqaTppCwTVpdkOCRDFVtAmSlMBPiBKFa2pomsZk3uIjVyaYLuaYK9kAlBwTU9d4cqaQeRGCOOu2GiXoukbDC9GDLPSw1vK3VYj0wydvLLd4fbHFcssnjBXNXsTsE5PZOjshiYKn54oEsaKaM7lT9/jO7RoF28AL420zajRNu6+vR8E2BvNpLEOn5WWP7yx1HUVnq+X8h69McnOjQzln7jp1OGebXJ0uDXI+umHCxYkcM0WbWjdkpmhzcWJ/reKFgyGdZwVB2AsRI1tkM1FsyjkH1/SJktED7vpo7C1Q+rdZU4d4KxE2ikHTIU3BsjSiRBHECaapYWg6mq6x3gwpuiZTeZMkUbiGjq3rBLEiZ+loCiZLDg0vJkp9VloB37/bGiRsZvNuNLphTNOLKNgW771Q5eZmF5MsBPODu81tnT9vbXa5tZkNCXx3rUPLjzPREaX0woSJvMOPXZniGzc2uVXrsVDNU9gSESpVLNYzgTNdzDFRsHjvxSqzZfe+qpSXFsqDFu8vLpTpbjX8KjgmrmVsm0uzk6JjYuo6fpRSdC1aXsw7a92R1S6jcj6UUpRcC13TtvJaxBA+Sg67QkkQhMcLESNbzJQcPvjEJBvtgOVmD63h7Xm84v7+ItqO5zQtEyOOpRFGiiAFY8vVkqTZgKJEKYxUJ0ySrYTZhErOZbrgUs2bPDFbotHx+d7dJmGUYFvZ5IE4hVrX5921Dh0/JkkUtqHxo9U2620fTdNpehFTeYtn5sqYWuY1UKlio+PT9rOJtj+422Sx0WO1FfDjT0xydbrIjc0OQZRSyVlcmMjjRyleGHN1ujBocNb2I4qOiWXofOtmHd2Aa6aJQnFrs3uv22iaDkI53TDh6kxx0D317bUupq4xVbS5dqE6SFxdbXqD0txLk/ms98dQHsfmVkLtbtUuo3I+bmx0KbkWz54rD9ay7fcpO/cjRRq7CYKwFyJGttA0jefPl1lvefzxa0sEYySv7vScKMDeCslAlqhqGVnCZLLlRtGNLFSjUpgsWiRxim1qlHQTXTeYLlg8da5MxTGZLTl4UUInjJnIWQSWkXVeTRXdKMXWdX643KQdJPhhAlomgGq9CC/Myn/9OGWjFxCkCbfWu9za7PLkTJGXFipomkaSKp6YKrDayjwk71ko86GrkySpYqbk8Oxckc1uRNuP8KOEbhBza7NL3jZo+zHfuV2nE8aUcxa3N7ucq7iYhkZt65xRlTX97ql36x7TW56QvnFabwd85Z0N3l3PPD1Xpwt8/JmZLIdjK4+j6Jg0vXjPip2domLYWzI8Bbh/jOzcjxaZZiwIJ5OTshGTO8IQmqax0vJZ7UT7zhHpEyVZK/j++X6SEKdZ9YxGFrLRAdfWKTkWPS1GpYqcbZGzdGYqOZZqPVo5kx+ttrlb92l6EbFKmSnYtP0Yw9CJlWJhtoQfxcRxQiVnkaI4V3ao5W2+e7tBnCaECaSpouJa2HoISrHR9nl9sckT0wU6QUSqjMFsmctTBaaLNhudrB37ZjcahE6+d6exTSRU8iYLEy6zJYfNbsBEwWa65NDxI6aLDqkymC4693Ub7QRZL5S+CHBNfSAONjsBHT+i4lp0gphbG11uTeW3ralgG7y0UN5WsdP/g7q12eV2rUdhK6zTFxXD3hI/SrYN/Os/Ljv3o0MqlAThZHJSNmIiRnawWPNIk3TbxN0HoZEJjIR7uSTm1vdJmnkrNJX9P2dm/y/aFk0vwjIMSjmdSs5C1zXu1DzSVOGaOrapkXNMml5I24uIowTLMsjrgNIJkphUaTiWQb0XYZsaRcfCNU2u53uoNHvxXpjQ8iNafkTeMehFCV+/UcvKhA2N6aKzTYR8+1adW5u9LE/D0AdGpBPEVHMWoNENYi5P5Xl2rkx9S7A8MV1gueGx2g5Zbdd4cqsp2c5/2EXHZKKQVcI4ps4T0wUW6x6pyprPpcBSs8daO2S25AzExVLDJ0kVugYLEzlcyxhcs/8HtVjvsdoO+PCVSfwoHYiK4QqZ6+sdkpRtwkN27keLVCgJwsnkpGzE5I67gwuTeSaLDn7DI0qzDqvJLm4SnUxwpNwL2fQFTMxW3gjZNXKOTpKmmHpWYmoYGmmswFDEqSKIFK6lCKKEmZLLasvH1iFWEV4YY5sGQZwQpYrJnItr6+RMkyhN8UJF3jbIWwamrnFpMk+UKJJU4ZgGQRyhAx03Jk5T5is5XNuknDNp+Vm4A2C97bPc9FlseIMckpWmxx+/luVvNHshfpwVOl+dLnBxIkfBMbFNnZmSg21odPyYD1+Z4uZGZzDFt89w07OFiRxXZ7Ly3LYf8c5al/lqjrv1lJJrEMcpOdvgw09MEiTZef0/mDeWWqy1A6aLzn2ejSemi6y2A25udlmo5keKilHCY5yd+2G5M0+KW1QQBOGkbMREjAyhlOL58yU+9vQkP7jTZKXpo2tQ78X4I9wk5lZlzCitYgAl18SxDNpeRN42MfWs06vSsiqakmtyZTrPajvA0MGPU+JE0Q0jgjhhYSrPVMlho+3T8BI6XkTTD7nT8MjZJnPlbHhdwwtBaVyczIOCphfhRRGNXkw5b1JyLCp5h8mCw+vLLeIkxdI17tY9lps+CSlvrXYoOQaTBWeQQ/L6YpM79R5rHR9T15nIWfzYE5NcnsqqabIW9B7FLa/FfNXFNLImaIWh/JC+sd3LHWjoGov1HssNj41OSKoUQZSyOiQ61ts+zV5EEGfibKdnQ9dgpeFRcgzOV1xeWigPRMWwABgV5hln535Y7syT4hYVBEE4KSFUESNDrLeDwTAz19I5V3XpehG9KMEP7kkOE8jbUMzZ9IKIhr9djhhAwdVJFUzmbS5N5ImSFD9OqXUDvCilYJmUchYFx6QYZt6GjUZAmKR4zRjXspgp53jufBnb0PjmjRrrnRAvSfDCFC1UvHq3STVv8TMvnOPWZg8/TFls9uiGMevtAMfSmbdzXJ7Mc3Ozh6Hp5G0Dx9bJOwa2AbapoZSOoWUVPnGq8KKEJ2cKGBrcqXcxNJ1qzmKzG/LOejt7kxp0g5i1dsiHr0zhRwmOmYV0bm126YYxm92QphcPjO1u7sDpos181eXt1Ta3ax5LDY+ya6HpkLMy0bFY72EZOlGacmWmOMj7GPZsLEzkWGsHTBYcdC3rydL3OIwSAA9q0raTw3JnnhS3qCAIwkkJoYoY2UKprCT1K+9u8s3rmyw3A9Ag3QqD9DGAnJ2FQiaKDou1Hl7oE6RZSEYja3IWRFnTtIKj80sfvEDDi/jBnTorrsntjR5o0NsySjlTp9aLsA2DomuSpIq8bbLW8nEtnY9cnWK6aHOr1sUPE8I4xbEMYpUSxglPzxZ5Zq7E197d4J21LEHTtYwsjGJvhVHKDrV2yHTRoeRabHZCukGMF6Y0/Ahd0/jQ5Srvv1TFtQyKjsl62+cbN2psdELu1HskSUo3SHhjqc25ao6feHKKtXbIzY0OCxN5Sq41EB21bnSfse27AxcbPbpbJbr9HiBLDZ9GL6LphdimTgo4us58NcsNSZXGC/MVlhoe5ysupa0E12HPhmPqWLpOOWdS62TVPH2PwzgC4EHhk8NyZ54Ut6ggCMJJQe6CW2SVGD1ub3RZ6wRYhoauaTSCBAW4BvgJFGyYLLosVBwmSy7NrsdU0aLRiyjnLKI4m+sSp+DoGn6k+N7dJkopat2YpVrmubANgyTJkkt1w0DTInKOQcePiBKFZRrkbYM0hXdW21lYpuRQzdncbXgkacJCJUcpZ/GDLQ/J7brHaidgqelDqoiTlIuTWSfXkmOxUM2R3Fa8vdbGtkzW2x7z1TyfeGaGzU7Ae+bLTBXsQQ8Ox9R578UqV6eL/OXNTTp+zNPniqy3Q3pBRH2rm2k1bzFfdZkuZt1gtxvbe2W0/fDIrc0uS3WP170mtzZ7XJpw2ewEuJaBZegoleBaGk/PZnNlNE3bZrz7omenmAjilDv1HtFGimXovHihPHhupwAo2AZrLX+b8HhQ+ORh3JmjBM5JcYsKgiCcFESMbNHPJXhhocrbax2afoKupeQcnY6f0u+RlSpoeiE/XInR13okKkVDZ6aYVZO0/ZBWkNL1I1AQRAnfv9skDCP8RNHwItJUYdg6Jcek5GZD8WZLFrc3fVpeiK7rKJXSCROC2KMZxkzlLfKOxUxJZ6Jg0+iFVPMOXpjw9es1pgo2Sw2PuZJLmiqiJKXiWpyr5nhhocJyw+fJmQIoWG56WYKrysSQoek8d75C0bX4yjsbAyP53LkS00WXibzCNDXeWm6z2grQgGfOl5mv5mgHMY5lsNTwmS46I8to+5UyfQOvaRob3ZBqzub6Rpc0VdytewT///buNDau8zr8//cuc+/sM+RwF0Vq8SLZlhwvsaM4aX9tjPrvv2G0CJCkhQuoVfsigILaMbrELQo3KBInBdI2iAMnbgobRWOkRlu7SxqkrtPYPwNxvCq2Y1mOrIUS9232ufv9vRjOmKRIiZKGHok8H0AvRIrDMxybz5nznOc8no+qwJU9Ka7sTS1JBtayeJu6ymBHjEw8QqHqYupq83OLY0oYGjNlm0OnCkuGrp2renIh5czVEpxLoSwqhBCXCklGFiRNnYrj4/s+V/clGS9YKIBlubhuvXvVDaHmQqhAvuaB4tObjuIFIWmz3qxqRGLUvCooGkHo44VQrbkoYUix5gIKMUMjCBTsAEqWT3dK47rBLLqar38uDJks1ihaDpmowXTBYqpQoydpcuNQF9tycY7PVHhnooSpa5Qdj1zSYLrsMFOuN7d+qDcFYX0r6J2xEh2JCKlofVT70akyI3NVruhJkjB1ejMmu/vTnJgp8950hWwswmSxwtaOWHMBv34wzYeHO3hvukLM0Ni7JUPF8ZunYBYv3suP0Qbh0mO076tvfxm6wtaOOAEBh8cDEqaOqqqoqtrcJlm+eK9UcUhFI+SSJn4QklvYjmpYHNNU0eL1kXxz6FpjaixA2XYZzYfoqtqS7ZPFCc5ovtqcTiunaIQQ4n2SjCzoTpkM5+JMFGtc0ZvG0CIEYcBUyaIaKDiuD149uYgoUHFDdDXE8eq9Idm4Xl9gkiauF5KLe+iaRi4R4eh0hULNxfZCohGlfgxWV9jakeQjO3MoQCaqk4lHGC3UsFyfiKbSla5XOabLNt1Jg0Q0wrauBNu6krw1VmKi5FBzPXRFYbgzwbUDaSDFTNlGV1TylkPWNJitWmTj9d6M7pTJ/9nVw+sj+WZVYFdfCoDxgkXF9khHdSq2x3jBYltXku1dCRRFoS8b57rBjubPbKponXPrY6X+iIRRH7JWtj12JhNc2ZtivGAzmq/PE9nencJy/bM2dq5UcVjr9sfyoWuur3NytkrC0ChU3frx6N54c9vpYix+/hXbo2zV+2nkFI0QQrxPkpEFiqIwnEtwZKLE6fkaqXiEjKkThAEly8P1AsKFJlXH9+lK6HTG64uV7YfMVRzKdkDZ9omZGvt29HJ0usRk3sLzA6quT9RQ6klG0uSGoQ6SZgTHC5oL6mBHjLLtUq66hGqUbCzCkckSUV3jqp4MplGvFJRtD4WQbFQj9H1UTcX3XSDC1s4Y1wykmS7ZTBZtetMmL52Y493JElMlm21dCfrSJjcMZTF1lVQ0QhiGvHG6gOUEaIpCvuaiKQqWEzQv1Vtp0Vy++DceZ/HFeACZWP0/s8VzRz5+ZXfz67qSBt0ph0xMJ2FUqTkeunb2ysSKWypr3P5oDF0Lqc91SRj1Swljhs5MxUHTlCXbTg0XMh/kfO7UEUKIzUqSkUW6U/VFerxQY3S+hhtR6M3Ub5+drzooav0HpqkqHXGD7lS0PqsChdFChYgWkI7qJDSdfMXC83xqroem1CsrWzpiRFSVPYMZPrG7h7fHirw3U2Z0roap10+OTBbshUUyYLJokzQjdCWidCbq/R+Nhs6S7XFkqoLt+oRhvUckopXpScW4bkuavkx9++it0QLTRQsUhTdOF3htZI5btuXoSkWbScZ7UyXmyg7pmM5AJkYiqqIpGrv6U4wX6pWO7kUDy2wvaCYyjerBShfjjcxVKdS8ZnKy+Kjt8qSh0WsynEusqbFz8cmcsuVydLLEbNluXqy3PElYPmdk72CGkbkquqbg+gGn52vMlG0AtuUSS6a3NlzIfJDF20Nnu1NHCCE2M/ltuIii1Eejb+9KEoto1Nx6D0l04Z2z6wEKmGpI1akP5Ko6Hn4IlhcSEjBRtBjIxrD9et/BVNkmX3GouUF9amgiytaOOHEzsrAwWfxiukyp5mFEVCKaynBnnFwySs3x+KWruyGE/myM3f3vD/Ea6owzmDXRVY13J0u4vk/KjGDqKhOFGuWazak5i7fH88zVPFTAiGiULI2i5TJTsanYLh/ZUZ8Rcmq+upDQqOwaqI9SHy9YzUWzsRDPlm1Oz9fY2hGnc2E+yOh8jfmKy2zFImrUR7Trar15dK3zNIIg4J2JUnNI2rZc/Vbh5ds+jSSjUXE4OVthPF/jvekKiqIsuVhvsZUSiVzSZK7i0p+NoqAQjajNOSsr9Yxc7HwQOUUjhBArk2RkmYrjN6+af/XkLMemKhCGhAtvtHWtvsBqmkJHwsDxAopVp34cV1MxdY2UqdOViKApClXbb46B15X6MLWQkNmyzVTJYrZs0xEz8IOQqK6RiunUXJ+kGZCORZgp2fRlYuzqSy1ZYK/qS/PG6SLHZiooKlSdEFVxSccMslqE0/M2b40XOTVvYbkBnfEInUkdLwh59eQ8qqoyXXLwgpCtHTEGO2KkYzqn8xau5zOQjTWrH90pk2PTZebKDpbrMV91GMiaC1UJh+mSw3y1fn/OQEeMXMJgOJcgDEMKteKaKgHvTJT4wZsTzYQIoCtprlqJaFQcyraHqip0xA0ad+aslCSslEg0qivjeYtc0mTPlnRzG2ylZOFi54NcKsOFhBDiUiPJyDKNseKHx4pMl2ymKzZhqBCPaHh+gKFr6CromornhySjGtl4gqrjLTRGKkQjGiWnXm1IGDqKAio+PWmTdFTn7bFifYqqqpCO6syUHfwgBCUkFzcY6oqzrTNB2alv8azUlrCrL8VHdnSSMFS60t0UKjaZWISYoVNzPX5eqJKv2nSnTebLDqqqkIlF6ElFUQhJRQ0SplbvP1EUckmT2bJNseoyXXLxw6WLf2OGR77qMFGsH8PtTkXJVxxOzlUpWz4diQi6rpFb6LUIw5C9Z1ncF6s3kgZc3ZfmyET9Zx+NaOesRCTNeuPwZPH924Qv9D6axkWBq5HKhhBCrA9JRpZZPFY8FqlXOdRMyFTZxg0CDE3jQ1szDOXiZKIGFcej7PgUqw62G3LjcAcpUydqqKgo5BImJ+fKFCouCTOCE/i8N10hHTXQFbhpuIPD40UUFBKGytaOGHftGSAa0fjFVJlYROPEbIWRuWqzFyIMQ2bKDh0Jg+GuJAmzPjHV90PemSihqVB2PYIQapZPOqpzzUCG23f3sqUjxttjRY7PVilYHl0pk6HO+pbIi8dmieoqPWkTy/UpWS5QryqULZctHVGuGUjxxqkCmgof3p7j+HSJnrRJX0ahVPOI6e9vbyyuBJyr+bM7ZRLRVI5MFIlo6qoncVh4rKmixchclTAMubo3ydaOGIqinHE53+LHX55ILK9UTBWts/aESGVDCCHWhyQjyyhKvbKRSxokozqjeYua4xPVFYYHMozMVqg5Pn2ZGHde24eiKBw6lednp/MUqw5BEJBLGWzpiJNcGLu+rSuB7fq8O1ViumxzYqpKJl5hqDOGqWtEjQhDOQ1VhWTUIBrRsL2A49NlJoo2cUMjYegM5xL0pKPN/gcvCICQYtXh8ESJ0bkyZQf27ejE0DS25xKoGuzqS/Ppm7fSm47yzkSJiKawrTPOcC7W3E55Y7RQP3FTtDidr3JVb4qtnTGOz1Txg5CS5RLRVFRF5YreJGFYn6yaMCP0EFJ1fFKmzg1D2RWTgXM1fzaOFzd6Rnb1pVAUZcVKxHTJ5oWjM7w3/X415Jeu6m4e1T0+Uzkj4VlLInG53Bkjt/4KITYaSUZWkDTrczbemypj6hqZqIGVqp9YcXyo2j6n5mrMVV26U1HGixYnZqtYts98zaMjaXLdgE4YRuu9IprKxHyZEzMVxuarWF7IyZkiWzpihIAXhCgKlCwPdeFm37F8DT8McT2fXUMdmLraXBwbi+aWbJy3xwr8bLTA4fEyfuBTqHm8eGKWqK6zszuBqqgYmsbp+RqvjuT56XuzKIpCNh7husEMqqryf38xzSsn5hkv1DA0laihUnN8KosHds3Xx8rnkiaJhSbViuNTczwOj5fQlPpNvV3JlRfGcy30qqpyzUDmjK9bKYEoL/SFZGMRFveJABd1G+6F9oR80MmB3PorhNhoJBlZQffC1kXZ8tjWlWQsX+HIZJGfny4SjdQvnSvVvGZfw+nZKv5C/8jIXI3nj0xydKpEJqZjaDr5msPpuSpHJkq4fn3CaESNEAQhpqaRNDSMiMbOniS/dGUXpq7iB7BnS5aqE5CvOvVKy8LiuHjR9IKQIKgPUzM1Ez+AroRBR9yk5nqoispc1cYPA96dKFOseVzRlyRfdZvxl22P7qTJ3EIT6hU9WbqS0SV3wuia2qzMNIRhyCsn5ihbLh0Jk3zFXrKdtFgrL4dbrU+kXaddPujk4HKp4AghxFpJMrKCxgC0Qq1+t0p3KkouYeJ7cGSqzOl5i45Evafi1RNzHJ4ocGq+Rs32UFSVuKnz7uQUPWmTHT0pyjWPk7NlXD9A01Qc3yfEpzcV5Zot9epEYyR7Y6tBUxVqrs/O7gRDnXGGc4nm4rh40dzaGcNyPKZKDmXbozNhcN2WLJbrc2K2ihd4uH6IqWuYukbcDDk1W6M3bTb7MpKmzmTBImPqJCIanXGTjkSkfuuv6Ta3TpZPJJ0u1ZOP47NVfnpinp6UQSKqkzD15s2/jZjDMCQTqw9GS5h6sx/lQqoI3SmTj13R1ex1Wdwn0o7TLh90ciC3/gohNhr5LbaKlaaLThZrVN0AQ1eJGSqHx4u8M17i1JxFzQmouT6GBoT1iatl2+fwWBGVkGLNR1UVVAXius7Nw11s64ozXrDoTBrs7k83302v1my5klzC4P/f08fWzvjC/SoKt2zv5NCpeXZ0J+hKmhyeKGJ7Pr0ZE12JoSghN2/PNfsyGgt7I1GIRrTmZNaxvIUfhCtOJC3b9a2Z3f0pbM9nd38aLwh57eQ83alos0oA8OZoET8IKdsuYQipaOSCqwiKotCbidGbiZ31NfugTrvUkwN4e6xQPyrdGSMMw3XbqpFTPUKIjUaSkVUsf5cchiE3bcuhqhq6qjBXsXh3sowXhFQdF9uvJymhojBfsUku3MhLGKIpCpl4gKopWK7Ph7ZmOfCxYXRdX3FBOdc79JW2BX7tuv7maZCJok3CjJCMRkiYOnu3ZNnaESMZjSyZHdJYLFda2KF+yd3Z3vEnTR1dVVFR6U7WB4d5QYihaWdcjNd4nNdGahDC1X3pllcR2nXapTtlMpCNMVGwMDSN0fnaGYlbK8mpHiHERiPJyCpWakpcfOJDVWCsYDFdtrE8H9uBMAJxQ+GqvlS9Z8PxycQNijWHmKFxTcqgavv8f9f2omnaGYnIatNGl1ttW2DxO+Z4RGW24jBTdhjqjLOrL4W6MBV1rc85YWhn3Q5ofL+S5XLtlhQV2yNfdSnUXEbnq0vul2k8TsLQKFker43MNb/H5a5xAqs7FT3jNZGTL0IIcW6SjKxiqmjxwtGZ5iJy284cc1WXV0/MMl1yqdoOYRhiaAqdcRMrEpCJ1weJ/cpVPVzVn+G/3hxnrFBDVxSyCYP+TIz+TIzBzkRz26JxodxsxVlyk+7eweyq76xX6xlovGPuDkMOjxd5fSSPoWk4XrCmd+rLKy57tqTPuh3QfIeejjJVtBgvFAgAdeE5LO5zaTxOzfF4e6xI1fYoVF1OztbHuF/ui/Rqr4mcfBFCiHOTZGQVI3NV3puukI1FmCxWSJk602WHQ6eKTJYsVMCMqGzJxkmbBm+NFYioMJCN0pWO0RmPEIZgOwG5bIyetMnVfWl296cpWe6SysbJ2Qqvnpzn5GyV3kyUIAg4OVs564CwsyUJ0yWb10fynJ6vNT93tnfqUE++Xjw2y6n5KtcNZLC8gIrjs6M7uabtgMXHjcfyteYU1obGtsKx6TLpmEFPOsZPj89yeKJE0fIv+0V6tddETr4IIcS5STKygjAMma84zFcc9IWJp/XL0xQMXaVYddmai+N6AX4QkIppbM3F8P2QK3vSWK7Pm6NFqk5AIqpzOl8jlzKXNKkubngsVB0mijZ+GHJkooTnhxgRjbmKe0GTQMu2h64qdC2czDEXTUVd6Z06wAtHZ3jjdIGpks10yWbvYPa8Tmms9YRH49+dmCkDq9+Qe7lZ7TWRky9CCHFu8ptxBdMlm6LlEtHg1FyF/myMzoRRP+abNknM6syXHdLxCIZev9Pk2i0dHJ+ucM1AmiBUsFyPuKGSjsbQVZud3UuP5i5ueJwp1wjDkP50/d/2pg0Spn7B76aTpk5u4RhuLKItmYq60jv1xscHMlEyC6dolo9VX8s497Wc8Gj8u0xMJzlXXfWG3I1CTr4IIcS5bcwV4CKVbY9kNMJNw5389PgscVPD8nz6M1H8IKRSs5muOFzbn0FdaF5UqVdNClWXXNLkip4kXhBStj2Gu+JcP5hdMpp8ccPj22MhiqoQN3SGu+rNpuMF+6zvps+WHNQXwOyKn1vtnXp9iJgN1IeIDecSS5KNc/U+rPWER7OvJWUynEts+EVaTr4IIcS5STKygsaR1cmqRTZusmdLFssNGMtb/HysRL4WMJ63MdQKfVmTvmyMlKkz0BGlL22Sjhl0JQ26U9E1XUffmTDYM5hpDgqrf61z1oX6bMnB2RbA1d6przZErKHVvQ+ySAshhGiQZGQFq20lVGwPxw/oy5iMzFVIxVTS0QgjM5XmTI8re5LNpOBsi+25Bput16VuqyUBK80aWVx9sVy/fpxZeh82JDmCLIRoJ1lRFln8CzlhaGzteH9xHuqMM1O2+dnpAkcmyvghlO2AQs2lbPtEdJ3JUoXhXHzFAWLLXWxl4INojFxafYEtHbEzxryvt1YvkhfyeJthoZYjyEKIdpJkZJHFv5CXjy1XFIXd/Wk+sr1GXFeImRGqtkvc0PCDgJLlkq86zFeddR0F3vBBNEYur75EIxo7upMt/z5n0+pF8kIebzMs1HIEWQjRTmcfybnJLP6FXLY9KrZHfybKXNnh8HiRmbLD3sEMnckoo/NVyo5PLKIRN3VmyjYRVeXUbI1XTswxVbQIw/CM7xGGIVNFi2PT5VX/zVo0Kis7upMr3pLbCpfCsdTFr4m/0BD8QT9eq2O4FF0Kr7UQYvOS3ziLLP6FXL8cj/pFePNVQkJcP6Q/Y2J7PgHQkTCImzq5pEkmapCNGxyZKPL2eJFCzVvxHfTl9C77UjiW2upF8kIebzMs1JfCay2E2Lw23m/Vi7DS3S5vjhZIx3R60yYn56pUbJfOhImha0yXbPwAruxNMZa3GJ2vEgKZqM474wUqtstHduSWVC4up3L4pXDipdWL5IU83mZYqC+F11oIsXlJMrLI4rtdfj6a5/tvjDOWr1GouRyZKNGdMvH9kFRUIwhCohGV4Vycq3uTdCVNMjGdIAx5/VSeqZLDdMXG9QOuGXj/2G798rkP7rr5D8J6Nni2epG8kMeThVoIIdaXJCPLhAuXzD3x0givnpwnDMDxQ1w/YHtXgrmKTRAYmLpCJhan5vjMVtzmIC+AuYpDytRRFJW3x4pMlSxyiSiuH3DDUJb+TPQDu25+vYWLLuVbyyV/QgghxHKSjCwzXbJ57eQ8kwULxw0wIipxTcULQn56bJaYoaOpFYY643xkZ5KJfI3D40WA5lTR4VyVN0cLTJWqmDqUHA/HC7HcAEVRuKo3SXcqSn8myjvjpSVffyEVhXYePW38vE7P1+hadimfEEIIsRaSjCxTtj0MTWN7d5JTc1XKtkfK1IkZKumozlV9GV45PssvJktMFCyiEQ0FBdcP2TuYoTtl8vEru4hoCqfmqwxmY/z0+BzjhRp9mRjzFZv5ioECvHRsjhOzZYZrCRwv4PqtF1ZRaGdTbOPn1b1wKV9sYTtKCCGEWCtZNZZJGBphGGA5HumojrLwsYrtoyoq704WURSFvkyUubJLoeYyVapRsjy25WL0pKP0ZmLs29lF/FSeuYpDR9yg6njMVxySUZ2i5dKXiVF2XBRFQVEV5isuJcsFOO8KR6MptlWVlvORNHU6EhEATF1dcimfEEIIsRaSjKxgsmwxMm9RcXzKrk/M1Jkp2LheSDxSrwLEDR1S8ObpKj85NkdnwmDXQIqdPfUtk5LlEjM03GLAUC7OXNkmAPZsyVJz/YXkIUYyGmGmZBPVVSzX5/WRPBXbI2HqfPzKrjVNc20cPV1+DPmDqJB0p0yu37rypXxCCCHEWkgyskzF8XG9kK6kiabA3FiR2bIFqGiaSmJhrkgqpuMXAzrjJnsGs+SrDq7nL2nmdH2fiKaxuz/NS8fmKDsuEwWLXLJ+kd50ycJyPTJxnRuGslRsj2MzFbIx47xGyzeOnh4eLxISsnsgzXjeWrF3o9X9JXLSRAghxMWSZGSZpKnTEY/w1liRUs0lG9fxghDbC5kt2wxkouyMR/jQ1iz5mgvM4Xg+2bhBRNeWNHOGQYhiqrwzXiJfc8jEIrh+wEA2Rmc8AiikzPoFe11Jk6rjL0RxflNZGwkBgOuHjOetVYdzXU5D14QQQmwOkows050yuWV7JzNlm7mKg+V6qCjkLY+R2QoTRYtc3uCagQw7uhLEDR3X84noGq7nYzkBXUmDmZLNYEeMG4ayTJfsJRWLaESj6gakohGu7kszlq9RcXyGOuPs7E5Qtj12JhMMdcbPO/bGcK5670vIsenykgrIpT50baNcSrdRnocQQnwQzvtumueff567776bgYEBFEXh6aefPufX/PjHP+bGG2/ENE2uuOIKHn/88QsI9YOhKApxM8K2XIoretNous58zUNT4KreFEOdCSzP583TeY5OVbDcgN5MDMsNmCo55C0HQoXBjhg3Dnewuz/N7v40uaTJ2HyNkuUyW7axXB9NZcmI8Z50lI9f2d38c74Vi8X31SiKwpujRX4xWeaN0wWmSzZw6Y82b1Rulsd9qVt+59BU0bosn4cQQrTDea9ElUqF66+/ngMHDvDJT37ynP/++PHj3HXXXXz2s5/lu9/9Ls8++yy///u/T39/P3fccccFBb3eEobGbMXi8EQRQoW4oeH4AZqqUXU9ooHKeMFiIBtnsmhRthwsL4AQ/CCkKxVh386u5hj4RsXi5GyFiuMxW3HIV122dMSak1kb75xb1X+xWgXkUh9t3o7KTSuqGMu3vzIx/ZKuQAkhxKXkvJORO++8kzvvvHPN//5b3/oW27dv52tf+xoAu3fv5oUXXuBv/uZvLtlkBMDQNKq2R7Hms3drhu6kgaoo2H7AYEec10fmefHYLB0Jg4LlMDZvMV9zURWF7qTBbMWh4vjNxa0nHaVse8xV3OYCFY1o7OhOnndsa1k8V6uAXOoNpxdaubmYhKIVfTTLkyjgkq5ACSHEpWTdf0P+5Cc/4fbbb1/ysTvuuIP77rtvvb/1BWskEdu7krw9XmS2ZHN1b4prt2QYna8xV3GIaAoxQ+OWbR2cmCmTj2hkYgamrlJxPF47OU9kYXLrDUNZdven17zQnmthXcviealXQFZzoXFfTELRimrM8td2qDPe7NG5nH7+QgjRDuuejExMTNDb27vkY729vRSLRWq1GrHYmUdXbdvGtt/fYy8Wi+sd5hJJU8cNAlRV5cPbO9FVhW1dCXb1pQCYKtn0ZuIUqg5TRYdUzGCwU2Gm4uCFITFVo+YFWF7ATMkmDOtHhde60J5tYQ3DkJOzFUbzVbblEtRcf8XF81KvgKzmQuO+mISiFX00K722iqJcdj9/IYRoh0uydvzQQw/xxS9+sW3fvztlcuNwB4qiNC9/G84lUFWVaESjK2nSn41yeKxIb8ZkV1+KMAw5NV8vz8cNjddH8pyer9GdMjE0rb44pqNrWmjPtrBOl2xOzlaZLNpMFm12didImvqmP71xMQlFK6pIl2vyJ4QQl4J1T0b6+vqYnJxc8rHJyUnS6fSKVRGABx54gPvvv7/592KxyNatW9c1zuVyCYOreuv9HEOd8eYC1Vj0xvMWuaTJ7v50s2rRl60fxQ3DsJkIGJpGRyJyXotj0tRRFTg8VsTxfbZ2xpqP2Vgwb92e48RMuRnbZp8fcjEJhSQSQgjRXuuejOzbt4//+q//WvKxZ555hn379q36NaZpYprt22OfLtm8OVpsLuyKojSTi7UseoqisLs/TVfSvKDFsTtlsqUjxlTJJqKpjOVrdCXrTbBJU0fX6qPjt3TEGc4lVpwfcqH33FyuJKEQQojL13knI+VymaNHjzb/fvz4cQ4dOkRnZydDQ0M88MADjI6O8g//8A8AfPazn+Xhhx/mj//4jzlw4AA/+tGPePLJJ/n+97/fumfRYmfbJlm+6DXmSyxf9C9mcVQUpbkdtDy5KFkuA9kopq6SikbOqNg0tilsL+D4Jq6UCCGEuHycdzLyyiuv8Cu/8ivNvze2U/bv38/jjz/O+Pg4IyMjzc9v376d73//+3z+85/n61//OoODg3znO9+5pI/1rtR/sFpPxrm2R87Wy3G2z51vcrG8YlOyXJlzIYQQ4rKghGF4fhehtEGxWCSTyVAoFEin0+v+/VZKEhYnHapCc2DZbNlmtuywpSPOWL7Glb1JdnQnm49xcrbCyFyVhKmjq+qSJKIxpXO1UzOLYyhZLkenKgxkY4zmq+QSBrmkueoWzNkeWwghhPggrHX9viRP07TbSlssi7du3h4rcHS6RNzQCcKQpBE54xRHI3kZna8yWbK5dXsnlhssqVCcz3YQvD9Eq2J7lK36ALWNNmdECCHE5iPJyBot3jaZKVmcmK/SGTOwPJ+P7sxxZW9yyaLfSDS2dSWZLNmcmK2wJRtfcqrmfI6jLk4uGtWYs23BSEOnEEKIy4UkI2u0OBkoVB3eGi/i+1BzfRSU5lj3RkPrbNmmZLkEQcCOrgTDufrJl8UViq6kwUA2ynTJpitpEATBGbfsNixOLpKmTqHmyahxIYQQG4KsYmu0OBmYKVn0jJtEdQ3L88nG9OaJGsv1GZ2v4YchigJdKZObFpKQ5X0dM2WHsbyFH4S8M1EiDCEVjSzZelmpf0W2YIQQQmwkkoxcgOFcgr2DWUqWSxjCfM1l5N1pkqbObMUhoqrsHkgzlq+RW5gPspLFPSOvjdQghKv70ku2XlY7rSNbMEIIITYKtd0BXI66U/XJqx1xAxQYz1scm6kQM3R0VcHxfUbnq5Qsl9myzVTRYqVDS4t7RpKmTsLUGc1XKdvvf93iI7p+EFK2vTY8YyGEEGL9SGVkjRZvl1iuz1i+Rr7qMl1yuLovxVTZ5s3T82TjBtu6EhiaQsXxmK04FGreOU+8JAwNgJG5KmXLY7Zc/7qBbFSuohdCCLGhycq2Rou3S6ZLFrqqkI0bHJkocmpWpTtpYrk+hqZRc3yMmI7n16shjWbW5cnISideKo7PXMVtnpQxdZU9W9KMzFWBelLUuKdms1+OJ4QQYmOQZGSNFvd3FKous9X6cV03CMlXHXpSUfrSUQY7E82qyen5GsdnKkQ0lT2DmTV9n+XHfVPRCACFWv37F2pF9i4kMZv9cjwhhBAbgyQja7Q4SehIRMgmdN6dDIhGNGpuwGzVRl2URKSjOoMdMULqp2/KlrvkNt/VrHRS5vhMZcXhaGcbmiaEEEJcLiQZWaPlSUJ9nojN6fka3SmTpKkxnIs3R7SHYcjIXI1jMxUATs3X2NZlr+nemuVbN6sNRzufoWlCCCHEpUpWrzVaniQEQcC2rgQzZZswCMklDIZziSV3ywzn4lQcj225BDXXP6Ny0dhm8YKAiu0x1Pn+YLTFFZTV5orIvBEhhBAbgSQjF2i6ZDNRqKGrCvmaQxDGljSXKorCcC5BoeZhuQG6qp5RuWhss8QiGm+cLlC2vBVP3qw22l1GvgshhNgIJBm5QCNzVY7NVNEVheMzFWIRDU3Vms2lcO7KRWOb5cRsBQjJxiOM5qtkYnIyRgghxOYhycgaLO/t6EoazFcc8lUbVVEIQuhORZtDyc528+5ijWQlE9MJFkbCK4pCwqgu2fIRQgghNjJJRtZg+RHagWyUQs0lomkUqg7ZeIQwDM+7ibSRrDQqJm+PFsgmTOYrNidnK1IdEUIIsSlIMrIGy4/QTpdsUtEIv7qrl+PTJQayMXb2JElFIxfURNroLzk5W+XIZAmA1JxUR4QQQmwOkoyswfIjtN0pk7G8heX6DHYmWjJsrDtlnvP0jRBCCLERSTKyBssbUbuSBl1Js6VHatdy+kYIIYTYiGS1W4OVGlHX40itzA0RQgixGUkycgmRuSFCCCE2I7XdAQghhBBic5NkRAghhBBtJcmIEEIIIdpKkhEhhBBCtJUkI0IIIYRoK0lGhBBCCNFWkowIIYQQoq0kGRFCCCFEW0kyIoQQQoi2kmRECCGEEG0lyYgQQggh2kqSESGEEEK01WVxUV4YhgAUi8U2RyKEEEKItWqs2411fDWXRTJSKpUA2Lp1a5sjEUIIIcT5KpVKZDKZVT+vhOdKVy4BQRAwNjZGKpVCUZSWPW6xWGTr1q2cOnWKdDrdsse9VGz05wcb/znK87u8yfO7vMnzu3hhGFIqlRgYGEBVV+8MuSwqI6qqMjg4uG6Pn06nN+R/aA0b/fnBxn+O8vwub/L8Lm/y/C7O2SoiDdLAKoQQQoi2kmRECCGEEG21qZMR0zR58MEHMU2z3aGsi43+/GDjP0d5fpc3eX6XN3l+H5zLooFVCCGEEBvXpq6MCCGEEKL9JBkRQgghRFtJMiKEEEKItpJkRAghhBBttamTkW9+85ts27aNaDTKrbfeyksvvdTukFrm+eef5+6772ZgYABFUXj66afbHVLLPPTQQ3z4wx8mlUrR09PDb/zGb3DkyJF2h9UyjzzyCHv37m0OItq3bx8/+MEP2h3WuvnKV76Coijcd9997Q6lZf7iL/4CRVGW/Nm1a1e7w2qp0dFRfvu3f5tcLkcsFmPPnj288sor7Q6rJbZt23bG66coCgcPHmx3aC3h+z5//ud/zvbt24nFYuzcuZO//Mu/POf9Metp0yYj//RP/8T999/Pgw8+yGuvvcb111/PHXfcwdTUVLtDa4lKpcL111/PN7/5zXaH0nLPPfccBw8e5MUXX+SZZ57BdV1+7dd+jUql0u7QWmJwcJCvfOUrvPrqq7zyyiv86q/+Kr/+67/Oz3/+83aH1nIvv/wy3/72t9m7d2+7Q2m5a6+9lvHx8eafF154od0htcz8/Dy33XYbkUiEH/zgB7z99tt87Wtfo6Ojo92htcTLL7+85LV75plnAPjUpz7V5sha46tf/SqPPPIIDz/8MIcPH+arX/0qf/VXf8U3vvGN9gUVblK33HJLePDgwebffd8PBwYGwoceeqiNUa0PIHzqqafaHca6mZqaCoHwueeea3co66ajoyP8zne+0+4wWqpUKoVXXnll+Mwzz4S//Mu/HN57773tDqllHnzwwfD6669vdxjr5k/+5E/Cj33sY+0O4wNz7733hjt37gyDIGh3KC1x1113hQcOHFjysU9+8pPhPffc06aIwnBTVkYcx+HVV1/l9ttvb35MVVVuv/12fvKTn7QxMnEhCoUCAJ2dnW2OpPV83+d73/selUqFffv2tTucljp48CB33XXXkv8PN5Jf/OIXDAwMsGPHDu655x5GRkbaHVLL/Pu//zs333wzn/rUp+jp6eGGG27g7/7u79od1rpwHId//Md/5MCBAy29qLWdPvrRj/Lss8/y7rvvAvCzn/2MF154gTvvvLNtMV0WF+W12szMDL7v09vbu+Tjvb29vPPOO22KSlyIIAi47777uO2227juuuvaHU7LvPnmm+zbtw/Lskgmkzz11FNcc8017Q6rZb73ve/x2muv8fLLL7c7lHVx66238vjjj3P11VczPj7OF7/4RT7+8Y/z1ltvkUql2h3eRTt27BiPPPII999/P3/6p3/Kyy+/zB/8wR9gGAb79+9vd3gt9fTTT5PP5/md3/mddofSMl/4whcoFovs2rULTdPwfZ8vfelL3HPPPW2LaVMmI2LjOHjwIG+99daG2o8HuPrqqzl06BCFQoF//ud/Zv/+/Tz33HMbIiE5deoU9957L8888wzRaLTd4ayLxe8w9+7dy6233srw8DBPPvkkv/d7v9fGyFojCAJuvvlmvvzlLwNwww038NZbb/Gtb31rwyUjf//3f8+dd97JwMBAu0NpmSeffJLvfve7PPHEE1x77bUcOnSI++67j4GBgba9fpsyGenq6kLTNCYnJ5d8fHJykr6+vjZFJc7X5z73Of7zP/+T559/nsHBwXaH01KGYXDFFVcAcNNNN/Hyyy/z9a9/nW9/+9ttjuzivfrqq0xNTXHjjTc2P+b7Ps8//zwPP/wwtm2jaVobI2y9bDbLVVddxdGjR9sdSkv09/efkRjv3r2bf/mXf2lTROvj5MmT/M///A//+q//2u5QWuqP/uiP+MIXvsBv/uZvArBnzx5OnjzJQw891LZkZFP2jBiGwU033cSzzz7b/FgQBDz77LMbbl9+IwrDkM997nM89dRT/OhHP2L79u3tDmndBUGAbdvtDqMlPvGJT/Dmm29y6NCh5p+bb76Ze+65h0OHDm24RASgXC7z3nvv0d/f3+5QWuK222474zj9u+++y/DwcJsiWh+PPfYYPT093HXXXe0OpaWq1SqqunT51zSNIAjaFNEmrYwA3H///ezfv5+bb76ZW265hb/927+lUqnwu7/7u+0OrSXK5fKSd2HHjx/n0KFDdHZ2MjQ01MbILt7Bgwd54okn+Ld/+zdSqRQTExMAZDIZYrFYm6O7eA888AB33nknQ0NDlEolnnjiCX784x/zwx/+sN2htUQqlTqjvyeRSJDL5TZM388f/uEfcvfddzM8PMzY2BgPPvggmqbxW7/1W+0OrSU+//nP89GPfpQvf/nLfPrTn+all17i0Ucf5dFHH213aC0TBAGPPfYY+/fvR9c31lJ5991386UvfYmhoSGuvfZaXn/9df76r/+aAwcOtC+otp3juQR84xvfCIeGhkLDMMJbbrklfPHFF9sdUsv87//+bwic8Wf//v3tDu2irfS8gPCxxx5rd2gtceDAgXB4eDg0DCPs7u4OP/GJT4T//d//3e6w1tVGO9r7mc98Juzv7w8Nwwi3bNkSfuYznwmPHj3a7rBa6j/+4z/C6667LjRNM9y1a1f46KOPtjuklvrhD38YAuGRI0faHUrLFYvF8N577w2HhobCaDQa7tixI/yzP/uz0LbttsWkhGEbR64JIYQQYtPblD0jQgghhLh0SDIihBBCiLaSZEQIIYQQbSXJiBBCCCHaSpIRIYQQQrSVJCNCCCGEaCtJRoQQQgjRVpKMCCGEEKKtJBkRQgghRFtJMiKEEEKItpJkRAghhBBtJcmIEEIIIdrq/wHRnL6tjOlowAAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -650,12 +585,11 @@ ], "source": [ "# similarly, for bruise\n", - "local_symptom_data[\"search_trends_bruise\"] = \\\n", - " local_symptom_data[\"search_trends_bruise\"].astype(float)\n", "sns.regplot(\n", - " x=\"new_confirmed\",\n", + " x=\"new_cases_percent_of_pop\",\n", " y=\"search_trends_bruise\",\n", - " data=local_symptom_data\n", + " data=weekly_data,\n", + " scatter_kws={'alpha': 0.2, \"s\" :5}\n", ")" ] }, @@ -681,7 +615,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We used matplotlib to draw a line graph of COVID-19 cases over time in the USA. Then, we used downsampling to download only a portion of the available data, and used seaborn locally to plot lines of best fit to observe corellation between COVID-19 cases and searches for related vs. unrelated symptoms.\n", + "We used matplotlib to draw a line graph of COVID-19 cases over time in the USA. Then, we used downsampling to download only a portion of the available data, used seaborn to plot lines of best fit to observe corellation between COVID-19 cases and searches for related versus unrelated symptoms.\n", "\n", "Thank you for using BigQuery DataFrames!" ] @@ -692,7 +626,8 @@ "provenance": [] }, "kernelspec": { - "display_name": "Python 3", + "display_name": "venv", + "language": "python", "name": "python3" }, "language_info": { @@ -705,7 +640,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.1" + "version": "3.12.6" } }, "nbformat": 4, diff --git a/notebooks/visualization/tutorial.ipynb b/notebooks/visualization/tutorial.ipynb new file mode 100644 index 0000000000..ab838a89f0 --- /dev/null +++ b/notebooks/visualization/tutorial.ipynb @@ -0,0 +1,1448 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "b11d1db5", + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "id": "e661697d", + "metadata": {}, + "source": [ + "## BigQuery DataFrame Visualization Tutorials\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "5e93c4c1", + "metadata": {}, + "source": [ + "This notebook provides tutorials for all plotting methods that BigQuery DataFrame offers. You will visualize different datasets with histograms, line charts, area charts, bar charts, and scatter plots." + ] + }, + { + "cell_type": "markdown", + "id": "f96c47f7", + "metadata": {}, + "source": [ + "# Before you begin" + ] + }, + { + "cell_type": "markdown", + "id": "a8dd598a", + "metadata": {}, + "source": [ + "## Set up your project ID and region" + ] + }, + { + "cell_type": "markdown", + "id": "d442ab74", + "metadata": {}, + "source": [ + "This step makes sure that you will access the target dataset with the correct auth profile." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "7cc6237d", + "metadata": {}, + "outputs": [], + "source": [ + "PROJECT_ID = \"bigframes-dev\" # @param {type:\"string\"}\n", + "REGION = \"US\" # @param {type: \"string\"}" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "bf96593a", + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "\n", + "bpd.options.bigquery.project = PROJECT_ID\n", + "bpd.options.bigquery.location = REGION" + ] + }, + { + "cell_type": "markdown", + "id": "165fedc6", + "metadata": {}, + "source": [ + "You can also turn on the partial ordering mode for faster data processing." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "ac5a1722", + "metadata": {}, + "outputs": [], + "source": [ + "bpd.options.bigquery.ordering_mode = 'partial'" + ] + }, + { + "cell_type": "markdown", + "id": "2ed45ca7", + "metadata": {}, + "source": [ + "# Histogram" + ] + }, + { + "cell_type": "markdown", + "id": "88837be7", + "metadata": {}, + "source": [ + "You will use the penguins public dataset in this example. First, you take a look at the shape of this data:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "fb595a8f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
speciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
0Adelie Penguin (Pygoscelis adeliae)Dream36.618.4184.03475.0FEMALE
1Adelie Penguin (Pygoscelis adeliae)Dream39.819.1184.04650.0MALE
2Adelie Penguin (Pygoscelis adeliae)Dream40.918.9184.03900.0MALE
3Chinstrap penguin (Pygoscelis antarctica)Dream46.517.9192.03500.0FEMALE
4Adelie Penguin (Pygoscelis adeliae)Dream37.316.8192.03000.0FEMALE
\n", + "
" + ], + "text/plain": [ + " species island culmen_length_mm \\\n", + "0 Adelie Penguin (Pygoscelis adeliae) Dream 36.6 \n", + "1 Adelie Penguin (Pygoscelis adeliae) Dream 39.8 \n", + "2 Adelie Penguin (Pygoscelis adeliae) Dream 40.9 \n", + "3 Chinstrap penguin (Pygoscelis antarctica) Dream 46.5 \n", + "4 Adelie Penguin (Pygoscelis adeliae) Dream 37.3 \n", + "\n", + " culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "0 18.4 184.0 3475.0 FEMALE \n", + "1 19.1 184.0 4650.0 MALE \n", + "2 18.9 184.0 3900.0 MALE \n", + "3 17.9 192.0 3500.0 FEMALE \n", + "4 16.8 192.0 3000.0 FEMALE " + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "penguins = bpd.read_gbq('bigquery-public-data.ml_datasets.penguins')\n", + "penguins.peek()" + ] + }, + { + "cell_type": "markdown", + "id": "176c12f8", + "metadata": {}, + "source": [ + "You want to draw a histogram about the distribution of culmen lengths:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "333e88a3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjIAAAGdCAYAAAAIbpn/AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAJvBJREFUeJzt3Xt0lPWBxvFnyOQKuRggmaQEgiQBEQFFiqzBglAusqwEquAFCNLuosGK4SJovaDYIApilUpPDybQHsXSBUQpeOESagE5oEDZpVxiQkACQTQJCZKEzOwfHmYdE0IymcnML3w/58w5vO+8875P8gby8Ht/M6/F4XA4BAAAYKBWvg4AAADgLooMAAAwFkUGAAAYiyIDAACMRZEBAADGosgAAABjUWQAAICxKDIAAMBYVl8H8Da73a5Tp04pPDxcFovF13EAAEADOBwOnT9/XvHx8WrV6srjLi2+yJw6dUoJCQm+jgEAANxw4sQJdejQ4YrPt/giEx4eLun7b0RERISP0wAAgIYoKytTQkKC8/f4lbT4InP5clJERARFBgAAw1xtWgiTfQEAgLEoMgAAwFgUGQAAYKwWP0cGAFoKh8OhS5cuqaamxtdRgCYLCAiQ1Wpt8kejUGQAwABVVVUqKirShQsXfB0F8JiwsDDFxcUpKCjI7X1QZADAz9ntduXn5ysgIEDx8fEKCgriAz5hNIfDoaqqKp09e1b5+flKTk6u90Pv6kORAQA/V1VVJbvdroSEBIWFhfk6DuARoaGhCgwM1PHjx1VVVaWQkBC39sNkXwAwhLv/YwX8lSd+pvlbAQAAjEWRAQAAxmKODAAYLHHOhmY9XsGCkc16vJycHE2fPl0lJSXNelxPGDhwoHr37q0lS5Z4/VgWi0Vr167V6NGjvX4sf8OIDAAAhnjuuefUu3dvX8fwKxQZAABgLIoMAMCr7Ha7Fi5cqKSkJAUHB6tjx4568cUXtW3bNlksFpfLRvv27ZPFYlFBQUGd+7o8IvHWW2+pY8eOatOmjR555BHV1NRo4cKFstlsiomJ0YsvvujyupKSEv3yl79U+/btFRERoTvvvFP79++vtd8//elPSkxMVGRkpMaPH6/z58836GusqKjQxIkT1aZNG8XFxWnRokW1tqmsrNTMmTP1k5/8RK1bt1a/fv20bds25/M5OTmKiorSunXrlJycrJCQEA0bNkwnTpxwPj9v3jzt379fFotFFotFOTk5ztd//fXXSktLU1hYmJKTk7V+/foGZb98Hj788EPdfPPNCg0N1Z133qni4mJt3LhRN9xwgyIiInT//fe7fCDjwIED9eijj2r69Om67rrrFBsbqz/+8Y+qqKjQ5MmTFR4erqSkJG3cuLFBOdzFHBkAfqcx8z6ae84GGm/u3Ln64x//qFdffVWpqakqKirSv/71L7f3l5eXp40bN2rTpk3Ky8vTL37xC3355ZdKSUlRbm6uduzYoYceekhDhgxRv379JEn33HOPQkNDtXHjRkVGRuoPf/iDBg8erCNHjig6Otq533Xr1umDDz7Qt99+q3vvvVcLFiyoVYrqMmvWLOXm5uq9995TTEyMnnzySX3++ecul4GmTZum//3f/9WqVasUHx+vtWvXavjw4frnP/+p5ORkSdKFCxf04osvauXKlQoKCtIjjzyi8ePH6x//+IfGjRungwcPatOmTfrkk08kSZGRkc79z5s3TwsXLtTLL7+s119/XQ888ICOHz/u/Pqu5rnnntMbb7yhsLAw3Xvvvbr33nsVHByst99+W+Xl5UpLS9Prr7+uJ554wvmaFStWaPbs2dq9e7feffddPfzww1q7dq3S0tL05JNP6tVXX9WECRNUWFjotc9AYkQGAOA158+f12uvvaaFCxdq0qRJ6tKli1JTU/XLX/7S7X3a7Xa99dZb6t69u0aNGqVBgwbp8OHDWrJkibp27arJkyera9eu2rp1qyTp008/1e7du7V69WrdeuutSk5O1iuvvKKoqCj99a9/ddlvTk6OevTooQEDBmjChAnavHnzVfOUl5dr+fLleuWVVzR48GDddNNNWrFihS5duuTcprCwUNnZ2Vq9erUGDBigLl26aObMmUpNTVV2drZzu+rqar3xxhvq37+/+vTpoxUrVmjHjh3avXu3QkND1aZNG1mtVtlsNtlsNoWGhjpfm56ervvuu09JSUn67W9/q/Lycu3evbvB39f58+fr9ttv180336wpU6YoNzdXb775pm6++WYNGDBAv/jFL5zf08t69eql3/zmN0pOTtbcuXMVEhKidu3a6Ve/+pWSk5P1zDPP6Ny5czpw4ECDczQWIzIAAK85dOiQKisrNXjwYI/tMzExUeHh4c7l2NhYBQQEuHy4WmxsrIqLiyVJ+/fvV3l5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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "penguins['culmen_depth_mm'].plot.hist(bins=40)" + ] + }, + { + "cell_type": "markdown", + "id": "9e0aa359", + "metadata": {}, + "source": [ + "# Line Chart" + ] + }, + { + "cell_type": "markdown", + "id": "b0f37913", + "metadata": {}, + "source": [ + "In this example you will use the NOAA public dataset." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "49ed2417", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
stnwbandateyearmodatempcount_tempdewpcount_dewp...flag_minprcpflag_prcpsndpfograin_drizzlesnow_ice_pelletshailthundertornado_funnel_cloud
0010030999992021-11-102021111026.4417.94...<NA>0.0I999.9000000
1010030999992021-02-01202102018.940.54...<NA>2.76G999.9000000
2010060999992021-07-222021072234.449999.90...<NA>0.0I999.9000000
3010070999992021-04-052021040517.946.44...<NA>0.0I999.9000000
4010070999992021-02-042021020419.149999.90...<NA>0.0I999.9000000
\n", + "

5 rows × 33 columns

\n", + "
" + ], + "text/plain": [ + " stn wban date year mo da temp count_temp dewp \\\n", + "0 010030 99999 2021-11-10 2021 11 10 26.4 4 17.9 \n", + "1 010030 99999 2021-02-01 2021 02 01 8.9 4 0.5 \n", + "2 010060 99999 2021-07-22 2021 07 22 34.4 4 9999.9 \n", + "3 010070 99999 2021-04-05 2021 04 05 17.9 4 6.4 \n", + "4 010070 99999 2021-02-04 2021 02 04 19.1 4 9999.9 \n", + "\n", + " count_dewp ... flag_min prcp flag_prcp sndp fog rain_drizzle \\\n", + "0 4 ... 0.0 I 999.9 0 0 \n", + "1 4 ... 2.76 G 999.9 0 0 \n", + "2 0 ... 0.0 I 999.9 0 0 \n", + "3 4 ... 0.0 I 999.9 0 0 \n", + "4 0 ... 0.0 I 999.9 0 0 \n", + "\n", + " snow_ice_pellets hail thunder tornado_funnel_cloud \n", + "0 0 0 0 0 \n", + "1 0 0 0 0 \n", + "2 0 0 0 0 \n", + "3 0 0 0 0 \n", + "4 0 0 0 0 \n", + "\n", + "[5 rows x 33 columns]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "noaa_surface = bpd.read_gbq(\"bigquery-public-data.noaa_gsod.gsod2021\")\n", + "noaa_surface.peek()" + ] + }, + { + "cell_type": "markdown", + "id": "239ec3d1", + "metadata": {}, + "source": [ + "You are going to plot a line chart of temperatures by date. The original dataset contains many rows for a single date, and you wan to coalesce them with their median values." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "e06afd00", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Query job a2cee421-0f51-49a8-918a-68177b3199dc is DONE. 64.4 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
temp
date
2021-02-1224.6
2021-02-1125.9
2021-02-1330.4
2021-02-1432.1
2021-01-0932.9
\n", + "
" + ], + "text/plain": [ + " temp\n", + "date \n", + "2021-02-12 24.6\n", + "2021-02-11 25.9\n", + "2021-02-13 30.4\n", + "2021-02-14 32.1\n", + "2021-01-09 32.9" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "noaa_surface_median_temps=noaa_surface[['date', 'temp']].groupby('date').median()\n", + "noaa_surface_median_temps.peek()" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "68324aaf", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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OKLpmik2bdokCoIglpWVBfQcwQj36+UN41z8OpbXat26dWJMTIz49ttvBzy2rKws8emnn1Zvs1gso1L8Ou6Byd133y2azWbxo48+Euvq6tSv7u5u9Zi77rpLLCgoED/88ENx586dYmlpqVhaWqrev3//fjE9PV28+eabfc7R2Njo81x79uwR9+zZI5aUlIg33nijuGfPHvHgwYODju+1114TjUaj+NJLL4mHDh0S77zzTjEpKUmsr69Xj6mrqxP37NkjvvDCCyIAcevWreKePXvElpYWXiNRFCsqKsQnnnhC3Llzp3jy5Enx008/FZcvXy6mpKSIDQ0NI3KNvEX69Tp27Jj42GOPiTt37hQrKyvFf/7zn+KkSZPE8847bwSvkiTSr5Xi6NGjoiAI4rvvvjsCV2Vw0XDN/vznP4vbtm0Tjx07Jv71r38VU1JSxAceeGCErpCvcL9eVqtVfRwA8de//rW4Z88e8eTJk+oxLS0t4p49e8R33nlHBCC+9tpr4p49e8S6uroRukqSsbpWr7zyiqjT6cQ1a9b4HNPe3j7o+J588kkxKSlJ/Oc//ynu27dPvOqqq8Ti4mKxp6dHPebkyZPinj17xEcffVQ0mUzqtbVarQFfh3EPTAD4/fL+JN3T0yN+5zvfEZOTk8W4uDjxmmuu8fmDeOSRR/yeo7CwcMjn6nuMP7/73e/EgoIC0WAwiGeddZa4fft2n/sHev6RygZE+jU6deqUeNlll4kZGRmiXq8X8/LyxBtvvHFUPp0N9DtE0vWqqqoSzzvvPDElJUU0Go3ilClTxAcffNBneedIifRrpVi1apWYn58vulyuUC9FwKLhmv3gBz8QMzMzRb1eL06dOlX81a9+Jbrd7uFclgGF+/VSMt19v2699Vb1mLVr1/o95pFHHhn+BRpi/KNxrc4///whf2d/3G63+JOf/ETMzMwUjUajeNFFF4nl5eU+x9x6661+z7158+aAr4MgXwwiIiKicRdWq3KIiIhoYmNgQkRERGGDgQkRERGFDQYmREREFDYYmBAREVHYYGBCREREYYOBCREREYUNBiZEREQUNhiYENGIWbJkCe67777xHgYRRTAGJkQ0Lj766CMIgoD29vbxHgoRhREGJkRERBQ2GJgQUUi6urpwyy23wGQyITs7G7/61a987v/rX/+KM844AwkJCcjKysKNN96IxsZGAMCJEydwwQUXAACSk5MhCAJuu+02AIDb7cbq1atRXFyM2NhYzJs3D2+88caY/m5ENH4YmBBRSB588EFs2bIF//znP/Hee+/ho48+wu7du9X7HQ4HHn/8cezduxdvv/02Tpw4oQYf+fn5+Mc//gEAKC8vR11dHZ599lkAwOrVq/Hyyy/j+eefx8GDB3H//ffj5ptvxpYtW8b8dySiscfdhYkoaJ2dnUhNTcXf/vY3XHfddQCA1tZW5OXl4c4778RvfvObfo/ZuXMnzjzzTFitVphMJnz00Ue44IIL0NbWhqSkJACAzWZDSkoKPvjgA5SWlqqPveOOO9Dd3Y1169aNxa9HRONIN94DIKLIU1FRAbvdjoULF6q3paSkYPr06erPu3btwk9/+lPs3bsXbW1tcLvdAICqqirMmjXL73mPHTuG7u5uXHzxxT632+12zJ8/fxR+EyIKNwxMiGjEdXV1YdmyZVi2bBleeeUVpKeno6qqCsuWLYPdbh/wcZ2dnQCAd955B7m5uT73GY3GUR0zEYUHBiZEFLTJkydDr9fj888/R0FBAQCgra0NR44cwfnnn4+ysjK0tLTgySefRH5+PgBpKsebwWAAALhcLvW2WbNmwWg0oqqqCueff/4Y/TZEFE4YmBBR0EwmE26//XY8+OCDSE1NRUZGBn784x9Do5Hq6QsKCmAwGPC73/0Od911Fw4cOIDHH3/c5xyFhYUQBAH//ve/8ZWvfAWxsbFISEjA9773Pdx///1wu90455xzYLFY8OmnnyIxMRG33nrrePy6RDSGuCqHiELy1FNP4dxzz8Xy5cuxdOlSnHPOOSgpKQEApKen46WXXsL69esxa9YsPPnkk3j66ad9Hp+bm4tHH30UP/zhD5GZmYl77rkHAPD444/jJz/5CVavXo2ZM2fi0ksvxTvvvIPi4uIx/x2JaOxxVQ4RERGFDWZMiIiIKGwwMCEiIqKwwcCEiIiIwgYDEyIiIgobDEyIiIgobDAwISIiorDBwISIiIjCBgMTIiIiChsMTIiIiChsMDAhIiKisMHAhIiIiMLG/wc+Xu84OfM/pQAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "noaa_surface_median_temps.plot.line()" + ] + }, + { + "cell_type": "markdown", + "id": "5b1e75df", + "metadata": {}, + "source": [ + "# Area Chart" + ] + }, + { + "cell_type": "markdown", + "id": "544f5605", + "metadata": {}, + "source": [ + "In this example you will use the table that tracks the popularity of names in the USA." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "0c8f9726", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
stategenderyearnamenumber
0ALF1910Sadie40
1ALF1910Mary875
2ARF1910Vera39
3ARF1910Marie78
4ARF1910Lucille66
\n", + "
" + ], + "text/plain": [ + " state gender year name number\n", + "0 AL F 1910 Sadie 40\n", + "1 AL F 1910 Mary 875\n", + "2 AR F 1910 Vera 39\n", + "3 AR F 1910 Marie 78\n", + "4 AR F 1910 Lucille 66" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "usa_names = bpd.read_gbq(\"bigquery-public-data.usa_names.usa_1910_2013\")\n", + "usa_names.peek()" + ] + }, + { + "cell_type": "markdown", + "id": "be525493", + "metadata": {}, + "source": [ + "You want to visualize the trends of the popularities of three names in US history: Mary, Emily and Lisa." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "a12cd1f5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Query job d1fc606b-18b0-4e7e-a669-0e505a95aa5a is DONE. 132.6 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nameEmilyLisaMary
year
19271631070864
19182353067492
19121126032375
19232047071799
19331036055769
\n", + "
" + ], + "text/plain": [ + "name Emily Lisa Mary\n", + "year \n", + "1927 1631 0 70864\n", + "1918 2353 0 67492\n", + "1912 1126 0 32375\n", + "1923 2047 0 71799\n", + "1933 1036 0 55769" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "name_counts = usa_names[usa_names['name'].isin(('Mary', 'Emily', 'Lisa'))].groupby(('year', 'name'))['number'].sum()\n", + "name_counts = name_counts.unstack(level=1).fillna(0)\n", + "name_counts.peek()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "4af287bd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjkAAAGwCAYAAABLvHTgAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAsTxJREFUeJzs3Xt8XHWd+P/XOWfuk8nk0jTpvYUCbbm0UKBUhUXtUrXsVxZwvSAggits0YWuovzWB/LV/YqyCygrWEWhdYXlpiJSKJZCuZZboLRNr2nTJm3ul5nJXM/MOef3x3SGhl6TzC3J++kjD8nMZ875JG0z73w+78/7rViWZSGEEEIIMcqoxZ6AEEIIIUQ+SJAjhBBCiFFJghwhhBBCjEoS5AghhBBiVJIgRwghhBCjkgQ5QgghhBiVJMgRQgghxKhkK/YEisk0TVpbW/H5fCiKUuzpCCGEEOI4WJZFf38/EydORFWPvF4zpoOc1tZWpkyZUuxpCCGEEGIIWlpamDx58hGfH9NBjs/nA9LfpPLy8iLPRgghhBDHIxQKMWXKlOz7+JGM6SAns0VVXl4uQY4QQggxwhwr1UQSj4UQQggxKkmQI4QQQohRSYIcIYQQQoxKYzonRwghhBguwzBIJpPFnsaoYrfb0TRt2NeRIEcIIYQYAsuyaG9vJxAIFHsqo1JFRQV1dXXDqmMnQY4QQggxBJkAZ/z48Xg8HikqmyOWZRGNRuns7ARgwoQJQ76WBDlCCCHEIBmGkQ1wqquriz2dUcftdgPQ2dnJ+PHjh7x1JYnHQgghxCBlcnA8Hk+RZzJ6Zb63w8l3kiBHCCGEGCLZosqfXHxvJcgRQgghxKgkQY4QQgghRiUJcoQQQggxKkmQI4QQQohRSYIccYiEkaAr2lXsaQghhBDDIkGOOMSLzS/ywKYH+KDrg2JPRQghxrwLL7yQb3/729xyyy1UVVVRV1fH7bffnn3+7rvv5vTTT8fr9TJlyhT+5V/+hXA4nH1+xYoVVFRU8Mwzz3DKKafg8Xi4/PLLiUajrFy5kunTp1NZWcm3v/1tDMPIvi6RSPCd73yHSZMm4fV6WbBgAevWrSvgVz58EuSIARJGgl2BXWzp3sL/bv1fTMs8rteZlkl3rBvLsvI8QyGEGHtWrlyJ1+vlrbfe4s477+RHP/oRa9asAUBVVe69914aGhpYuXIlL774IrfccsuA10ejUe69914effRRVq9ezbp16/jHf/xHnn32WZ599ln+53/+h1//+tc8+eST2dfceOONrF+/nkcffZSNGzfyhS98gc985jPs3LmzoF/7cCjWGH5XCoVC+P1+gsEg5eXlxZ5OSdjZt5PHtj9GfXs9NtXGTfNv4uOTPn7M172671XebHuTC6dcyDl15xRgpkIIUTzxeJympiZmzJiBy+XK670uvPBCDMPg1VdfzT527rnn8qlPfYqf/vSnh4x/8sknuf766+nu7gbSKznXXHMNjY2NnHjiiQBcf/31/M///A8dHR2UlZUB8JnPfIbp06ezfPlympubOeGEE2hubmbixInZay9atIhzzz2Xn/zkJ/n8koGjf4+P9/1b2jqIAZqCTfTF+0BJr+o8vetpFk5ciKocedEvrIfZ1LWJhu4GQomQBDlCCJFjZ5xxxoDPJ0yYkO3t9MILL3DHHXewbds2QqEQqVSKeDxONBrNVg32eDzZAAegtraW6dOnZwOczGOZa27atAnDMDj55JMH3DeRSIyoNhYS5IgswzTYG9pLV7SLqb6p9MR72BXYxWv7X+OCyRcc8XXvd75Pe7SdvkQfCTNBb6yXKndVAWcuhBCjm91uH/C5oiiYpsmePXu4+OKLueGGG/h//+//UVVVxWuvvca1116LruvZIOdwrz/SNQHC4TCaplFfX39I36iDA6NSJ0GOyGoNt9IT7yFhJJhYNpFyRzkbujbw9K6n+fjEj6OphzZIiyajbO7eTEt/C6qiEkvGWN+2niUnLCnCVyCEEGNLfX09pmly1113oarpFffHH3982Nc988wzMQyDzs5Ozj///GFfr1gk8VhkNYWaCMQD2FQbZfYyxnvHU+GsYE9wD6/se+Wwr9nQtYGOaAexVIyTKk7CtEze63ivwDMXQoixaebMmSSTSf77v/+b3bt38z//8z8sX7582Nc9+eSTueKKK7jqqqv405/+RFNTE2+//TZ33HEHq1atysHMC0OCHAGAZVnsCe6hK9ZFhasCRVHQFI0Z5TNIGAme2f0MKSM14DWxVIxNXZto6W+h0lVJrbcWp+Zke+92wnr4CHcSQgiRK3PnzuXuu+/mZz/7GaeddhoPP/wwd9xxR06u/dBDD3HVVVfxb//2b5xyyilccsklvPPOO0ydOjUn1y8EOV0lp6sA6Ip2sbJh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+tWv8tprr7F371727t3L66+/zle/+tUBYy677DIuvfRSZs6cybx583jwwQfZtGkTW7ZsGTDupptu4tJLL2XGjBlMmDCB6667jueff562tnQds87OTp599lm+/vWvF+zrg0EGOX19fXz84x/Hbrfz3HPPsWXLFu666y4qKyuzY+68807uvfdeli9fzltvvYXX62Xx4sXE4x9mml9xxRU0NDSwZs0annnmGV555RX++Z//Oft8KBTioosuYtq0adTX1/Of//mf3H777fzmN7/JjnnjjTf48pe/zLXXXsv777/PJZdcwiWXXMLmzSOzZspghPUwbZE2AonAmM3HyXCoDmrcNUSSER7c/CA/r/85b7a9SSwVK/bUjiqTdKwoCm778QcpmaPkjYFGYskYpmWWfLXj45X5Puzr31fciRhJeOvX8M7vQOr4iFGspqaGJUuWsGLFCh566CGWLFnCuHEDTznu3LmTL3/5y5xwwgmUl5czffp0AJqbmweMO/vsswd8fu6553LqqaeycuVKAP7whz8wbdo0LrigsLmTg0o8/tnPfsaUKVMGLEHNmDEj+9+WZfHzn/+cH/zgB3z+858H4Pe//z21tbU89dRTfOlLX2Lr1q2sXr2ad955J/tN+e///m8+97nP8V//9V9MnDiRhx9+GF3XefDBB3E4HJx66qls2LCBu+++OxsM/eIXv+Azn/kM3/3udwH48Y9/zJo1a/jlL395SLb4aJPZqsJixJ0oyjVFUZhdPZsyRxm7ArvojHayM7CT6eXTufa0a6krK80gMJN07NScgzpN5NJc2aPk3fFuFEXBYRuZiccf5bF5UBWVfeEiBznhTujdDW0b4YQLofrE4s5HiDz6+te/zo033gjAfffdd8jz//AP/8C0adN44IEHmDhxIqZpctppp6HrA1fNvd5Df9m67rrruO+++/j+97/PQw89xDXXXFPwnYdBreQ8/fTTnH322XzhC19g/PjxnHnmmTzwwAPZ55uammhvb2fRokXZx/x+PwsWLGD9+vUArF+/noqKigFR36JFi1BVlbfeeis75oILLsDh+PCH9+LFi9m+fTt9fX3ZMQffJzMmc5/DSSQShEKhAR8jUXN/M4FEAJfNNWYach6NqqhMK5/GJ6d8kllVswgkArzb/i5/3PnHYk/tiHriPUSSkUEf/VcUhWpXNYZp0BfvQ0Ud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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "name_counts.plot.area(stacked=False, alpha=0.5)" + ] + }, + { + "cell_type": "markdown", + "id": "26d14b7e", + "metadata": {}, + "source": [ + "You can also use set `subplots` to `True` to draw separate graphs for each column." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "531e20b5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([, ,\n", + " ], dtype=object)" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "name_counts.plot.area(subplots=True, alpha=0.5)" + ] + }, + { + "cell_type": "markdown", + "id": "89d576a9", + "metadata": {}, + "source": [ + "# Bar Chart" + ] + }, + { + "cell_type": "markdown", + "id": "9e4c6864", + "metadata": {}, + "source": [ + "Bar Charts are suitable for analyzing categorical data. For example, you are going to check the sex distribution of the penguin data:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "e4aef1a1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "penguin_count_by_sex = penguins[penguins['sex'].isin((\"MALE\", \"FEMALE\"))].groupby('sex')['species'].count()\n", + "penguin_count_by_sex.plot.bar()" + ] + }, + { + "cell_type": "markdown", + "id": "41f5f621", + "metadata": {}, + "source": [ + "# Scatter Plot" + ] + }, + { + "cell_type": "markdown", + "id": "d79c527a", + "metadata": {}, + "source": [ + "In this example, you will explore the relationship between NYC taxi fares and trip distances." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "b6bf3f2a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
vendor_idpickup_datetimedropoff_datetimepassenger_counttrip_distancerate_codestore_and_fwd_flagpayment_typefare_amountextramta_taxtip_amounttolls_amountimp_surchargeairport_feetotal_amountpickup_location_iddropoff_location_iddata_file_yeardata_file_month
022021-09-19 10:25:05+00:002021-09-19 10:25:10+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-926426420219
122021-09-20 14:53:02+00:002021-09-20 14:53:23+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-919319320219
212021-09-14 12:01:02+00:002021-09-14 12:07:19+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-917017020219
322021-09-12 10:40:32+00:002021-09-12 10:41:26+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-919319320219
412021-09-25 11:57:21+00:002021-09-25 11:58:32+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-9959520219
\n", + "
" + ], + "text/plain": [ + " vendor_id pickup_datetime dropoff_datetime \\\n", + "0 2 2021-09-19 10:25:05+00:00 2021-09-19 10:25:10+00:00 \n", + "1 2 2021-09-20 14:53:02+00:00 2021-09-20 14:53:23+00:00 \n", + "2 1 2021-09-14 12:01:02+00:00 2021-09-14 12:07:19+00:00 \n", + "3 2 2021-09-12 10:40:32+00:00 2021-09-12 10:41:26+00:00 \n", + "4 1 2021-09-25 11:57:21+00:00 2021-09-25 11:58:32+00:00 \n", + "\n", + " passenger_count trip_distance rate_code store_and_fwd_flag payment_type \\\n", + "0 1 0E-9 1.0 N 1 \n", + "1 1 0E-9 1.0 N 1 \n", + "2 1 0E-9 1.0 N 1 \n", + "3 1 0E-9 1.0 N 1 \n", + "4 1 0E-9 1.0 N 1 \n", + "\n", + " fare_amount extra mta_tax tip_amount tolls_amount imp_surcharge \\\n", + "0 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "1 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "2 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "3 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "4 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "\n", + " airport_fee total_amount pickup_location_id dropoff_location_id \\\n", + "0 0E-9 0E-9 264 264 \n", + "1 0E-9 0E-9 193 193 \n", + "2 0E-9 0E-9 170 170 \n", + "3 0E-9 0E-9 193 193 \n", + "4 0E-9 0E-9 95 95 \n", + "\n", + " data_file_year data_file_month \n", + "0 2021 9 \n", + "1 2021 9 \n", + "2 2021 9 \n", + "3 2021 9 \n", + "4 2021 9 " + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "taxi_trips = bpd.read_gbq('bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2021').dropna()\n", + "taxi_trips.peek()" + ] + }, + { + "cell_type": "markdown", + "id": "413c0f91", + "metadata": {}, + "source": [ + "First, you santize the data a bit by remove outliers and pathological datapoints:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "d4876b08", + "metadata": {}, + "outputs": [], + "source": [ + "taxi_trips = taxi_trips[taxi_trips['trip_distance'].between(0, 10, inclusive='right')]\n", + "taxi_trips = taxi_trips[taxi_trips['fare_amount'].between(0, 50, inclusive='right')]" + ] + }, + { + "cell_type": "markdown", + "id": "f1ed53f7", + "metadata": {}, + "source": [ + "You also need to sort the data before plotting if you have turned on the partial ordering mode during the setup stage." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "e9ddad9b", + "metadata": {}, + "outputs": [], + "source": [ + "taxi_trips = taxi_trips.sort_values('pickup_datetime')" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "e34ab06d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "taxi_trips.plot.scatter(x='trip_distance', y='fare_amount', alpha=0.5)" + ] + }, + { + "cell_type": "markdown", + "id": "7ab4ded3", + "metadata": {}, + "source": [ + "# Advacned Plotting with Pandas/Matplotlib Parameters" + ] + }, + { + "cell_type": "markdown", + "id": "51e3b044", + "metadata": {}, + "source": [ + "Because BigQuery DataFrame's plotting library is powered by Matplotlib and Pandas, you are able to pass in more parameters to fine tune your graph like what you do with Pandas. \n", + "\n", + "In the following example, you will resuse the taxi trips dataset, except that you will rename the labels for X-axis and Y-axis, use `passenger_count` for point sizes, color points with `tip_amount`, and resize the figure. " + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "51c4dfc7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABGkAAAJfCAYAAADM54shAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xd4XPWV//H3nT6jKerVKu6We8UFMIZACGwIJCTAhg2QRpINmwKk7pJAEhbSiEnjt9mwQDYhjQ0lECCUYKoLLrjbsi1ZLmpWr1Pv74+xBUJukkcaSfN55Zkn6N47954ryZLmzPmeY5imaSIiIiIiIiIiIkllSXYAIiIiIiIiIiKiJI2IiIiIiIiIyIigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIjCkvv/wyl112GYWFhRiGwWOPPdZnv2mafOtb36KgoAC3282FF15IRUVFcoJ9ByVpRERERERERGRM6ezsZM6cOfziF7847v4f/OAH/PSnP+X//b//x5o1a0hLS+Piiy+mp6dnmCPtyzBN00xqBCIiIiIiIiIiQ8QwDB599FGuuOIKIF5FU1hYyC233MKtt94KQGtrK3l5eTz44INcc801SYvVlrQrD5NYLMbhw4fx+XwYhpHscERERERERGSMM02T9vZ2CgsLsVjG9gKWnp4eQqHQsFzLNM1+r+udTidOp3NA56msrKS2tpYLL7ywd1sgEGDx4sW88cYbStIMpcOHD1NcXJzsMERERERERCTFHDhwgHHjxiU7jCHT09NDUU4OTR0dw3I9r9dLx7uu9e1vf5vbb799QOepra0FIC8vr8/2vLy83n3JMuaTND6fD4j/4/D7/UmORkRERERERMa6trY2iouLe1+PjlWhUIimjg7+9OUv4xlgNctAdQWDXPWTn/R7bT/QKpqRbswnaY6VQvn9fiVpREREREREZNikSssNr9NJ2hAnS44tGkvEa/v8/HwA6urqKCgo6N1eV1fH3Llzz+jcZ2psL44TEREREREREXmH8ePHk5+fzwsvvNC7ra2tjTVr1rB06dIkRpYClTQiIiIiIiIiMnQsDH0FyEDP39HRwZ49e3o/rqysZNOmTWRmZlJSUsKXvvQlvve97zF58mTGjx/PbbfdRmFhYe8EqGRRkkZERERERERExpQ333yT888/v/fjm2++GYDrr7+eBx98kK9+9at0dnZy44030tLSwjnnnMMzzzyDy+VKVsiAkjRAfIxXJBIhGo0mOxQRAKxWKzabLWXWsIqIiIiIyOg1EitpVqxYgWmaJ9xvGAbf+c53+M53vnNmgSVYyidpQqEQNTU1dHV1JTsUkT48Hg8FBQU4HI5khyIiIiIiIiLDIKWTNLFYjMrKSqxWK4WFhTgcDlUuSNKZpkkoFKKhoYHKykomT56MxaIe3yIiIiIiMjJZjz6G+hqpIKWTNKFQiFgsRnFxMR6PJ9nhiPRyu93Y7Xb2799PKBRK+rpIERERERERGXopnaQ5RlUKMhLp+1JEREREREYDg6HvSZMqa170KlBEREREREREZARQJc0Z6iHMfhppoZsYJnasFJJOPn4sKZPrExERERERkVQ1Eqc7jVZK0gxSiAibOMgOamjm7clQJuA8mqiZSzFlZCUvSBEREREREREZNVIlGZVQQSI8z05eYw9BIhQSoJgMismghAz8uKmmiWfZxg5qkx2ujECGYfDYY48lOwwREREREZEzZhmmRypIlftMGBOT19jLLmrJw08WaVjf9Wl0Y6eQACbwChUcpDk5waaYaDRKLBZLdhgiIiIiIiIig6IkzQA10kkFdWTgwXmS1WIGBtmk0U2YrRzGxExoHCtWrOCmm27ipptuIhAIkJ2dzW233YZpxq/zv//7vyxcuBCfz0d+fj4f/ehHqa+v731+c3Mz1157LTk5ObjdbiZPnswDDzwAxEeT33TTTRQUFOByuSgtLeWuu+7qfW5LSwuf+tSnyMnJwe/3c8EFF/DWW2/17r/99tuZO3cu//u//0tZWRmBQIBrrrmG9vb23mPa29u59tprSUtLo6CggJ/85CesWLGCL33pS73HBINBbr31VoqKikhLS2Px4sW89NJLvfsffPBB0tPTeeKJJ5g+fTpOp5Pq6upTfu7+53/+hxkzZuB0OikoKOCmm27q3VddXc3ll1+O1+vF7/dz1VVXUVdX17v/hhtu4Iorruhzvi996UusWLGiz9fmC1/4Al/96lfJzMwkPz+f22+/vXd/WVkZAB/84AcxDKP3YxEREREREUltStIM0F4a6CKEF+cpjzUwSMfNfhppojPhsTz00EPYbDbWrl3Lvffeyz333MOvf/1rAMLhMN/97nd56623eOyxx6iqquKGG27ofe5tt93G9u3befrpp9mxYwf33Xcf2dnZAPz0pz/liSee4E9/+hO7du3id7/7XZ9Ewkc+8hHq6+t5+umnWb9+PfPnz+c973kPTU1Nvcfs3buXxx57jCeffJInn3ySVatWcffdd/fuv/nmm3nttdd44okneO6553jllVfYsGFDn/u76aabeOONN/jDH/7A5s2b+chHPsL73vc+Kioqeo/p6uri+9//Pr/+9a/Ztm0bubm5J/2c3XfffXz+85/nxhtvZMuWLTzxxBNMmjQJgFgsxuWXX05TUxOrVq3iueeeY9++fVx99dUD+8IQ/9qkpaWxZs0afvCDH/Cd73yH5557DoB169YB8MADD1BTU9P7sYiIiIiIyGhkHaZHKlDj4AHaTyNu7BinObkpDQfNdFFHO1l4ExpLcXExP/nJTzAMg6lTp7JlyxZ+8pOf8OlPf5pPfOITvcdNmDCBn/70pyxatIiOjg68Xi/V1dXMmzePhQsXAvRJwlRXVzN58mTOOeccDMOgtLS0d9+rr77K2rVrqa+vx+mMJ6p+9KMf8dhjj/HII49w4403AvGEx4MPPojP5wPgYx/7GC+88AJ33nkn7e3tPPTQQzz88MO85z3vAeIJi8LCwj4xPPDAA1RXV/duv/XWW3nmmWd44IEH+M///E8gnoz65S9/yZw5c07rc/a9732PW265hS9+8Yu92xYtWgTACy+8wJYtW6isrKS4uBiA3/zmN8yYMYN169b1Hnc6Zs+ezbe//W0AJk+ezM9//nNeeOEFLrroInJycgBIT08nPz//tM8pIiIiIiIiY5sqaQYoSKRfD5qTMY6mc8JEEx7LkiVLMIy3k0VLly6loqKCaDTK+vXrueyyyygpKcHn83HeeecB9C4H+tznPscf/vAH5s6dy1e/+lVef/313vPccMMNbNq0ialTp/KFL3yBv//977373nrrLTo6OsjKysLr9fY+Kisr2bt3b+9xZWVlvQkagIKCgt7lVvv27SMcDnPWWWf17g8EAkydOrX34y1bthCNRpkyZUqf66xatarPdRwOB7Nnzz6tz1d9fT2HDx/uTQy9244dOyguLu5N0ABMnz6d9PR0duzYcVrXOObdMb3z/kVEREQktUWI0EIzLTQTHYLXCSLDTY2DE0eVNANkw0qM0Gkfbx7tRmMbxm+pnp4eLr74Yi6++GJ+97vfkZOTQ3V1NRdffDGhUDz2Sy65hP379/O3v/2N5557jve85z18/vOf50c/+hHz58+nsrKSp59+mueff56rrrqKCy+8kEceeYSOjg4KCgr69IY5Jj09vfe/7XZ7n32GYQyoqW9HRwdWq5X169djtfYtbPN6365IcrvdfRJVJ+N2u0/7+idisVh6+/4cEw6H+x13pvcvIiIiImNPjBhVVLGH3bQR79fox89kplBG2WlX64vI2KUkzQCNI503acPEPK0fot2EcWAji7SEx7JmzZo+H69evZrJkyezc+dOGhsbufvuu3urQt58881+z8/JyeH666/n+uuv59xzz+UrX/kKP/rRjwDw+/1cffXVXH311Xz4wx/mfe97H01NTcyfP5/a2lpsNtugG95OmDABu93OunXrKCkpAaC1tZXdu3ezfPlyAObNm0c0GqW+vp5zzz13UNd5N5/PR1lZGS+88ALnn39+v/3l5eUcOHCAAwcO9H7etm/fTktLC9OnTwfin7OtW7f2ed6mTZv6JWVOxW63E43qXRMRERGRVFLBbt5iExaseI+2QmijlTdZS4Qwk5mS5AhFBmc4Kl1SpZImVe4zYSaRixMb3fSvnjieZroZRzp5+BMeS3V1NTfffDO7du3i97//PT/72c/44he/SElJCQ6Hg5/97Gfs27ePJ554gu9+97t9nvutb32Lxx9/nD179rBt2zaefPJJysvLAbjnnnv4/e9/z86dO9m9ezd//vOfyc/PJz09nQsvvJClS5dyxRVX8Pe//52qqipef/11/v3f//24iaDj8fl8XH/99XzlK1/hH//4B9u2beOTn/wkFoultypmypQpXHvttVx33XX85S9/obKykrVr13LXXXfx1FNPDfpzdvvtt/PjH/+Yn/70p1RUVLBhwwZ+9rOfAXDhhRcya9Ysrr32WjZs2MDatWu57rrrOO+883p791xwwQW8+eab/OY3v6GiooJvf/vb/ZI2p+NYsqi2tpbmZo1oFxERERnruulmN7uw4yCTTBxH/5dJFlZs7GInPfQkO0wRSTIlaQYoDz+lZNFAB5FTrB9tpRsrBtMpGJLSxeuuu47u7m7OOussPv/5z/PFL36RG2+8kZycHB588EH+/Oc/M336dO6+++7eCpljHA4H3/jGN5g9ezbLly/HarXyhz/8AYgnUX7wgx+wcOFCFi1aRFVVFX/72996kyh/+9vfWL58OR//+MeZMmUK11xzDfv37ycvL++0Y7/nnntYunQp73//+7nwwgs5++yzKS8vx+Vy9R7zwAMPcN1113HLLbcwdepUrrjiij7VN4Nx/fXXs3LlSn75y18yY8YM3v/+9/dOizIMg8cff5yMjAyWL1/OhRdeyIQJE/jjH//Y+/yLL76Y2267ja9+9assWrSI9vZ2rrvuugHH8eMf/5jnnnuO4uJi5s2bN+j7EREREZHRoYF6OunEh6/fPj9+OujgCA1JiEzkzGm6U+IY5rsbbIwxbW1tBAIBWltb8fv7VrP09PRQWVnJ+PHj+yQHTqWdHp5lO9U0kYkHL84+SZgIUZrpIkyMxYxnEaUJT9KsWLGCuXPnsnLlyoSeN1k6OzspKirixz/+MZ/85CeTHc6IMNjvTxEREREZeSrZxxpWk0v/NzZNTBqoYylnU0rZ8AcnCXey16FjybH7fPPrX8d7dPrvUOkIBll4991j/nOqnjSD4MPFJczgdfayj0aaacaOFQsGYeLNYTPxMI8SZgxRFc1ot3HjRnbu3MlZZ51Fa2sr3/nOdwC4/PLLkxyZiIiIiEji+fBjw0aQIE76vpgNEsSO47hVNiKjgXrSJI6SNIOUhpMLKaeZLvbSwBE6CBPFg4MSMiklC6c+vSf1ox/9iF27duFwOFiwYAGvvPIK2dnZZ3TOd05+erenn346YU2IRUREREQGIpNM8sjnANVkk4Pt6GuFMGFaaKaUMjLITHKUIpJsyiKcAQODTNLIHILJTadyvBHYo8m8efNYv359ws+7adOmE+4rKipK+PVERERERE6HBQvzmE+ECPXUETtagW/BQiFFzGWeKvBl1DIY+kqXVPnXoSSNjCmTJk1KdggiIiIiIsflxcs5nEsttTTTBEAWWeSR31tZIyKpTT8JgDHeO1lGKX1fioiIiIw9duwUH/2fyFihnjSJkyr3eVx2ux2Arq6uJEci0t+x78tj36ciIiIiIiIytqV0JY3VaiU9PZ36+noAPB4PhpEqK91kpDJNk66uLurr60lPT8dqtSY7JBERERERkROyHn0M9TVSQUonaQDy8/MBehM1IiNFenp67/eniIiIiIiIjH0pn6QxDIOCggJyc3MJh8PJDkcEiC9xUgWNiIiIiIiMBupJkzgpn6Q5xmq16kWxiIiIiIiIiCRNqiSjRERERERERERGNFXSiIiIiIiIiMigablT4qTKfYqIiIiIiIiIjGiqpBERERERERGRQVMlTeKkyn2KiIiIiIiIiIxoqqQRERERERERkUGzHn0M9TVSgSppRERERERERERGAFXSiIiIiIiIiMigqSdN4qTKfYqIiIiIiIiIjGiqpBERERERERGRQTMY+goQY4jPP1KokkZEREREREREZARQJY2IiIiIiIiIDJqmOyWOKmlEREREREREREYAVdKIiIiIiIiIyKBpulPipMp9ioiIiIiIiIiMaKqkEREREREREZFBsxhgGeISEEuKjHdSJY2IiIiIiIiIyAigShoRERERERERGTSLZRgqaVKkxCRFblNEREREREREZGRTJY2IiIiIiIiIDJrViD+G+hqpIKmVNPfddx+zZ8/G7/fj9/tZunQpTz/9dO/+FStWYBhGn8dnP/vZJEYsIiIiIiIiIjI0klpJM27cOO6++24mT56MaZo89NBDXH755WzcuJEZM2YA8OlPf5rvfOc7vc/xeDzJCldEREREREREZMgkNUlz2WWX9fn4zjvv5L777mP16tW9SRqPx0N+fn4ywhMRERERERGRU1Dj4MQZMbcZjUb5wx/+QGdnJ0uXLu3d/rvf/Y7s7GxmzpzJN77xDbq6uk56nmAwSFtbW5+HiIiIiIiIiMhIl/TGwVu2bGHp0qX09PTg9Xp59NFHmT59OgAf/ehHKS0tpbCwkM2bN/O1r32NXbt28Ze//OWE57vrrru44447hit8ERERERERkZRmtcQfQ32NVGCYpmkmM4BQKER1dTWtra088sgj/PrXv2bVqlW9iZp3evHFF3nPe97Dnj17mDhx4nHPFwwGCQaDvR+3tbVRXFxMa2srfr9/yO5DREREREREBOKvQwOBwJh/Hdp7n7d/Hb/LObTX6gkSuP3uMf85TXoljcPhYNKkSQAsWLCAdevWce+99/Jf//Vf/Y5dvHgxwEmTNE6nE6dzaL85REREREREROQoC0PfTCVFKmlG3G3GYrE+lTDvtGnTJgAKCgqGMSIRERERERERkaGX1Eqab3zjG1xyySWUlJTQ3t7Oww8/zEsvvcSzzz7L3r17efjhh7n00kvJyspi8+bNfPnLX2b58uXMnj07mWGLiIiIiIiIyDGqpEmYpCZp6uvrue6666ipqSEQCDB79myeffZZLrroIg4cOMDzzz/PypUr6ezspLi4mCuvvJL/+I//SGbIIiIiIiIiIiJDIqlJmvvvv/+E+4qLi1m1atUwRiMiIiIiIiIiA6ZKmoRJkdsUERERERERERnZkj7dSURERERERERGMVXSJEyK3KaIiIiIiIiIyMimShoRERERERERGTzj6GOor5ECVEkjIiIiIiIiIjICqJJGRERERERERAbPYOhLQFRJIyIiIiIiIiIiw0WVNCIiIiIiIiIyeJrulDApcpsiIiIiIiIiIiObkjQiIiIiIiIiIiOAljuJiIiIiIiIyOBpuVPCpMhtioiIiIiIiIiMbKqkEREREREREZHBUyVNwqTIbYqIiIiIiIiIjGyqpBERERERERGRwVMlTcKkyG2KiIiIiIiIiIxsqqQRERERERERkcEzjj6G+hopQJU0IiIiIiIiIiIjgCppRERERERERGTw1JMmYVLkNkVERERERERERjZV0oiIiIiIiIjI4KmSJmFS5DZFREREREREREY2VdKIiIiIiIiIyOCpkiZhlKQREREREZHkCjfHH9Y0cOSCkSKzdkVE3kVJGhERERERSY5wM9Q9Bs2rIdoJFif4ZkP+B8FdkuzoROR0GQx9pUuK5G5TpGBIRERERERGlEgn7P851D0BhgVcRWD1QPMqqFoJPYeTHaGIyLBTkkZERERERIZf61po2wTeaeDMA6sbHFngnQldlXDkhWRHKCKnyzJMjxSQIrcpIiIiIiIjSus6sDjiS5zeybCAIzuexImFkxObiEiSqCeNiIiIiIgMv2g3GI7j77M4wAzHH9iHNSwRGQRNd0qYFLlNEREREREZUdImQ7QdTLP/vlAjuErA4h7+uEREkkhJGhERERERGX7pS8CeBd2VYMbi20wTgrXxEdxZKzSKW0RSjpY7iYiIiIjI8PNMgHE3wKH/hfat8YSMGQN7OuRdGU/iiMjoYDD0I7JTJGerJI2IiIiIiCRHxtngmQxt6+NLnGw+8M0Gd5mqaEQkJSlJIyIiIiIiyePMhZxLkh2FiJwJNQ5OmBS5TRERERERERGRkU2VNCIiIiIiIiIyeKqkSZgUuU0RERERERERkZFNlTQiIiIiIiKSFCYmERqIEcJG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ZmbmVfe+HK5O3K+40nDP4krC/YorDfcsriTcr/iuUaNG6dixY5o1a5aOHj2qXr16ae3atY0mE/YmFuPHsiYYAAAAAACAF2NOGgAAAAAAAC9AkQYAAAAAAMALUKQBAAAAAADwAhRpAAAAAAAAvABFmstg0aJF6tChgxwOh5KTk7V161azIwFNevzxx9W/f38FBwerTZs2GjFihPbs2WN2LOC8PPHEE7JYLJo6darZUYBmHTp0SL/5zW8UEREhf39/9ejRQ9u2bTM7FtCI0+nUzJkzFR8fL39/fyUkJOhvf/ubWHME3mLjxo0aPny4YmNjZbFYtHr1ao/thmFo1qxZiomJkb+/v9LS0lRQUGBOWOACUKRpYStXrtS0adOUmZmpHTt2KCkpSenp6SouLjY7GtBITk6OJk2apI8//lgffPCB6uvrdeutt6qystLsaMBZffLJJ3ruuefUs2dPs6MAzSotLdXAgQPl4+Oj9957Tzt37tScOXPUqlUrs6MBjTz55JNasmSJFi5cqF27dunJJ5/UU089pWeffdbsaIAkqbKyUklJSVq0aFGT25966iktWLBAS5cu1ZYtWxQYGKj09HTV1NRc5qTAhWEJ7haWnJys/v37a+HChZIkl8uluLg4/fGPf9T06dNNTgec3bFjx9SmTRvl5OQoNTXV7DhAkyoqKtSnTx8tXrxYjzzyiHr16qV58+aZHQtoZPr06fr3v/+tTZs2mR0FOKdhw4YpKipKL774ortt5MiR8vf31yuvvGJiMqAxi8WiVatWacSIEZJOj6KJjY3Vfffdp/vvv1+SdPLkSUVFRSkrK0ujR482MS1wdoykaUF1dXXavn270tLS3G1Wq1VpaWnavHmzicmA83Py5ElJUnh4uMlJgOZNmjRJP//5zz3+rQW80TvvvKN+/frptttuU5s2bdS7d2+98MILZscCmnTDDTcoOztbe/fulSTl5+crNzdXP/3pT01OBpxbYWGhjh496vG3QWhoqJKTk3kOg9ezmx3galZSUiKn06moqCiP9qioKO3evdukVMD5cblcmjp1qgYOHKju3bubHQdo0muvvaYdO3bok08+MTsKcE5ff/21lixZomnTpukvf/mLPvnkE02ZMkW+vr7KyMgwOx7gYfr06SovL1eXLl1ks9nkdDr16KOP6s477zQ7GnBOR48elaQmn8PObAO8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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "\n", + "taxi_trips['passenger_count_scaled'] = taxi_trips['passenger_count'] * 30\n", + "\n", + "taxi_trips.plot.scatter(\n", + " x='trip_distance', \n", + " xlabel='trip distance (miles)',\n", + " y='fare_amount', \n", + " ylabel ='fare amount (usd)',\n", + " alpha=0.5, \n", + " s='passenger_count_scaled', \n", + " label='passenger_count',\n", + " c='tip_amount',\n", + " cmap='jet',\n", + " colorbar=True,\n", + " legend=True,\n", + " figsize=(15,7),\n", + " sampling_n=1000)" + ] + }, + { + "cell_type": "markdown", + "id": "6356cdab", + "metadata": {}, + "source": [ + "# Visualize Large Dataset" + ] + }, + { + "cell_type": "markdown", + "id": "fce79ba0", + "metadata": {}, + "source": [ + "BigQuery DataFrame downloads data to your local machine for visualization. The amount of datapoints to be downloaded is capped at 1000 by default. If the amount of datapoints exceeds the cap, BigQuery DataFrame will randomly sample the amount of datapoints equal to the cap.\n", + "\n", + "You can override this cap by setting the `sampling_n` parameter when plotting graphs. For example:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "3d0ef911", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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X34Zhw4dQmlpKSwWCwAgJycHvXv3bva4GRkZqK6uxoQJE5o8bzQaMWjQoHb4JkQkNwwmRGR3VVVVSEhIQEJCAr766isEBQUhJycHCQkJMBqNl/1cZWUlAGDTpk3o1KlTk9e0Wm271kxE8sBgQkQ269q1K9RqNfbu3YuoqCgAQGlpKdLT0zFmzBikpqaiuLgYS5YsQWRkJID6SzkX0mg0AACz2Wx9rnfv3tBqtcjJycGYMWMc9G2ISE4YTIjIZl5eXpg9ezaeeuopBAQEIDg4GM8//zwUivr19FFRUdBoNHj//ffxyCOP4OjRo3jttdeaHKNz584QBAE//vgjbrzxRri7u8Pb2xtPPvkk5s+fD4vFglGjRkGv12PXrl3w8fHBrFmzpPi6RORAvCuHiFrlX//6F6699lpMnjwZ48ePx6hRoxAXFwcACAoKwqeffoq1a9eid+/eWLJkCd58880mn+/UqRNeeeUVPPvsswgJCcHjjz8OAHjttdfw97//HYsXL0avXr1www03YNOmTejSpYvDvyMROR7vyiEiIiLZ4IwJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREcnG/wO2QTfrb4+kxgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "noaa_surface_median_temps.plot.line(sampling_n=40)" + ] + }, + { + "cell_type": "markdown", + "id": "64d6f86d", + "metadata": {}, + "source": [ + "Note: `sampling_n` has no effect on histograms. This is because BigQuery DataFrame bucketizes the data on the server side for histograms. If your amount of bins is very large, you may encounter a \"Query too large\" error instead." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/noxfile.py b/noxfile.py index b851bf160d..5322c1af52 100644 --- a/noxfile.py +++ b/noxfile.py @@ -8,7 +8,7 @@ # # https://www.apache.org/licenses/LICENSE-2.0 # -# Unless required by applicable law or agreed to in writing, software +# Unless required by applicable law or agreed to in writing, software/ # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and @@ -29,7 +29,9 @@ import nox.sessions BLACK_VERSION = "black==22.3.0" +FLAKE8_VERSION = "flake8==7.1.2" ISORT_VERSION = "isort==5.12.0" +MYPY_VERSION = "mypy==1.15.0" # TODO: switch to 3.13 once remote functions / cloud run adds a runtime for it (internal issue 333742751) LATEST_FULLY_SUPPORTED_PYTHON = "3.12" @@ -44,13 +46,12 @@ "3.11", ] -# pytest-retry is not yet compatible with pytest 8.x. -# https://github.com/str0zzapreti/pytest-retry/issues/32 -PYTEST_VERSION = "pytest<8.0.0dev" +PYTEST_VERSION = "pytest==8.4.2" SPHINX_VERSION = "sphinx==4.5.0" LINT_PATHS = [ "docs", "bigframes", + "scripts", "tests", "third_party", "noxfile.py", @@ -59,25 +60,36 @@ DEFAULT_PYTHON_VERSION = "3.10" +# Cloud Run Functions supports Python versions up to 3.12 +# https://cloud.google.com/run/docs/runtimes/python +E2E_TEST_PYTHON_VERSION = "3.12" + UNIT_TEST_PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"] UNIT_TEST_STANDARD_DEPENDENCIES = [ "mock", "asyncmock", - "freezegun", PYTEST_VERSION, - "pytest-cov", "pytest-asyncio", + "pytest-cov", "pytest-mock", + "pytest-timeout", ] UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = [] UNIT_TEST_DEPENDENCIES: List[str] = [] -UNIT_TEST_EXTRAS: List[str] = [] -UNIT_TEST_EXTRAS_BY_PYTHON: Dict[str, List[str]] = {"3.12": ["polars"]} - +UNIT_TEST_EXTRAS: List[str] = ["tests"] +UNIT_TEST_EXTRAS_BY_PYTHON: Dict[str, List[str]] = { + "3.10": ["tests", "scikit-learn", "anywidget"], + "3.11": ["tests", "polars", "scikit-learn", "anywidget"], + # Make sure we leave some versions without "extras" so we know those + # dependencies are actually optional. + "3.13": ["tests", "polars", "scikit-learn", "anywidget"], +} + +# 3.11 is used by colab. # 3.10 is needed for Windows tests as it is the only version installed in the # bigframes-windows container image. For more information, search # bigframes/windows-docker, internally. -SYSTEM_TEST_PYTHON_VERSIONS = ["3.9", "3.10", "3.12", "3.13"] +SYSTEM_TEST_PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"] SYSTEM_TEST_STANDARD_DEPENDENCIES = [ "jinja2", "mock", @@ -97,7 +109,13 @@ SYSTEM_TEST_LOCAL_DEPENDENCIES: List[str] = [] SYSTEM_TEST_DEPENDENCIES: List[str] = [] SYSTEM_TEST_EXTRAS: List[str] = ["tests"] -SYSTEM_TEST_EXTRAS_BY_PYTHON: Dict[str, List[str]] = {} +SYSTEM_TEST_EXTRAS_BY_PYTHON: Dict[str, List[str]] = { + # Make sure we leave some versions without "extras" so we know those + # dependencies are actually optional. + "3.10": ["tests", "scikit-learn", "anywidget"], + LATEST_FULLY_SUPPORTED_PYTHON: ["tests", "scikit-learn", "polars", "anywidget"], + "3.13": ["tests", "polars", "anywidget"], +} LOGGING_NAME_ENV_VAR = "BIGFRAMES_PERFORMANCE_LOG_NAME" @@ -105,19 +123,16 @@ # Sessions are executed in the order so putting the smaller sessions # ahead to fail fast at presubmit running. -# 'docfx' is excluded since it only needs to run in 'docs-presubmit' nox.options.sessions = [ - "lint", - "lint_setup_py", - "mypy", - "format", - "docs", - "docfx", - "unit", + # Include unit_noextras to ensure at least some unit tests contribute to + # coverage. + # TODO(tswast): Consider removing this when unit_noextras and cover is run + # from GitHub actions. "unit_noextras", - "system-3.9", - "system-3.12", + "system-3.9", # No extras. + f"system-{LATEST_FULLY_SUPPORTED_PYTHON}", # All extras. "cover", + # TODO(b/401609005): remove "cleanup", ] @@ -132,7 +147,12 @@ def lint(session): Returns a failure if the linters find linting errors or sufficiently serious code quality issues. """ - session.install("flake8", BLACK_VERSION) + session.install(FLAKE8_VERSION, BLACK_VERSION, ISORT_VERSION) + session.run( + "isort", + "--check", + *LINT_PATHS, + ) session.run( "black", "--check", @@ -173,9 +193,17 @@ def format(session): @nox.session(python=DEFAULT_PYTHON_VERSION) def lint_setup_py(session): """Verify that setup.py is valid (including RST check).""" - session.install("docutils", "pygments") + session.install("docutils", "pygments", "setuptools") session.run("python", "setup.py", "check", "--restructuredtext", "--strict") + session.install("twine", "wheel") + shutil.rmtree("build", ignore_errors=True) + shutil.rmtree("dist", ignore_errors=True) + session.run("python", "setup.py", "sdist") + session.run( + "python", "-m", "twine", "check", *pathlib.Path("dist").glob("*.tar.gz") + ) + def install_unittest_dependencies(session, install_test_extra, *constraints): standard_deps = UNIT_TEST_STANDARD_DEPENDENCIES + UNIT_TEST_DEPENDENCIES @@ -184,14 +212,11 @@ def install_unittest_dependencies(session, install_test_extra, *constraints): if UNIT_TEST_LOCAL_DEPENDENCIES: session.install(*UNIT_TEST_LOCAL_DEPENDENCIES, *constraints) - if install_test_extra and UNIT_TEST_EXTRAS_BY_PYTHON: - extras = UNIT_TEST_EXTRAS_BY_PYTHON.get(session.python, []) - elif install_test_extra and UNIT_TEST_EXTRAS: - extras = UNIT_TEST_EXTRAS - else: - extras = [] - - if extras: + if install_test_extra: + if session.python in UNIT_TEST_EXTRAS_BY_PYTHON: + extras = UNIT_TEST_EXTRAS_BY_PYTHON[session.python] + else: + extras = UNIT_TEST_EXTRAS session.install("-e", f".[{','.join(extras)}]", *constraints) else: session.install("-e", ".", *constraints) @@ -211,6 +236,10 @@ def run_unit(session, install_test_extra): session.run( "py.test", "--quiet", + # Any individual test taking longer than 1 mins will be terminated. + "--timeout=60", + # Log 20 slowest tests + "--durations=20", f"--junitxml=unit_{session.python}_sponge_log.xml", "--cov=bigframes", f"--cov={tests_path}", @@ -248,14 +277,17 @@ def mypy(session): deps = ( set( [ - "mypy", - "pandas-stubs", + MYPY_VERSION, + # TODO: update to latest pandas-stubs once we resolve bigframes issues. + "pandas-stubs<=2.2.3.241126", "types-protobuf", "types-python-dateutil", "types-requests", "types-setuptools", "types-tabulate", + "types-PyYAML", "polars", + "anywidget", ] ) | set(SYSTEM_TEST_STANDARD_DEPENDENCIES) @@ -331,10 +363,13 @@ def run_system( install_systemtest_dependencies(session, install_test_extra, "-c", constraints_path) + # Print out package versions for debugging. + session.run("python", "-m", "pip", "freeze") + # Run py.test against the system tests. pytest_cmd = [ "py.test", - "--quiet", + "-v", f"-n={num_workers}", # Any individual test taking longer than 15 mins will be terminated. f"--timeout={timeout_seconds}", @@ -403,6 +438,10 @@ def doctest(session: nox.sessions.Session): "third_party/bigframes_vendored/ibis", "--ignore", "bigframes/core/compile/polars", + "--ignore", + "bigframes/testing", + "--ignore", + "bigframes/display/anywidget.py", ), test_folder="bigframes", check_cov=True, @@ -410,7 +449,7 @@ def doctest(session: nox.sessions.Session): ) -@nox.session(python=SYSTEM_TEST_PYTHON_VERSIONS[-1]) +@nox.session(python=E2E_TEST_PYTHON_VERSION) def e2e(session: nox.sessions.Session): """Run the large tests in system test suite.""" run_system( @@ -443,20 +482,31 @@ def cover(session): session.install("coverage", "pytest-cov") # Create a coverage report that includes only the product code. + omitted_paths = [ + # non-prod, unit tested + "bigframes/core/compile/polars/*", + "bigframes/core/compile/sqlglot/*", + # untested + "bigframes/streaming/*", + # utils + "bigframes/testing/*", + ] + session.run( "coverage", "report", "--include=bigframes/*", + # Only unit tested + f"--omit={','.join(omitted_paths)}", "--show-missing", - "--fail-under=85", + "--fail-under=84", ) - # Make sure there is no dead code in our test directories. + # Make sure there is no dead code in our system test directories. session.run( "coverage", "report", "--show-missing", - "--include=tests/unit/*", "--include=tests/system/small/*", # TODO(b/353775058) resume coverage to 100 when the issue is fixed. "--fail-under=99", @@ -465,24 +515,15 @@ def cover(session): session.run("coverage", "erase") -@nox.session(python=DEFAULT_PYTHON_VERSION) +@nox.session(python="3.13") def docs(session): """Build the docs for this library.""" - - session.install("-e", ".") + session.install("-e", ".[scikit-learn]") session.install( - # We need to pin to specific versions of the `sphinxcontrib-*` packages - # which still support sphinx 4.x. - # See https://github.com/googleapis/sphinx-docfx-yaml/issues/344 - # and https://github.com/googleapis/sphinx-docfx-yaml/issues/345. - "sphinxcontrib-applehelp==1.0.4", - "sphinxcontrib-devhelp==1.0.2", - "sphinxcontrib-htmlhelp==2.0.1", - "sphinxcontrib-qthelp==1.0.3", - "sphinxcontrib-serializinghtml==1.1.5", - SPHINX_VERSION, - "alabaster", - "recommonmark", + "sphinx==8.2.3", + "sphinx-sitemap==2.9.0", + "myst-parser==4.0.1", + "pydata-sphinx-theme==0.16.1", ) shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) @@ -510,21 +551,14 @@ def docs(session): def docfx(session): """Build the docfx yaml files for this library.""" - session.install("-e", ".") + session.install("-e", ".[scikit-learn]") session.install( - # We need to pin to specific versions of the `sphinxcontrib-*` packages - # which still support sphinx 4.x. - # See https://github.com/googleapis/sphinx-docfx-yaml/issues/344 - # and https://github.com/googleapis/sphinx-docfx-yaml/issues/345. - "sphinxcontrib-applehelp==1.0.4", - "sphinxcontrib-devhelp==1.0.2", - "sphinxcontrib-htmlhelp==2.0.1", - "sphinxcontrib-qthelp==1.0.3", - "sphinxcontrib-serializinghtml==1.1.5", SPHINX_VERSION, - "alabaster", - "recommonmark", - "gcp-sphinx-docfx-yaml==3.0.1", + "sphinx-sitemap==2.9.0", + "pydata-sphinx-theme==0.13.3", + "myst-parser==0.18.1", + "gcp-sphinx-docfx-yaml==3.2.4", + "anywidget", ) shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) @@ -548,7 +582,7 @@ def docfx(session): "sphinx.ext.napoleon," "sphinx.ext.todo," "sphinx.ext.viewcode," - "recommonmark" + "myst_parser" ), "-b", "html", @@ -617,7 +651,7 @@ def prerelease(session: nox.sessions.Session, tests_path, extra_pytest_options=( session.install( "--upgrade", "-e", - "git+https://github.com/googleapis/python-bigquery-storage.git#egg=google-cloud-bigquery-storage", + "git+https://github.com/googleapis/google-cloud-python.git#egg=google-cloud-bigquery-storage&subdirectory=packages/google-cloud-bigquery-storage", ) already_installed.add("google-cloud-bigquery-storage") session.install( @@ -652,6 +686,8 @@ def prerelease(session: nox.sessions.Session, tests_path, extra_pytest_options=( if match.group(1) not in already_installed ] + print(already_installed) + # We use --no-deps to ensure that pre-release versions aren't overwritten # by the version ranges in setup.py. session.install(*deps) @@ -726,10 +762,10 @@ def notebook(session: nox.Session): "google-cloud-aiplatform", "matplotlib", "seaborn", + "anywidget", ) notebooks_list = list(pathlib.Path("notebooks/").glob("*/*.ipynb")) - denylist = [ # Regionalized testing is manually added later. "notebooks/location/regionalized.ipynb", @@ -745,12 +781,15 @@ def notebook(session: nox.Session): # our test infrastructure. "notebooks/getting_started/ml_fundamentals_bq_dataframes.ipynb", # Needs DATASET. "notebooks/ml/bq_dataframes_ml_linear_regression.ipynb", # Needs DATASET_ID. + "notebooks/ml/bq_dataframes_ml_linear_regression_big.ipynb", # Needs DATASET_ID. "notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb", # Needs CONNECTION. # TODO(b/332737009): investigate why we get 404 errors, even though # bq_dataframes_llm_code_generation creates a bucket in the sample. "notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb", # Needs BUCKET_URI. "notebooks/generative_ai/sentiment_analysis.ipynb", # Too slow "notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb", # Gemini 2.0 backend hasn't ready in prod. + "notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb", # Limited quota for vector index ddl statements on table. + "notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb", # Needs CONNECTION. # TODO(b/366290533): to protect BQML quota "notebooks/generative_ai/bq_dataframes_llm_claude3_museum_art.ipynb", "notebooks/vertex_sdk/sdk2_bigframes_pytorch.ipynb", # Needs BUCKET_URI. @@ -758,12 +797,16 @@ def notebook(session: nox.Session): "notebooks/vertex_sdk/sdk2_bigframes_tensorflow.ipynb", # Needs BUCKET_URI. # The experimental notebooks imagine features that don't yet # exist or only exist as temporary prototypes. - "notebooks/experimental/longer_ml_demo.ipynb", + "notebooks/experimental/ai_operators.ipynb", "notebooks/experimental/semantic_operators.ipynb", # The notebooks that are added for more use cases, such as backing a # blog post, which may take longer to execute and need not be # continuously tested. "notebooks/apps/synthetic_data_generation.ipynb", + "notebooks/multimodal/multimodal_dataframe.ipynb", # too slow + # This anywidget notebook uses deferred execution, so it won't + # produce metrics for the performance benchmark script. + "notebooks/dataframes/anywidget_mode.ipynb", ] # TODO: remove exception for Python 3.13 cloud run adds a runtime for it (internal issue 333742751) @@ -780,11 +823,10 @@ def notebook(session: nox.Session): ] ) - # Convert each Path notebook object to a string using a list comprehension. + # Convert each Path notebook object to a string using a list comprehension, + # and remove tests that we choose not to test. notebooks = [str(nb) for nb in notebooks_list] - - # Remove tests that we choose not to test. - notebooks = list(filter(lambda nb: nb not in denylist, notebooks)) + notebooks = [nb for nb in notebooks if nb not in denylist and "/kaggle/" not in nb] # Regionalized notebooks notebooks_reg = { @@ -982,7 +1024,7 @@ def cleanup(session): # project within the "Number of functions" quota # https://cloud.google.com/functions/quotas#resource_limits recency_cutoff_hours = 12 - cleanup_count_per_location = 20 + cleanup_count_per_location = 40 cleanup_options.extend( [ f"--recency-cutoff={recency_cutoff_hours}", diff --git a/owlbot.py b/owlbot.py deleted file mode 100644 index 10fc47ebd7..0000000000 --- a/owlbot.py +++ /dev/null @@ -1,153 +0,0 @@ -# Copyright 2021 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This script is used to synthesize generated parts of this library.""" - -import pathlib -import re -import textwrap - -from synthtool import gcp -import synthtool as s -from synthtool.languages import python - -REPO_ROOT = pathlib.Path(__file__).parent.absolute() - -common = gcp.CommonTemplates() - -# ---------------------------------------------------------------------------- -# Add templated files -# ---------------------------------------------------------------------------- -templated_files = common.py_library( - default_python_version="3.10", - unit_test_python_versions=["3.9", "3.10", "3.11", "3.12", "3.13"], - system_test_python_versions=["3.9", "3.11", "3.12", "3.13"], - cov_level=35, - intersphinx_dependencies={ - "pandas": "https://pandas.pydata.org/pandas-docs/stable/", - "pydata-google-auth": "https://pydata-google-auth.readthedocs.io/en/latest/", - }, -) -s.move( - templated_files, - excludes=[ - # Multi-processing note isn't relevant, as bigframes is responsible for - # creating clients, not the end user. - "docs/multiprocessing.rst", - "noxfile.py", - ".pre-commit-config.yaml", - "README.rst", - "CONTRIBUTING.rst", - ".github/release-trigger.yml", - # BigQuery DataFrames manages its own Kokoro cluster for presubmit & continuous tests. - ".kokoro/build.sh", - ".kokoro/continuous/common.cfg", - ".kokoro/presubmit/common.cfg", - # Temporary workaround to update docs job to use python 3.10 - ".github/workflows/docs.yml", - ], -) - -# ---------------------------------------------------------------------------- -# Fixup files -# ---------------------------------------------------------------------------- - -# Encourage sharring all relevant versions in bug reports. -assert 1 == s.replace( # bug_report.md - [".github/ISSUE_TEMPLATE/bug_report.md"], - re.escape("#### Steps to reproduce\n"), - textwrap.dedent( - """ - ```python - import sys - import bigframes - import google.cloud.bigquery - import pandas - import pyarrow - import sqlglot - - print(f"Python: {sys.version}") - print(f"bigframes=={bigframes.__version__}") - print(f"google-cloud-bigquery=={google.cloud.bigquery.__version__}") - print(f"pandas=={pandas.__version__}") - print(f"pyarrow=={pyarrow.__version__}") - print(f"sqlglot=={sqlglot.__version__}") - ``` - - #### Steps to reproduce - """, - ), -) - -# Make sure build includes all necessary files. -assert 1 == s.replace( # MANIFEST.in - ["MANIFEST.in"], - re.escape("recursive-include google"), - "recursive-include third_party/bigframes_vendored *\nrecursive-include bigframes", -) - -# Even though BigQuery DataFrames isn't technically a client library, we are -# opting into Cloud RAD for docs hosting. -assert 1 == s.replace( # common.cfg - [".kokoro/docs/common.cfg"], - re.escape('value: "docs-staging-v2-dev"'), - 'value: "docs-staging-v2"', -) - -# Use a custom table of contents since the default one isn't organized well -# enough for the number of classes we have. -assert 1 == s.replace( # publish-docs.sh - [".kokoro/publish-docs.sh"], - ( - re.escape("# upload docs") - + "\n" - + re.escape( - 'python3.10 -m docuploader upload docs/_build/html/docfx_yaml --metadata-file docs.metadata --destination-prefix docfx --staging-bucket "${V2_STAGING_BUCKET}"' - ) - ), - ( - "# Replace toc.yml template file\n" - + "mv docs/templates/toc.yml docs/_build/html/docfx_yaml/toc.yml\n\n" - + "# upload docs\n" - + 'python3.10 -m docuploader upload docs/_build/html/docfx_yaml --metadata-file docs.metadata --destination-prefix docfx --staging-bucket "${V2_STAGING_BUCKET}"' - ), -) - -# Fixup the documentation. -assert 1 == s.replace( # docs/conf.py - ["docs/conf.py"], - re.escape("Google Cloud Client Libraries for bigframes"), - "BigQuery DataFrames provides DataFrame APIs on the BigQuery engine", -) - -# Don't omit `*/core/*.py` when counting test coverages -assert 1 == s.replace( # .coveragerc - [".coveragerc"], - re.escape(" */core/*.py\n"), - "", -) - -# ---------------------------------------------------------------------------- -# Samples templates -# ---------------------------------------------------------------------------- - -python.py_samples(skip_readmes=True) - -# ---------------------------------------------------------------------------- -# Final cleanup -# ---------------------------------------------------------------------------- - -s.shell.run(["nox", "-s", "format"], hide_output=False) -for noxfile in REPO_ROOT.glob("samples/**/noxfile.py"): - s.shell.run(["nox", "-s", "format"], cwd=noxfile.parent, hide_output=False) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..064bdaf362 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "python-bigquery-dataframes", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/pytest.ini b/pytest.ini index 204c743bbf..512fd81a7e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,3 @@ [pytest] doctest_optionflags = NORMALIZE_WHITESPACE -filterwarnings = - ignore::pandas.errors.SettingWithCopyWarning +addopts = "--import-mode=importlib" diff --git a/samples/dbt/.dbt.yml b/samples/dbt/.dbt.yml new file mode 100644 index 0000000000..a4301a0bab --- /dev/null +++ b/samples/dbt/.dbt.yml @@ -0,0 +1,27 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +dbt_sample_project: + outputs: + dev: # The target environment name (e.g., dev, prod) + compute_region: us-central1 # Region used for compute operations + dataset: dbt_sample_dateset # BigQuery dataset where dbt will create models + gcs_bucket: dbt_sample_bucket # GCS bucket to store output files + location: US # BigQuery dataset location + method: oauth # Authentication method + priority: interactive # Job priority: "interactive" or "batch" + project: bigframes-dev # GCP project ID + threads: 1 # Number of threads dbt can use for running models in parallel + type: bigquery # Specifies the dbt adapter + target: dev # The default target environment diff --git a/samples/dbt/README.md b/samples/dbt/README.md new file mode 100644 index 0000000000..986aa2eae3 --- /dev/null +++ b/samples/dbt/README.md @@ -0,0 +1,64 @@ +# dbt BigFrames Integration + +This repository provides simple examples of using **dbt Python models** with **BigQuery** in **BigFrames** mode. + +It includes basic configurations and sample models to help you get started quickly in a typical dbt project. + +## Highlights + +- `profiles.yml`: configures your connection to BigQuery. +- `dbt_project.yml`: configures your dbt project - **dbt_sample_project**. +- `dbt_bigframes_code_sample_1.py`: An example to read BigQuery data and perform basic transformation. +- `dbt_bigframes_code_sample_2.py`: An example to build an incremental model that leverages BigFrames UDF capabilities. +- `prepare_table.py`: An ML example to consolidate various data sources into a single, unified table for later usage. +- `prediction.py`: An ML example to train models and then generate predictions using the prepared table. + +## Requirements + +Before using this project, ensure you have: + +- A [Google Cloud account](https://cloud.google.com/free?hl=en) +- A [dbt Cloud account](https://www.getdbt.com/signup) (if using dbt Cloud) +- Python and SQL basics +- Familiarity with dbt concepts and structure + +For more, see: +- https://docs.getdbt.com/guides/dbt-python-bigframes +- https://cloud.google.com/bigquery/docs/dataframes-dbt + +## Run Locally + +Follow these steps to run the Python models using dbt Core. + +1. **Install the dbt BigQuery adapter:** + + ```bash + pip install dbt-bigquery + ``` + +2. **Initialize a dbt project (if not already done):** + + ```bash + dbt init + ``` + + Follow the prompts to complete setup. + +3. **Finish the configuration and add sample code:** + + - Edit `~/.dbt/profiles.yml` to finish the configuration. + - Replace or add code samples in `.../models/example`. + +4. **Run your dbt models:** + + To run all models: + + ```bash + dbt run + ``` + + Or run a specific model: + + ```bash + dbt run --select your_model_name + ``` \ No newline at end of file diff --git a/samples/dbt/dbt_sample_project/dbt_project.yml b/samples/dbt/dbt_sample_project/dbt_project.yml new file mode 100644 index 0000000000..789f4d2549 --- /dev/null +++ b/samples/dbt/dbt_sample_project/dbt_project.yml @@ -0,0 +1,52 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Name your project! Project names should contain only lowercase characters +# and underscores. A good package name should reflect your organization's +# name or the intended use of these models +name: 'dbt_sample_project' +version: '1.0.0' + +# This setting configures which "profile" dbt uses for this project. +profile: 'dbt_sample_project' + +# These configurations specify where dbt should look for different types of files. +# The `model-paths` config, for example, states that models in this project can be +# found in the "models/" directory. You probably won't need to change these! +model-paths: ["models"] +analysis-paths: ["analyses"] +test-paths: ["tests"] +seed-paths: ["seeds"] +macro-paths: ["macros"] +snapshot-paths: ["snapshots"] + +clean-targets: # directories to be removed by `dbt clean` + - "target" + - "dbt_packages" + + +# Configuring models +# Full documentation: https://docs.getdbt.com/docs/configuring-models + +# In this example config, we tell dbt to build all models in the example/ +# directory as views. These settings can be overridden in the individual model +# files using the `{{ config(...) }}` macro. +models: + dbt_sample_project: + # Optional: These settings (e.g., submission_method, notebook_template_id, + # etc.) can also be defined directly in the Python model using dbt.config. + submission_method: bigframes + # Config indicated by + and applies to all files under models/example/ + example: + +materialized: view diff --git a/samples/dbt/dbt_sample_project/models/example/dbt_bigframes_code_sample_1.py b/samples/dbt/dbt_sample_project/models/example/dbt_bigframes_code_sample_1.py new file mode 100644 index 0000000000..2e24596b79 --- /dev/null +++ b/samples/dbt/dbt_sample_project/models/example/dbt_bigframes_code_sample_1.py @@ -0,0 +1,80 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This example demonstrates one of the most general usages of transforming raw +# BigQuery data into a processed table using a dbt Python model with BigFrames. +# See more from: https://cloud.google.com/bigquery/docs/dataframes-dbt. +# +# Key defaults when using BigFrames in a dbt Python model for BigQuery: +# - The default materialization is 'table' unless specified otherwise. This +# means dbt will create a new BigQuery table from the result of this model. +# - The default timeout for the job is 3600 seconds (60 minutes). This can be +# adjusted if your processing requires more time. +# - If no runtime template is provided, dbt will automatically create and reuse +# a default one for executing the Python code in BigQuery. +# +# BigFrames provides a pandas-like API for BigQuery data, enabling familiar +# data manipulation directly within your dbt project. This code sample +# illustrates a basic pattern for: +# 1. Reading data from an existing BigQuery dataset. +# 2. Processing it using pandas-like DataFrame operations powered by BigFrames. +# 3. Outputting a cleaned and transformed table, managed by dbt. + + +def model(dbt, session): + # Optional: Override settings from your dbt_project.yml file. + # When both are set, dbt.config takes precedence over dbt_project.yml. + # + # Use `dbt.config(submission_method="bigframes")` to tell dbt to execute + # this Python model using BigQuery DataFrames (BigFrames). This allows you + # to write pandas-like code that operates directly on BigQuery data + # without needing to pull all data into memory. + dbt.config(submission_method="bigframes") + + # Define the BigQuery table path from which to read data. + table = "bigquery-public-data.epa_historical_air_quality.temperature_hourly_summary" + + # Define the specific columns to select from the BigQuery table. + columns = [ + "state_name", + "county_name", + "date_local", + "time_local", + "sample_measurement", + ] + + # Read data from the specified BigQuery table into a BigFrames DataFrame. + df = session.read_gbq(table, columns=columns) + + # Sort the DataFrame by the specified columns. This prepares the data for + # `drop_duplicates` to ensure consistent duplicate removal. + df = df.sort_values(columns).drop_duplicates(columns) + + # Group the DataFrame by 'state_name', 'county_name', and 'date_local'. For + # each group, calculate the minimum and maximum of the 'sample_measurement' + # column. The result will be a BigFrames DataFrame with a MultiIndex. + result = df.groupby(["state_name", "county_name", "date_local"])[ + "sample_measurement" + ].agg(["min", "max"]) + + # Rename some columns and convert the MultiIndex of the 'result' DataFrame + # into regular columns. This flattens the DataFrame so 'state_name', + # 'county_name', and 'date_local' become regular columns again. + result = result.rename( + columns={"min": "min_temperature", "max": "max_temperature"} + ).reset_index() + + # Return the processed BigFrames DataFrame. + # In a dbt Python model, this DataFrame will be materialized as a table + return result diff --git a/samples/dbt/dbt_sample_project/models/example/dbt_bigframes_code_sample_2.py b/samples/dbt/dbt_sample_project/models/example/dbt_bigframes_code_sample_2.py new file mode 100644 index 0000000000..1f060cd60b --- /dev/null +++ b/samples/dbt/dbt_sample_project/models/example/dbt_bigframes_code_sample_2.py @@ -0,0 +1,79 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This example demonstrates how to build an **incremental dbt Python model** +# using BigFrames. +# +# Incremental models are essential for efficiently processing large datasets by +# only transforming new or changed data, rather than reprocessing the entire +# dataset every time. If the target table already exists, dbt will perform a +# merge based on the specified unique keys; otherwise, it will create a new +# table automatically. +# +# This model also showcases the definition and application of a **BigFrames +# User-Defined Function (UDF)** to add a descriptive summary column based on +# temperature data. BigFrames UDFs allow you to execute custom Python logic +# directly within BigQuery, leveraging BigQuery's scalability. + + +def model(dbt, session): + # Optional: override settings from dbt_project.yml. + # When both are set, dbt.config takes precedence over dbt_project.yml. + dbt.config( + # Use BigFrames mode to execute this Python model. This enables + # pandas-like operations directly on BigQuery data. + submission_method="bigframes", + # Materialize this model as an 'incremental' table. This tells dbt to + # only process new or updated data on subsequent runs. + materialized="incremental", + # Use MERGE strategy to update rows during incremental runs. + incremental_strategy="merge", + # Define the composite key that uniquely identifies a row in the + # target table. This key is used by the 'merge' strategy to match + # existing rows for updates during incremental runs. + unique_key=["state_name", "county_name", "date_local"], + ) + + # Reference an upstream dbt model or an existing BigQuery table as a + # BigFrames DataFrame. It allows you to seamlessly use the output of another + # dbt model as input to this one. + df = dbt.ref("dbt_bigframes_code_sample_1") + + # Define a BigFrames UDF to generate a temperature description. + # BigFrames UDFs allow you to define custom Python logic that executes + # directly within BigQuery. This is powerful for complex transformations. + @session.udf(dataset="dbt_sample_dataset", name="describe_udf") + def describe( + max_temperature: float, + min_temperature: float, + ) -> str: + is_hot = max_temperature > 85.0 + is_cold = min_temperature < 50.0 + + if is_hot and is_cold: + return "Expect both hot and cold conditions today." + if is_hot: + return "Overall, it's a hot day." + if is_cold: + return "Overall, it's a cold day." + return "Comfortable throughout the day." + + # Apply the UDF using combine and store the result in a column "describe". + df["describe"] = df["max_temperature"].combine(df["min_temperature"], describe) + + # Return the transformed BigFrames DataFrame. + # This DataFrame will be the final output of your incremental dbt model. + # On subsequent runs, only new or changed rows will be processed and merged + # into the target BigQuery table based on the `unique_key`. + return df diff --git a/samples/dbt/dbt_sample_project/models/ml_example/prediction.py b/samples/dbt/dbt_sample_project/models/ml_example/prediction.py new file mode 100644 index 0000000000..d2fb54b384 --- /dev/null +++ b/samples/dbt/dbt_sample_project/models/ml_example/prediction.py @@ -0,0 +1,67 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This DBT Python model prepares and trains a machine learning model to predict +# ozone levels. +# 1. Data Preparation: The model first gets a prepared dataset and splits it +# into three subsets based on the year: training data (before 2017), +# testing data (2017-2019), and prediction data (2020 and later). +# 2. Model Training: It then uses the LinearRegression model from BigFrames +# ML library. The model is trained on the historical data, using other +# atmospheric parameters to predict the 'o3' (ozone) levels. +# 3. Prediction: Finally, the trained model makes predictions on the most +# recent data (from 2020 onwards) and returns the resulting DataFrame of +# predicted ozone values. +# +# See more details from the related blog post: https://docs.getdbt.com/blog/train-linear-dbt-bigframes + + +def model(dbt, session): + dbt.config(submission_method="bigframes", timeout=6000) + + df = dbt.ref("prepare_table") + + # Define the rules for separating the training, test and prediction data. + train_data_filter = (df.date_local.dt.year < 2017) + test_data_filter = ( + (df.date_local.dt.year >= 2017) & (df.date_local.dt.year < 2020) + ) + predict_data_filter = (df.date_local.dt.year >= 2020) + + # Define index_columns again here in prediction. + index_columns = ["state_name", "county_name", "site_num", "date_local", "time_local"] + + # Separate the training, test and prediction data. + df_train = df[train_data_filter].set_index(index_columns) + df_test = df[test_data_filter].set_index(index_columns) + df_predict = df[predict_data_filter].set_index(index_columns) + + # Finalize the training dataframe. + X_train = df_train.drop(columns="o3") + y_train = df_train["o3"] + + # Finalize the prediction dataframe. + X_predict = df_predict.drop(columns="o3") + + # Import the LinearRegression model from bigframes.ml module. + from bigframes.ml.linear_model import LinearRegression + + # Train the model. + model = LinearRegression() + model.fit(X_train, y_train) + + # Make the prediction using the model. + df_pred = model.predict(X_predict) + + return df_pred diff --git a/samples/dbt/dbt_sample_project/models/ml_example/prepare_table.py b/samples/dbt/dbt_sample_project/models/ml_example/prepare_table.py new file mode 100644 index 0000000000..23b54a9122 --- /dev/null +++ b/samples/dbt/dbt_sample_project/models/ml_example/prepare_table.py @@ -0,0 +1,93 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This DBT Python model processes EPA historical air quality data from BigQuery +# using BigFrames. The primary goal is to merge several hourly summary +# tables into a single, unified DataFrame for later prediction. It includes the +# following steps: +# 1. Reading and Cleaning: It reads individual hourly summary tables from +# BigQuery for various atmospheric parameters (like CO, O3, temperature, +# and wind speed). Each table is cleaned by sorting, removing duplicates, +# and renaming columns for clarity. +# 2. Combining Data: It then merges these cleaned tables into a single, +# comprehensive DataFrame. An inner join is used to ensure the final output +# only includes records with complete data across all parameters. +# 3. Final Output: The unified DataFrame is returned as the model's output, +# creating a corresponding BigQuery table for future use. +# +# See more details from the related blog post: https://docs.getdbt.com/blog/train-linear-dbt-bigframes + + +import bigframes.pandas as bpd + +def model(dbt, session): + # Optional: override settings from dbt_project.yml. + # When both are set, dbt.config takes precedence over dbt_project.yml. + dbt.config(submission_method="bigframes", timeout=6000) + + # Define the dataset and the columns of interest representing various parameters + # in the atmosphere. + dataset = "bigquery-public-data.epa_historical_air_quality" + index_columns = ["state_name", "county_name", "site_num", "date_local", "time_local"] + param_column = "parameter_name" + value_column = "sample_measurement" + + # Initialize a list for collecting dataframes from individual parameters. + params_dfs = [] + + # Collect dataframes from tables which contain data for single parameter. + table_param_dict = { + "co_hourly_summary" : "co", + "no2_hourly_summary" : "no2", + "o3_hourly_summary" : "o3", + "pressure_hourly_summary" : "pressure", + "so2_hourly_summary" : "so2", + "temperature_hourly_summary" : "temperature", + } + + for table, param in table_param_dict.items(): + param_df = bpd.read_gbq( + f"{dataset}.{table}", + columns=index_columns + [value_column] + ) + param_df = param_df\ + .sort_values(index_columns)\ + .drop_duplicates(index_columns)\ + .set_index(index_columns)\ + .rename(columns={value_column : param}) + params_dfs.append(param_df) + + # Collect dataframes from the table containing wind speed. + # Optionally: collect dataframes from other tables containing + # wind direction, NO, NOx, and NOy data as needed. + wind_table = f"{dataset}.wind_hourly_summary" + bpd.read_gbq(wind_table, columns=[param_column]).value_counts() + + wind_speed_df = bpd.read_gbq( + wind_table, + columns=index_columns + [value_column], + filters=[(param_column, "==", "Wind Speed - Resultant")] + ) + wind_speed_df = wind_speed_df\ + .sort_values(index_columns)\ + .drop_duplicates(index_columns)\ + .set_index(index_columns)\ + .rename(columns={value_column: "wind_speed"}) + params_dfs.append(wind_speed_df) + + # Combine data for all the selected parameters. + df = bpd.concat(params_dfs, axis=1, join="inner") + df = df.reset_index() + + return df diff --git a/samples/polars/requirements.txt b/samples/polars/requirements.txt index a1d8fbcdac..1626982536 100644 --- a/samples/polars/requirements.txt +++ b/samples/polars/requirements.txt @@ -1,3 +1,3 @@ -bigframes==1.11.1 -polars==1.3.0 -pyarrow==15.0.0 +bigframes==2.25.0 +polars==1.24.0 +pyarrow==21.0.0 diff --git a/samples/snippets/bigquery_modules_test.py b/samples/snippets/bigquery_modules_test.py new file mode 100644 index 0000000000..0cc2b1d8b5 --- /dev/null +++ b/samples/snippets/bigquery_modules_test.py @@ -0,0 +1,98 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def test_bigquery_dataframes_examples() -> None: + # [START bigquery_dataframes_bigquery_methods_array_agg] + import bigframes.bigquery as bbq + import bigframes.pandas as bpd + + s = bpd.Series([0, 1, 2, 3, 4, 5]) + + # Group values by whether they are divisble by 2 and aggregate them into arrays + bbq.array_agg(s.groupby(s % 2 == 0)) + # False [1 3 5] + # True [0 2 4] + # dtype: list[pyarrow] + # [END bigquery_dataframes_bigquery_methods_array_agg] + + # [START bigquery_dataframes_bigquery_methods_struct] + import bigframes.bigquery as bbq + import bigframes.pandas as bpd + + # Load data from BigQuery + query_or_table = "bigquery-public-data.ml_datasets.penguins" + bq_df = bpd.read_gbq(query_or_table) + + # Create a new STRUCT Series with subfields for each column in a DataFrames. + lengths = bbq.struct( + bq_df[["culmen_length_mm", "culmen_depth_mm", "flipper_length_mm"]] + ) + + lengths.peek() + # 146 {'culmen_length_mm': 51.1, 'culmen_depth_mm': ... + # 278 {'culmen_length_mm': 48.2, 'culmen_depth_mm': ... + # 337 {'culmen_length_mm': 36.4, 'culmen_depth_mm': ... + # 154 {'culmen_length_mm': 46.5, 'culmen_depth_mm': ... + # 185 {'culmen_length_mm': 50.1, 'culmen_depth_mm': ... + # dtype: struct[pyarrow] + # [END bigquery_dataframes_bigquery_methods_struct] + + # [START bigquery_dataframes_bigquery_methods_unix_micros] + import pandas as pd + + import bigframes.bigquery as bbq + import bigframes.pandas as bpd + + # Create a series that consists of three timestamps: [1970-01-01, 1970-01-02, 1970-01-03] + s = bpd.Series(pd.date_range("1970-01-01", periods=3, freq="d", tz="UTC")) + + bbq.unix_micros(s) + # 0 0 + # 1 86400000000 + # 2 172800000000 + # dtype: Int64 + # [END bigquery_dataframes_bigquery_methods_unix_micros] + + # [START bigquery_dataframes_bigquery_methods_scalar] + import bigframes.bigquery as bbq + import bigframes.pandas as bpd + + # Load data from BigQuery + query_or_table = "bigquery-public-data.ml_datasets.penguins" + + # The sql_scalar function can be used to inject SQL syntax that is not supported + # or difficult to express with the bigframes.pandas APIs. + bq_df = bpd.read_gbq(query_or_table) + shortest = bbq.sql_scalar( + "LEAST({0}, {1}, {2})", + columns=[ + bq_df["culmen_depth_mm"], + bq_df["culmen_length_mm"], + bq_df["flipper_length_mm"], + ], + ) + + shortest.peek() + # 0 + # 149 18.9 + # 33 16.3 + # 296 17.2 + # 287 17.0 + # 307 15.0 + # dtype: Float64 + # [END bigquery_dataframes_bigquery_methods_scalar] + assert bq_df is not None + assert lengths is not None + assert shortest is not None diff --git a/samples/snippets/conftest.py b/samples/snippets/conftest.py index 9171ac78a4..e19cfbceb4 100644 --- a/samples/snippets/conftest.py +++ b/samples/snippets/conftest.py @@ -12,9 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Iterator +from typing import Generator, Iterator -from google.cloud import bigquery +from google.cloud import bigquery, storage import pytest import test_utils.prefixer @@ -24,6 +24,8 @@ "python-bigquery-dataframes", "samples/snippets" ) +routine_prefixer = test_utils.prefixer.Prefixer("bigframes", "") + @pytest.fixture(scope="session", autouse=True) def cleanup_datasets(bigquery_client: bigquery.Client) -> None: @@ -40,11 +42,38 @@ def bigquery_client() -> bigquery.Client: return bigquery_client +@pytest.fixture(scope="session") +def storage_client(project_id: str) -> storage.Client: + return storage.Client(project=project_id) + + @pytest.fixture(scope="session") def project_id(bigquery_client: bigquery.Client) -> str: return bigquery_client.project +@pytest.fixture(scope="session") +def gcs_bucket(storage_client: storage.Client) -> Generator[str, None, None]: + bucket_name = "bigframes_blob_test_with_data_wipeout" + + yield bucket_name + + bucket = storage_client.get_bucket(bucket_name) + for blob in bucket.list_blobs(): + blob.delete() + + +@pytest.fixture(scope="session") +def gcs_bucket_snippets(storage_client: storage.Client) -> Generator[str, None, None]: + bucket_name = "bigframes_blob_test_snippet_with_data_wipeout" + + yield bucket_name + + bucket = storage_client.get_bucket(bucket_name) + for blob in bucket.list_blobs(): + blob.delete() + + @pytest.fixture(autouse=True) def reset_session() -> None: """An autouse fixture ensuring each sample runs in a fresh session. @@ -101,3 +130,12 @@ def random_model_id_eu( full_model_id = f"{project_id}.{dataset_id_eu}.{random_model_id_eu}" yield full_model_id bigquery_client.delete_model(full_model_id, not_found_ok=True) + + +@pytest.fixture +def routine_id() -> Iterator[str]: + """Create a new BQ routine ID each time, so random_routine_id can be used as + target for udf creation. + """ + random_routine_id = routine_prefixer.create_prefix() + yield random_routine_id diff --git a/samples/snippets/create_kmeans_model_test.py b/samples/snippets/create_kmeans_model_test.py index 32ebc60a69..7d9a43e86c 100644 --- a/samples/snippets/create_kmeans_model_test.py +++ b/samples/snippets/create_kmeans_model_test.py @@ -18,10 +18,14 @@ def test_kmeans_sample(project_id: str, random_model_id_eu: str) -> None: your_model_id = random_model_id_eu # [START bigquery_dataframes_bqml_kmeans] import datetime + import typing import pandas as pd + from shapely.geometry import Point import bigframes + import bigframes.bigquery as bbq + import bigframes.geopandas import bigframes.pandas as bpd bigframes.options.bigquery.project = your_gcp_project_id @@ -41,21 +45,20 @@ def test_kmeans_sample(project_id: str, random_model_id_eu: str) -> None: } ) - s = bpd.read_gbq( - # Use ST_GEOPOINT and ST_DISTANCE to analyze geographical - # data. These functions determine spatial relationships between - # geographical features. - """ - SELECT - id, - ST_DISTANCE( - ST_GEOGPOINT(s.longitude, s.latitude), - ST_GEOGPOINT(-0.1, 51.5) - ) / 1000 AS distance_from_city_center - FROM - `bigquery-public-data.london_bicycles.cycle_stations` s - """ + # Use GeoSeries.from_xy and BigQuery.st_distance to analyze geographical + # data. These functions determine spatial relationships between + # geographical features. + cycle_stations = bpd.read_gbq("bigquery-public-data.london_bicycles.cycle_stations") + s = bpd.DataFrame( + { + "id": cycle_stations["id"], + "xy": bigframes.geopandas.GeoSeries.from_xy( + cycle_stations["longitude"], cycle_stations["latitude"] + ), + } ) + s_distance = bbq.st_distance(s["xy"], Point(-0.1, 51.5), use_spheroid=False) / 1000 + s = bpd.DataFrame({"id": s["id"], "distance_from_city_center": s_distance}) # Define Python datetime objects in the UTC timezone for range comparison, # because BigQuery stores timestamp data in the UTC timezone. @@ -91,8 +94,11 @@ def test_kmeans_sample(project_id: str, random_model_id_eu: str) -> None: # Engineer features to cluster the stations. For each station, find the # average trip duration, number of trips, and distance from city center. - stationstats = merged_df.groupby(["station_name", "isweekday"]).agg( - {"duration": ["mean", "count"], "distance_from_city_center": "max"} + stationstats = typing.cast( + bpd.DataFrame, + merged_df.groupby(["station_name", "isweekday"]).agg( + {"duration": ["mean", "count"], "distance_from_city_center": "max"} + ), ) stationstats.columns = pd.Index( ["duration", "num_trips", "distance_from_city_center"] diff --git a/samples/snippets/create_multiple_timeseries_forecasting_model_test.py b/samples/snippets/create_multiple_timeseries_forecasting_model_test.py index b749c37d50..0ce38e1a85 100644 --- a/samples/snippets/create_multiple_timeseries_forecasting_model_test.py +++ b/samples/snippets/create_multiple_timeseries_forecasting_model_test.py @@ -73,26 +73,103 @@ def test_multiple_timeseries_forecasting_model(random_model_id: str) -> None: from bigframes.ml import forecasting import bigframes.pandas as bpd + model = forecasting.ARIMAPlus( + # To reduce the query runtime with the compromise of a potential slight + # drop in model quality, you could decrease the value of the + # auto_arima_max_order. This shrinks the search space of hyperparameter + # tuning in the auto.ARIMA algorithm. + auto_arima_max_order=5, + ) + df = bpd.read_gbq("bigquery-public-data.new_york.citibike_trips") + # This query creates twelve time series models, one for each of the twelve + # Citi Bike start stations in the input data. If you remove this row + # filter, there would be 600+ time series to forecast. + df = df[df["start_station_name"].str.contains("Central Park")] + features = bpd.DataFrame( { - "num_trips": df.starttime, + "start_station_name": df["start_station_name"], + "num_trips": df["starttime"], "date": df["starttime"].dt.date, } ) - num_trips = features.groupby(["date"], as_index=False).count() - model = forecasting.ARIMAPlus() + num_trips = features.groupby( + ["start_station_name", "date"], + as_index=False, + ).count() X = num_trips["date"].to_frame() y = num_trips["num_trips"].to_frame() - model.fit(X, y) + model.fit( + X, + y, + # The input data that you want to get forecasts for, + # in this case the Citi Bike station, as represented by the + # start_station_name column. + id_col=num_trips["start_station_name"].to_frame(), + ) + # The model.fit() call above created a temporary model. # Use the to_gbq() method to write to a permanent location. - model.to_gbq( your_model_id, # For example: "bqml_tutorial.nyc_citibike_arima_model", replace=True, ) # [END bigquery_dataframes_bqml_arima_multiple_step_3_fit] + + # [START bigquery_dataframes_bqml_arima_multiple_step_4_evaluate] + # Evaluate the time series models by using the summary() function. The summary() + # function shows you the evaluation metrics of all the candidate models evaluated + # during the process of automatic hyperparameter tuning. + summary = model.summary() + print(summary.peek()) + + # Expected output: + # start_station_name non_seasonal_p non_seasonal_d non_seasonal_q has_drift log_likelihood AIC variance ... + # 1 Central Park West & W 72 St 0 1 5 False -1966.449243 3944.898487 1215.689281 ... + # 8 Central Park W & W 96 St 0 0 5 False -274.459923 562.919847 655.776577 ... + # 9 Central Park West & W 102 St 0 0 0 False -226.639918 457.279835 258.83582 ... + # 11 Central Park West & W 76 St 1 1 2 False -1700.456924 3408.913848 383.254161 ... + # 4 Grand Army Plaza & Central Park S 0 1 5 False -5507.553498 11027.106996 624.138741 ... + # [END bigquery_dataframes_bqml_arima_multiple_step_4_evaluate] + + # [START bigquery_dataframes_bqml_arima_multiple_step_5_coefficients] + coef = model.coef_ + print(coef.peek()) + + # Expected output: + # start_station_name ar_coefficients ma_coefficients intercept_or_drift + # 5 Central Park West & W 68 St [] [-0.41014089 0.21979212 -0.59854213 -0.251438... 0.0 + # 6 Central Park S & 6 Ave [] [-0.71488957 -0.36835772 0.61008532 0.183290... 0.0 + # 0 Central Park West & W 85 St [] [-0.39270166 -0.74494638 0.76432596 0.489146... 0.0 + # 3 W 82 St & Central Park West [-0.50219511 -0.64820817] [-0.20665325 0.67683137 -0.68108631] 0.0 + # 11 W 106 St & Central Park West [-0.70442887 -0.66885553 -0.25030325 -0.34160669] [] 0.0 + # [END bigquery_dataframes_bqml_arima_multiple_step_5_coefficients] + + # [START bigquery_dataframes_bqml_arima_multiple_step_6_forecast] + prediction = model.predict(horizon=3, confidence_level=0.9) + + print(prediction.peek()) + # Expected output: + # forecast_timestamp start_station_name forecast_value standard_error confidence_level ... + # 4 2016-10-01 00:00:00+00:00 Central Park S & 6 Ave 302.377201 32.572948 0.9 ... + # 14 2016-10-02 00:00:00+00:00 Central Park North & Adam Clayton Powell Blvd 263.917567 45.284082 0.9 ... + # 1 2016-09-25 00:00:00+00:00 Central Park West & W 85 St 189.574706 39.874856 0.9 ... + # 20 2016-10-02 00:00:00+00:00 Central Park West & W 72 St 175.474862 40.940794 0.9 ... + # 12 2016-10-01 00:00:00+00:00 W 106 St & Central Park West 63.88163 18.088868 0.9 ... + # [END bigquery_dataframes_bqml_arima_multiple_step_6_forecast] + # [START bigquery_dataframes_bqml_arima_multiple_step_7_explain] + explain = model.predict_explain(horizon=3, confidence_level=0.9) + + print(explain.peek(5)) + # Expected output: + # time_series_timestamp start_station_name time_series_type time_series_data time_series_adjusted_data standard_error confidence_level prediction_interval_lower_bound prediction_interval_upper_bound trend seasonal_period_yearly seasonal_period_quarterly seasonal_period_monthly seasonal_period_weekly seasonal_period_daily holiday_effect spikes_and_dips step_changes residual + # 0 2013-07-01 00:00:00+00:00 Central Park S & 6 Ave history 69.0 154.168527 32.572948 0.0 35.477484 -28.402102 0.0 -85.168527 147.093145 + # 1 2013-07-01 00:00:00+00:00 Grand Army Plaza & Central Park S history 79.0 79.0 24.982769 0.0 43.46428 -30.01599 0.0 0.0 65.55171 + # 2 2013-07-02 00:00:00+00:00 Central Park S & 6 Ave history 180.0 204.045651 32.572948 147.093045 72.498327 -15.545721 0.0 -85.168527 61.122876 + # 3 2013-07-02 00:00:00+00:00 Grand Army Plaza & Central Park S history 129.0 99.556269 24.982769 65.551665 45.836432 -11.831828 0.0 0.0 29.443731 + # 4 2013-07-03 00:00:00+00:00 Central Park S & 6 Ave history 115.0 205.968236 32.572948 191.32754 59.220766 -44.580071 0.0 -85.168527 -5.799709 + # [END bigquery_dataframes_bqml_arima_multiple_step_7_explain] diff --git a/samples/snippets/data_visualization_test.py b/samples/snippets/data_visualization_test.py new file mode 100644 index 0000000000..64cbbe0511 --- /dev/null +++ b/samples/snippets/data_visualization_test.py @@ -0,0 +1,149 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (t +# you may not use this file except in compliance wi +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in +# distributed under the License is distributed on a +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, eit +# See the License for the specific language governi +# limitations under the License. + + +def test_data_visualization() -> None: + # [START bigquery_dataframes_data_visualization_penguin_histogram] + import bigframes.pandas as bpd + + penguins = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + penguins["culmen_depth_mm"].plot.hist(bins=40) + # [END bigquery_dataframes_data_visualization_penguin_histogram] + + # [START bigquery_dataframes_data_visualization_noaa_line_chart] + import bigframes.pandas as bpd + + noaa_surface = bpd.read_gbq("bigquery-public-data.noaa_gsod.gsod2021") + + # Calculate median temperature for each day + noaa_surface_median_temps = noaa_surface[["date", "temp"]].groupby("date").median() + + noaa_surface_median_temps.plot.line() + # [END bigquery_dataframes_data_visualization_noaa_line_chart] + + # [START bigquery_dataframes_data_visualization_usa_names_area_chart] + import bigframes.pandas as bpd + + usa_names = bpd.read_gbq("bigquery-public-data.usa_names.usa_1910_2013") + + # Count the occurences of the target names each year. The result is a dataframe with a multi-index. + name_counts = ( + usa_names[usa_names["name"].isin(("Mary", "Emily", "Lisa"))] + .groupby(("year", "name"))["number"] + .sum() + ) + + # Flatten the index of the dataframe so that the counts for each name has their own columns. + name_counts = name_counts.unstack(level=1).fillna(0) + + name_counts.plot.area(stacked=False, alpha=0.5) + # [END bigquery_dataframes_data_visualization_usa_names_area_chart] + + # [START bigquery_dataframes_data_visualization_penguin_bar_chart] + import bigframes.pandas as bpd + + penguins = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + + penguin_count_by_sex = ( + penguins[penguins["sex"].isin(("MALE", "FEMALE"))] + .groupby("sex")["species"] + .count() + ) + penguin_count_by_sex.plot.bar() + # [END bigquery_dataframes_data_visualization_penguin_bar_chart] + + # [START bigquery_dataframes_data_visualization_taxi_scatter_plot] + import bigframes.pandas as bpd + + taxi_trips = bpd.read_gbq( + "bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2021" + ).dropna() + + # Data Cleaning + taxi_trips = taxi_trips[ + taxi_trips["trip_distance"].between(0, 10, inclusive="right") + ] + taxi_trips = taxi_trips[taxi_trips["fare_amount"].between(0, 50, inclusive="right")] + + # If you are using partial ordering mode, you will also need to assign an order to your dataset. + # Otherwise, the next line can be skipped. + taxi_trips = taxi_trips.sort_values("pickup_datetime") + + taxi_trips.plot.scatter(x="trip_distance", y="fare_amount", alpha=0.5) + # [END bigquery_dataframes_data_visualization_taxi_scatter_plot] + + # [START bigquery_dataframes_data_visualization_noaa_sampling_n] + import bigframes.pandas as bpd + + noaa_surface = bpd.read_gbq("bigquery-public-data.noaa_gsod.gsod2021") + + # Calculate median temperature for each day + noaa_surface_median_temps = noaa_surface[["date", "temp"]].groupby("date").median() + + noaa_surface_median_temps.plot.line(sampling_n=40) + # [END bigquery_dataframes_data_visualization_noaa_sampling_n] + + # [START bigquery_dataframes_data_visualization_usa_names_subplots] + import bigframes.pandas as bpd + + usa_names = bpd.read_gbq("bigquery-public-data.usa_names.usa_1910_2013") + + # Count the occurences of the target names each year. The result is a dataframe with a multi-index. + name_counts = ( + usa_names[usa_names["name"].isin(("Mary", "Emily", "Lisa"))] + .groupby(("year", "name"))["number"] + .sum() + ) + + # Flatten the index of the dataframe so that the counts for each name has their own columns. + name_counts = name_counts.unstack(level=1).fillna(0) + + name_counts.plot.area(subplots=True, alpha=0.5) + # [END bigquery_dataframes_data_visualization_usa_names_subplots] + + # [START bigquery_dataframes_data_visualization_taxi_scatter_multidimension] + import bigframes.pandas as bpd + + taxi_trips = bpd.read_gbq( + "bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2021" + ).dropna() + + # Data Cleaning + taxi_trips = taxi_trips[ + taxi_trips["trip_distance"].between(0, 10, inclusive="right") + ] + taxi_trips = taxi_trips[taxi_trips["fare_amount"].between(0, 50, inclusive="right")] + + # If you are using partial ordering mode, you also need to assign an order to your dataset. + # Otherwise, the next line can be skipped. + taxi_trips = taxi_trips.sort_values("pickup_datetime") + + taxi_trips["passenger_count_scaled"] = taxi_trips["passenger_count"] * 30 + + taxi_trips.plot.scatter( + x="trip_distance", + xlabel="trip distance (miles)", + y="fare_amount", + ylabel="fare amount (usd)", + alpha=0.5, + s="passenger_count_scaled", + label="passenger_count", + c="tip_amount", + cmap="jet", + colorbar=True, + legend=True, + figsize=(15, 7), + sampling_n=1000, + ) + # [END bigquery_dataframes_data_visualization_taxi_scatter_multidimension] diff --git a/samples/snippets/explore_query_result_test.py b/samples/snippets/explore_query_result_test.py index 42f48fd94e..7d4b241e4c 100644 --- a/samples/snippets/explore_query_result_test.py +++ b/samples/snippets/explore_query_result_test.py @@ -14,9 +14,9 @@ def test_bigquery_dataframes_explore_query_result() -> None: + # [START bigquery_dataframes_explore_query_result] import bigframes.pandas as bpd - # [START bigquery_dataframes_explore_query_result] # Load data from BigQuery query_or_table = "bigquery-public-data.ml_datasets.penguins" bq_df = bpd.read_gbq(query_or_table) diff --git a/samples/snippets/gemini_model_test.py b/samples/snippets/gemini_model_test.py index 24b4e7d26d..fe5d7d5b1e 100644 --- a/samples/snippets/gemini_model_test.py +++ b/samples/snippets/gemini_model_test.py @@ -29,7 +29,9 @@ def test_gemini_text_generator_model() -> None: # Create the Gemini LLM model session = bpd.get_global_session() connection = f"{PROJECT_ID}.{REGION}.{CONN_NAME}" - model = GeminiTextGenerator(session=session, connection_name=connection) + model = GeminiTextGenerator( + session=session, connection_name=connection, model_name="gemini-2.0-flash-001" + ) df_api = bpd.read_csv("gs://cloud-samples-data/vertex-ai/bigframe/df.csv") diff --git a/samples/snippets/gen_ai_model_test.py b/samples/snippets/gen_ai_model_test.py deleted file mode 100644 index 5cdcd6d3a7..0000000000 --- a/samples/snippets/gen_ai_model_test.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -def test_llm_model() -> None: - # Determine project id, in this case prefer the one set in the environment - # variable GOOGLE_CLOUD_PROJECT (if any) - import os - - PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT", "bigframes-dev") - REGION = "us" - CONN_NAME = "bigframes-default-connection" - - # [START bigquery_dataframes_gen_ai_model] - from bigframes.ml.llm import PaLM2TextGenerator - import bigframes.pandas as bpd - - # Create the LLM model - session = bpd.get_global_session() - connection = f"{PROJECT_ID}.{REGION}.{CONN_NAME}" - model = PaLM2TextGenerator(session=session, connection_name=connection) - - df_api = bpd.read_csv("gs://cloud-samples-data/vertex-ai/bigframe/df.csv") - - # Prepare the prompts and send them to the LLM model for prediction - df_prompt_prefix = "Generate Pandas sample code for DataFrame." - df_prompt = df_prompt_prefix + df_api["API"] - - # Predict using the model - df_pred = model.predict(df_prompt.to_frame(), max_output_tokens=1024) - # [END bigquery_dataframes_gen_ai_model] - assert df_pred["ml_generate_text_llm_result"] is not None - assert df_pred["ml_generate_text_llm_result"].iloc[0] is not None diff --git a/samples/snippets/limit_single_timeseries_forecasting_model_test.py b/samples/snippets/limit_single_timeseries_forecasting_model_test.py new file mode 100644 index 0000000000..6a9f14e383 --- /dev/null +++ b/samples/snippets/limit_single_timeseries_forecasting_model_test.py @@ -0,0 +1,64 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (t +# you may not use this file except in compliance wi +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in +# distributed under the License is distributed on a +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, eit +# See the License for the specific language governi +# limitations under the License. + + +def test_limit_single_timeseries(random_model_id: str) -> None: + your_model_id = random_model_id + + # [START bigquery_dataframes_bqml_limit_forecast_visualize] + import bigframes.pandas as bpd + + df = bpd.read_gbq("bigquery-public-data.new_york.citibike_trips") + + features = bpd.DataFrame( + { + "num_trips": df.starttime, + "date": df["starttime"].dt.date, + } + ) + num_trips = features.groupby(["date"]).count() + + num_trips.plot.line() + # [END bigquery_dataframes_bqml_limit_forecast_visualize] + + # [START bigquery_dataframes_bqml_limit_forecast_create] + from bigframes.ml import forecasting + import bigframes.pandas as bpd + + df = bpd.read_gbq("bigquery-public-data.new_york.citibike_trips") + + features = bpd.DataFrame( + { + "start_station_id": df["start_station_id"], + "num_trips": df.starttime, + "date": df["starttime"].dt.date, + } + ) + num_trips = features.groupby(["date", "start_station_id"], as_index=False).count() + model = forecasting.ARIMAPlus() + + X = num_trips[["date"]] + y = num_trips[["num_trips"]] + id_col = num_trips[["start_station_id"]] + + model.fit(X, y, id_col=id_col) + + model.to_gbq( + your_model_id, # For example: "bqml_tutorial.nyc_citibike_arima_model", + replace=True, + ) + # [END bigquery_dataframes_bqml_limit_forecast_create] + assert df is not None + assert features is not None + assert num_trips is not None diff --git a/samples/snippets/linear_regression_tutorial_test.py b/samples/snippets/linear_regression_tutorial_test.py index 452d88746d..8fc1c5ad61 100644 --- a/samples/snippets/linear_regression_tutorial_test.py +++ b/samples/snippets/linear_regression_tutorial_test.py @@ -78,8 +78,48 @@ def test_linear_regression(random_model_id: str) -> None: # 332 4740.7907 Gentoo penguin (Pygoscelis papua) Biscoe 46.2 14.4 214.0 4650.0 # 160 4731.310452 Gentoo penguin (Pygoscelis papua) Biscoe 44.5 14.3 216.0 4100.0 # [END bigquery_dataframes_bqml_linear_predict] + # [START bigquery_dataframes_bqml_linear_predict_explain] + # Use 'predict_explain' function to understand why the model is generating these prediction results. + # 'predict_explain'is an extended version of the 'predict' function that not only outputs prediction results, but also outputs additional columns to explain the prediction results. + # Using the trained model and utilizing data specific to Biscoe Island, explain the predictions of the top 3 features + explained = model.predict_explain(biscoe_data, top_k_features=3) + + # Expected results: + # predicted_body_mass_g top_feature_attributions baseline_prediction_value prediction_value approximation_error species island culmen_length_mm culmen_depth_mm flipper_length_mm body_mass_g sex + # 0 5413.510134 [{'feature': 'island', 'attribution': 7348.877... -5320.222128 5413.510134 0.0 Gentoo penguin (Pygoscelis papua) Biscoe 45.2 16.4 223.0 5950.0 MALE + # 1 4768.351092 [{'feature': 'island', 'attribution': 7348.877... -5320.222128 4768.351092 0.0 Gentoo penguin (Pygoscelis papua) Biscoe 46.5 14.5 213.0 4400.0 FEMALE + # 2 3235.896372 [{'feature': 'island', 'attribution': 7348.877... -5320.222128 3235.896372 0.0 Adelie Penguin (Pygoscelis adeliae) Biscoe 37.7 16.0 183.0 3075.0 FEMALE + # 3 5349.603734 [{'feature': 'island', 'attribution': 7348.877... -5320.222128 5349.603734 0.0 Gentoo penguin (Pygoscelis papua) Biscoe 46.4 15.6 221.0 5000.0 MALE + # 4 4637.165037 [{'feature': 'island', 'attribution': 7348.877... -5320.222128 4637.165037 0.0 Gentoo penguin (Pygoscelis papua) Biscoe 46.1 13.2 211.0 4500.0 FEMALE + # [END bigquery_dataframes_bqml_linear_predict_explain] + # [START bigquery_dataframes_bqml_linear_global_explain] + # To use the `global_explain()` function, the model must be recreated with `enable_global_explain` set to `True`. + model = LinearRegression(enable_global_explain=True) + + # The model must the be fitted before it can be saved to BigQuery and then explained. + training_data = bq_df.dropna(subset=["body_mass_g"]) + X = training_data.drop(columns=["body_mass_g"]) + y = training_data[["body_mass_g"]] + model.fit(X, y) + model.to_gbq("bqml_tutorial.penguins_model", replace=True) + + # Explain the model + explain_model = model.global_explain() + + # Expected results: + # attribution + # feature + # island 5737.315921 + # species 4073.280549 + # sex 622.070896 + # flipper_length_mm 193.612051 + # culmen_depth_mm 117.084944 + # culmen_length_mm 94.366793 + # [END bigquery_dataframes_bqml_linear_global_explain] + assert explain_model is not None assert feature_columns is not None assert label_columns is not None assert model is not None assert score is not None assert result is not None + assert explained is not None diff --git a/samples/snippets/mf_explicit_model_test.py b/samples/snippets/mf_explicit_model_test.py new file mode 100644 index 0000000000..fb54b7271c --- /dev/null +++ b/samples/snippets/mf_explicit_model_test.py @@ -0,0 +1,162 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (t +# you may not use this file except in compliance wi +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in +# distributed under the License is distributed on a +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, eit +# See the License for the specific language governi +# limitations under the License. + + +def test_explicit_matrix_factorization(random_model_id: str) -> None: + your_model_id = random_model_id + + # [START bigquery_dataframes_bqml_mf_explicit_create_dataset] + import google.cloud.bigquery + + bqclient = google.cloud.bigquery.Client() + bqclient.create_dataset("bqml_tutorial", exists_ok=True) + # [END bigquery_dataframes_bqml_mf_explicit_create_dataset] + + # [START bigquery_dataframes_bqml_mf_explicit_upload_movielens] + import io + import zipfile + + import google.api_core.exceptions + import requests + + try: + # Check if you've already created the Movielens tables to avoid downloading + # and uploading the dataset unnecessarily. + bqclient.get_table("bqml_tutorial.ratings") + bqclient.get_table("bqml_tutorial.movies") + except google.api_core.exceptions.NotFound: + # Download the https://grouplens.org/datasets/movielens/1m/ dataset. + ml1m = requests.get("http://files.grouplens.org/datasets/movielens/ml-1m.zip") + ml1m_file = io.BytesIO(ml1m.content) + ml1m_zip = zipfile.ZipFile(ml1m_file) + + # Upload the ratings data into the ratings table. + with ml1m_zip.open("ml-1m/ratings.dat") as ratings_file: + ratings_content = ratings_file.read() + + ratings_csv = io.BytesIO(ratings_content.replace(b"::", b",")) + ratings_config = google.cloud.bigquery.LoadJobConfig() + ratings_config.source_format = "CSV" + ratings_config.write_disposition = "WRITE_TRUNCATE" + ratings_config.schema = [ + google.cloud.bigquery.SchemaField("user_id", "INT64"), + google.cloud.bigquery.SchemaField("item_id", "INT64"), + google.cloud.bigquery.SchemaField("rating", "FLOAT64"), + google.cloud.bigquery.SchemaField("timestamp", "TIMESTAMP"), + ] + bqclient.load_table_from_file( + ratings_csv, "bqml_tutorial.ratings", job_config=ratings_config + ).result() + + # Upload the movie data into the movies table. + with ml1m_zip.open("ml-1m/movies.dat") as movies_file: + movies_content = movies_file.read() + + movies_csv = io.BytesIO(movies_content.replace(b"::", b"@")) + movies_config = google.cloud.bigquery.LoadJobConfig() + movies_config.source_format = "CSV" + movies_config.field_delimiter = "@" + movies_config.write_disposition = "WRITE_TRUNCATE" + movies_config.schema = [ + google.cloud.bigquery.SchemaField("movie_id", "INT64"), + google.cloud.bigquery.SchemaField("movie_title", "STRING"), + google.cloud.bigquery.SchemaField("genre", "STRING"), + ] + bqclient.load_table_from_file( + movies_csv, "bqml_tutorial.movies", job_config=movies_config + ).result() + # [END bigquery_dataframes_bqml_mf_explicit_upload_movielens] + + # [START bigquery_dataframes_bqml_mf_explicit_create] + from bigframes.ml import decomposition + import bigframes.pandas as bpd + + # Load data from BigQuery + bq_df = bpd.read_gbq( + "bqml_tutorial.ratings", columns=("user_id", "item_id", "rating") + ) + + # Create the Matrix Factorization model + model = decomposition.MatrixFactorization( + num_factors=34, + feedback_type="explicit", + user_col="user_id", + item_col="item_id", + rating_col="rating", + l2_reg=9.83, + ) + model.fit(bq_df) + model.to_gbq( + your_model_id, replace=True # For example: "bqml_tutorial.mf_explicit" + ) + # [END bigquery_dataframes_bqml_mf_explicit_create] + # [START bigquery_dataframes_bqml_mf_explicit_evaluate] + # Evaluate the model using the score() function + model.score(bq_df) + # Output: + # mean_absolute_error mean_squared_error mean_squared_log_error median_absolute_error r2_score explained_variance + # 0.485403 0.395052 0.025515 0.390573 0.68343 0.68343 + # [END bigquery_dataframes_bqml_mf_explicit_evaluate] + # [START bigquery_dataframes_bqml_mf_explicit_recommend_df] + # Use predict() to get the predicted rating for each movie for 5 users + subset = bq_df[["user_id"]].head(5) + predicted = model.predict(subset) + print(predicted) + # Output: + # predicted_rating user_id item_id rating + # 0 4.206146 4354 968 4.0 + # 1 4.853099 3622 3521 5.0 + # 2 2.679067 5543 920 2.0 + # 3 4.323458 445 3175 5.0 + # 4 3.476911 5535 235 4.0 + # [END bigquery_dataframes_bqml_mf_explicit_recommend_df] + # [START bigquery_dataframes_bqml_mf_explicit_recommend_model] + # import bigframes.bigquery as bbq + + # Load movies + movies = bpd.read_gbq("bqml_tutorial.movies") + + # Merge the movies df with the previously created predicted df + merged_df = bpd.merge(predicted, movies, left_on="item_id", right_on="movie_id") + + # Separate users and predicted data, setting the index to 'movie_id' + users = merged_df[["user_id", "movie_id"]].set_index("movie_id") + + # Take the predicted data and sort it in descending order by 'predicted_rating', setting the index to 'movie_id' + sort_data = ( + merged_df[["movie_title", "genre", "predicted_rating", "movie_id"]] + .sort_values(by="predicted_rating", ascending=False) + .set_index("movie_id") + ) + + # re-merge the separated dfs by index + merged_user = sort_data.join(users, how="outer") + + # group the users and set the user_id as the index + merged_user.groupby("user_id").head(5).set_index("user_id").sort_index() + print(merged_user) + # Output: + # movie_title genre predicted_rating + # user_id + # 1 Saving Private Ryan (1998) Action|Drama|War 5.19326 + # 1 Fargo (1996) Crime|Drama|Thriller 4.996954 + # 1 Driving Miss Daisy (1989) Drama 4.983671 + # 1 Ben-Hur (1959) Action|Adventure|Drama 4.877622 + # 1 Schindler's List (1993) Drama|War 4.802336 + # 2 Saving Private Ryan (1998) Action|Drama|War 5.19326 + # 2 Braveheart (1995) Action|Drama|War 5.174145 + # 2 Gladiator (2000) Action|Drama 5.066372 + # 2 On Golden Pond (1981) Drama 5.01198 + # 2 Driving Miss Daisy (1989) Drama 4.983671 + # [END bigquery_dataframes_bqml_mf_explicit_recommend_model] diff --git a/samples/snippets/multimodal_test.py b/samples/snippets/multimodal_test.py new file mode 100644 index 0000000000..033fead33e --- /dev/null +++ b/samples/snippets/multimodal_test.py @@ -0,0 +1,125 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def test_multimodal_dataframe(gcs_bucket_snippets: str) -> None: + # destination folder must be in a GCS bucket that the BQ connection service account (default or user provided) has write access to. + dst_bucket = f"gs://{gcs_bucket_snippets}" + # [START bigquery_dataframes_multimodal_dataframe_create] + import bigframes + + # Flags to control preview image/video preview size + bigframes.options.display.blob_display_width = 300 + + import bigframes.pandas as bpd + + # Create blob columns from wildcard path. + df_image = bpd.from_glob_path( + "gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/images/*", name="image" + ) + # Other ways are: from string uri column + # df = bpd.DataFrame({"uri": ["gs:///", "gs:///"]}) + # df["blob_col"] = df["uri"].str.to_blob() + + # From an existing object table + # df = bpd.read_gbq_object_table("", name="blob_col") + + # Take only the 5 images to deal with. Preview the content of the Mutimodal DataFrame + df_image = df_image.head(5) + df_image + # [END bigquery_dataframes_multimodal_dataframe_create] + + # [START bigquery_dataframes_multimodal_dataframe_merge] + # Combine unstructured data with structured data + df_image["author"] = ["alice", "bob", "bob", "alice", "bob"] # type: ignore + df_image["content_type"] = df_image["image"].blob.content_type() + df_image["size"] = df_image["image"].blob.size() + df_image["updated"] = df_image["image"].blob.updated() + df_image + # [END bigquery_dataframes_multimodal_dataframe_merge] + + # [START bigquery_dataframes_multimodal_dataframe_filter] + # Filter images and display, you can also display audio and video types. Use width/height parameters to constrain window sizes. + df_image[df_image["author"] == "alice"]["image"].blob.display() + # [END bigquery_dataframes_multimodal_dataframe_filter] + + # [START bigquery_dataframes_multimodal_dataframe_image_transform] + df_image["blurred"] = df_image["image"].blob.image_blur( + (20, 20), dst=f"{dst_bucket}/image_blur_transformed/", engine="opencv" + ) + df_image["resized"] = df_image["image"].blob.image_resize( + (300, 200), dst=f"{dst_bucket}/image_resize_transformed/", engine="opencv" + ) + df_image["normalized"] = df_image["image"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + dst=f"{dst_bucket}/image_normalize_transformed/", + engine="opencv", + ) + + # You can also chain functions together + df_image["blur_resized"] = df_image["blurred"].blob.image_resize( + (300, 200), dst=f"{dst_bucket}/image_blur_resize_transformed/", engine="opencv" + ) + df_image + # [END bigquery_dataframes_multimodal_dataframe_image_transform] + + # [START bigquery_dataframes_multimodal_dataframe_ml_text] + from bigframes.ml import llm + + gemini = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001") + + # Deal with first 2 images as example + df_image = df_image.head(2) + + # Ask the same question on the images + df_image = df_image.head(2) + answer = gemini.predict(df_image, prompt=["what item is it?", df_image["image"]]) + answer[["ml_generate_text_llm_result", "image"]] + # [END bigquery_dataframes_multimodal_dataframe_ml_text] + + # [START bigquery_dataframes_multimodal_dataframe_ml_text_alt] + # Ask different questions + df_image["question"] = [ # type: ignore + "what item is it?", + "what color is the picture?", + ] + answer_alt = gemini.predict( + df_image, prompt=[df_image["question"], df_image["image"]] + ) + answer_alt[["ml_generate_text_llm_result", "image"]] + # [END bigquery_dataframes_multimodal_dataframe_ml_text_alt] + + # [START bigquery_dataframes_multimodal_dataframe_ml_embed] + # Generate embeddings on images + embed_model = llm.MultimodalEmbeddingGenerator() + embeddings = embed_model.predict(df_image["image"]) + embeddings + # [END bigquery_dataframes_multimodal_dataframe_ml_embed] + + # [START bigquery_dataframes_multimodal_dataframe_pdf_chunk] + # PDF chunking + df_pdf = bpd.from_glob_path( + "gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/documents/*", name="pdf" + ) + df_pdf["chunked"] = df_pdf["pdf"].blob.pdf_chunk(engine="pypdf") + chunked = df_pdf["chunked"].explode() + chunked + # [END bigquery_dataframes_multimodal_dataframe_pdf_chunk] + assert df_image is not None + assert answer is not None + assert answer_alt is not None + assert embeddings is not None + assert chunked is not None diff --git a/samples/snippets/performance_optimizations_test.py b/samples/snippets/performance_optimizations_test.py new file mode 100644 index 0000000000..43e14e31cc --- /dev/null +++ b/samples/snippets/performance_optimizations_test.py @@ -0,0 +1,52 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def test_performance_optimizations() -> None: + # [START bigquery_bigframes_use_peek_to_preview_data] + import bigframes.pandas as bpd + + # Read the "Penguins" table into a dataframe + df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + + # Preview 3 random rows + df.peek(3) + # [END bigquery_bigframes_use_peek_to_preview_data] + assert df.peek(3) is not None + + import bigframes.pandas as bpd + + users = bpd.DataFrame({"user_name": ["John"]}) + groups = bpd.DataFrame({"group_id": ["group_1"]}) + transactions = bpd.DataFrame({"amount": [3], "completed": [True]}) + + # [START bigquery_bigframes_use_cache_after_expensive_operations] + # Assume you have 3 large dataframes "users", "group" and "transactions" + + # Expensive join operations + final_df = users.join(groups).join(transactions) + final_df.cache() + # Subsequent derived results will reuse the cached join + print(final_df.peek()) + print(len(final_df[final_df["completed"]])) + print(final_df.groupby("group_id")["amount"].mean().peek(30)) + # [END bigquery_bigframes_use_cache_after_expensive_operations] + assert final_df is not None + + # [START bigquery_bigframes_enable_deferred_repr_for_debugging] + import bigframes.pandas as bpd + + bpd.options.display.repr_mode = "deferred" + # [END bigquery_bigframes_enable_deferred_repr_for_debugging] + assert bpd.options.display.repr_mode == "deferred" diff --git a/samples/snippets/quickstart.py b/samples/snippets/quickstart.py index c26c6f4442..08662c1ea7 100644 --- a/samples/snippets/quickstart.py +++ b/samples/snippets/quickstart.py @@ -14,18 +14,9 @@ def run_quickstart(project_id: str) -> None: - import bigframes - - session_options = bigframes.BigQueryOptions() - session = bigframes.connect(session_options) - your_gcp_project_id = project_id - query_or_table = "bigquery-public-data.ml_datasets.penguins" - df_session = session.read_gbq(query_or_table) - average_body_mass = df_session["body_mass_g"].mean() - print(f"average_body_mass (df_session): {average_body_mass}") - # [START bigquery_bigframes_quickstart] + # [START bigquery_bigframes_quickstart_create_dataframe] import bigframes.pandas as bpd # Set BigQuery DataFrames options @@ -33,15 +24,29 @@ def run_quickstart(project_id: str) -> None: # On BigQuery Studio, the project ID is automatically detected. bpd.options.bigquery.project = your_gcp_project_id + # Use "partial" ordering mode to generate more efficient queries, but the + # order of the rows in DataFrames may not be deterministic if you have not + # explictly sorted it. Some operations that depend on the order, such as + # head() will not function until you explictly order the DataFrame. Set the + # ordering mode to "strict" (default) for more pandas compatibility. + bpd.options.bigquery.ordering_mode = "partial" + # Create a DataFrame from a BigQuery table query_or_table = "bigquery-public-data.ml_datasets.penguins" df = bpd.read_gbq(query_or_table) + # Efficiently preview the results using the .peek() method. + df.peek() + # [END bigquery_bigframes_quickstart_create_dataframe] + + # [START bigquery_bigframes_quickstart_calculate_print] # Use the DataFrame just as you would a pandas DataFrame, but calculations # happen in the BigQuery query engine instead of the local system. average_body_mass = df["body_mass_g"].mean() print(f"average_body_mass: {average_body_mass}") + # [END bigquery_bigframes_quickstart_calculate_print] + # [START bigquery_bigframes_quickstart_eval_metrics] # Create the Linear Regression model from bigframes.ml.linear_model import LinearRegression @@ -69,4 +74,8 @@ def run_quickstart(project_id: str) -> None: model = LinearRegression(fit_intercept=False) model.fit(X, y) model.score(X, y) - # [END bigquery_bigframes_quickstart] + # [END bigquery_bigframes_quickstart_eval_metrics] + + # close session and reset option so not to affect other tests + bpd.close_session() + bpd.options.reset() diff --git a/samples/snippets/quickstart_test.py b/samples/snippets/quickstart_test.py index 4abc87d011..a650f8365d 100644 --- a/samples/snippets/quickstart_test.py +++ b/samples/snippets/quickstart_test.py @@ -33,4 +33,4 @@ def test_quickstart( quickstart.run_quickstart(your_project_id) out, _ = capsys.readouterr() - assert "average_body_mass (df_session):" in out + assert "average_body_mass:" in out diff --git a/samples/snippets/remote_function.py b/samples/snippets/remote_function.py index c35daf35fc..4c5b365007 100644 --- a/samples/snippets/remote_function.py +++ b/samples/snippets/remote_function.py @@ -21,7 +21,7 @@ def run_remote_function_and_read_gbq_function(project_id: str) -> None: # Set BigQuery DataFrames options bpd.options.bigquery.project = your_gcp_project_id - bpd.options.bigquery.location = "us" + bpd.options.bigquery.location = "US" # BigQuery DataFrames gives you the ability to turn your custom scalar # functions into a BigQuery remote function. It requires the GCP project to @@ -47,9 +47,8 @@ def run_remote_function_and_read_gbq_function(project_id: str) -> None: # of the penguins, which is a real number, into a category, which is a # string. @bpd.remote_function( - float, - str, reuse=False, + cloud_function_service_account="default", ) def get_bucket(num: float) -> str: if not num: @@ -57,7 +56,7 @@ def get_bucket(num: float) -> str: boundary = 4000 return "at_or_above_4000" if num >= boundary else "below_4000" - # Then we can apply the remote function on the `Series`` of interest via + # Then we can apply the remote function on the `Series` of interest via # `apply` API and store the result in a new column in the DataFrame. df = df.assign(body_mass_bucket=df["body_mass_g"].apply(get_bucket)) @@ -91,10 +90,9 @@ def get_bucket(num: float) -> str: # as a remote function. The custom function in this example has external # package dependency, which can be specified via `packages` parameter. @bpd.remote_function( - str, - str, reuse=False, packages=["cryptography"], + cloud_function_service_account="default", ) def get_hash(input: str) -> str: from cryptography.fernet import Fernet diff --git a/samples/snippets/sessions_and_io_test.py b/samples/snippets/sessions_and_io_test.py new file mode 100644 index 0000000000..06f0c4ab3c --- /dev/null +++ b/samples/snippets/sessions_and_io_test.py @@ -0,0 +1,180 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def test_sessions_and_io(project_id: str, dataset_id: str, gcs_bucket: str) -> None: + YOUR_PROJECT_ID = project_id + YOUR_DATASET_ID = dataset_id + YOUR_LOCATION = "us" + YOUR_BUCKET = gcs_bucket + + # [START bigquery_dataframes_create_and_use_session_instance] + import bigframes + import bigframes.pandas as bpd + + # Create session object + context = bigframes.BigQueryOptions( + project=YOUR_PROJECT_ID, + location=YOUR_LOCATION, + ) + session = bigframes.Session(context) + + # Load a BigQuery table into a dataframe + df1 = session.read_gbq("bigquery-public-data.ml_datasets.penguins") + + # Create a dataframe with local data: + df2 = bpd.DataFrame({"my_col": [1, 2, 3]}, session=session) + # [END bigquery_dataframes_create_and_use_session_instance] + assert df1 is not None + assert df2 is not None + + # [START bigquery_dataframes_combine_data_from_multiple_sessions_raise_error] + import bigframes + import bigframes.pandas as bpd + + context = bigframes.BigQueryOptions(location=YOUR_LOCATION, project=YOUR_PROJECT_ID) + + session1 = bigframes.Session(context) + session2 = bigframes.Session(context) + + series1 = bpd.Series([1, 2, 3, 4, 5], session=session1) + series2 = bpd.Series([1, 2, 3, 4, 5], session=session2) + + try: + series1 + series2 + except ValueError as e: + print(e) # Error message: Cannot use combine sources from multiple sessions + # [END bigquery_dataframes_combine_data_from_multiple_sessions_raise_error] + + # [START bigquery_dataframes_set_options_for_global_session] + import bigframes.pandas as bpd + + # Set project ID for the global session + bpd.options.bigquery.project = YOUR_PROJECT_ID + # Update the global default session location + bpd.options.bigquery.location = YOUR_LOCATION + # [END bigquery_dataframes_set_options_for_global_session] + + # [START bigquery_dataframes_global_session_is_the_default_session] + # The following two statements are essentially the same + df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + df = bpd.get_global_session().read_gbq("bigquery-public-data.ml_datasets.penguins") + # [END bigquery_dataframes_global_session_is_the_default_session] + assert df is not None + + # [START bigquery_dataframes_create_dataframe_from_py_and_np] + import numpy as np + + import bigframes.pandas as bpd + + s = bpd.Series([1, 2, 3]) + + # Create a dataframe with Python dict + df = bpd.DataFrame( + { + "col_1": [1, 2, 3], + "col_2": [4, 5, 6], + } + ) + + # Create a series with Numpy + s = bpd.Series(np.arange(10)) + # [END bigquery_dataframes_create_dataframe_from_py_and_np] + assert s is not None + + # [START bigquery_dataframes_create_dataframe_from_pandas] + import numpy as np + import pandas as pd + + import bigframes.pandas as bpd + + pd_df = pd.DataFrame(np.random.randn(4, 2)) + + # Convert Pandas dataframe to BigQuery DataFrame with read_pandas() + df_1 = bpd.read_pandas(pd_df) + # Convert Pandas dataframe to BigQuery DataFrame with the dataframe constructor + df_2 = bpd.DataFrame(pd_df) + # [END bigquery_dataframes_create_dataframe_from_pandas] + assert df_1 is not None + assert df_2 is not None + + # [START bigquery_dataframes_convert_bq_dataframe_to_pandas] + import bigframes.pandas as bpd + + bf_df = bpd.DataFrame({"my_col": [1, 2, 3]}) + # Returns a Pandas Dataframe + bf_df.to_pandas() + + bf_s = bpd.Series([1, 2, 3]) + # Returns a Pandas Series + bf_s.to_pandas() + # [END bigquery_dataframes_convert_bq_dataframe_to_pandas] + assert bf_s.to_pandas() is not None + + # [START bigquery_dataframes_to_pandas_dry_run] + import bigframes.pandas as bpd + + df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + + # Returns a Pandas series with dry run stats + df.to_pandas(dry_run=True) + # [END bigquery_dataframes_to_pandas_dry_run] + assert df.to_pandas(dry_run=True) is not None + + # [START bigquery_dataframes_read_data_from_csv] + import bigframes.pandas as bpd + + # Read a CSV file from GCS + df = bpd.read_csv("gs://cloud-samples-data/bigquery/us-states/us-states.csv") + # [END bigquery_dataframes_read_data_from_csv] + assert df is not None + + # [START bigquery_dataframes_write_data_to_csv] + import bigframes.pandas as bpd + + df = bpd.DataFrame({"my_col": [1, 2, 3]}) + # Write a dataframe to a CSV file in GCS + df.to_csv(f"gs://{YOUR_BUCKET}/myfile*.csv") + # [END bigquery_dataframes_write_data_to_csv] + assert df is not None + + # [START bigquery_dataframes_read_data_from_bigquery_table] + import bigframes.pandas as bpd + + df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + # [END bigquery_dataframes_read_data_from_bigquery_table] + assert df is not None + + # [START bigquery_dataframes_read_from_sql_query] + import bigframes.pandas as bpd + + sql = """ + SELECT species, island, body_mass_g + FROM bigquery-public-data.ml_datasets.penguins + WHERE sex = 'MALE' + """ + + df = bpd.read_gbq(sql) + # [END bigquery_dataframes_read_from_sql_query] + assert df is not None + + YOUR_TABLE_NAME = "snippets-session-and-io-test" + + # [START bigquery_dataframes_dataframe_to_bigquery_table] + import bigframes.pandas as bpd + + df = bpd.DataFrame({"my_col": [1, 2, 3]}) + + df.to_gbq(f"{YOUR_PROJECT_ID}.{YOUR_DATASET_ID}.{YOUR_TABLE_NAME}") + # [END bigquery_dataframes_dataframe_to_bigquery_table] diff --git a/samples/snippets/set_options_test.py b/samples/snippets/set_options_test.py index 3dea524a17..6007dcbb38 100644 --- a/samples/snippets/set_options_test.py +++ b/samples/snippets/set_options_test.py @@ -19,23 +19,27 @@ def test_bigquery_dataframes_set_options() -> None: bpd.close_session() - # [START bigquery_dataframes_set_options] - import bigframes.pandas as bpd - - PROJECT_ID = "bigframes-dec" # @param {type:"string"} - REGION = "US" # @param {type:"string"} - - # Set BigQuery DataFrames options - # Note: The project option is not required in all environments. - # On BigQuery Studio, the project ID is automatically detected. - bpd.options.bigquery.project = PROJECT_ID - - # Note: The location option is not required. - # It defaults to the location of the first table or query - # passed to read_gbq(). For APIs where a location can't be - # auto-detected, the location defaults to the "US" location. - bpd.options.bigquery.location = REGION - - # [END bigquery_dataframes_set_options] - assert bpd.options.bigquery.project == PROJECT_ID - assert bpd.options.bigquery.location == REGION + try: + # [START bigquery_dataframes_set_options] + import bigframes.pandas as bpd + + PROJECT_ID = "bigframes-dev" # @param {type:"string"} + REGION = "US" # @param {type:"string"} + + # Set BigQuery DataFrames options + # Note: The project option is not required in all environments. + # On BigQuery Studio, the project ID is automatically detected. + bpd.options.bigquery.project = PROJECT_ID + + # Note: The location option is not required. + # It defaults to the location of the first table or query + # passed to read_gbq(). For APIs where a location can't be + # auto-detected, the location defaults to the "US" location. + bpd.options.bigquery.location = REGION + + # [END bigquery_dataframes_set_options] + assert bpd.options.bigquery.project == PROJECT_ID + assert bpd.options.bigquery.location == REGION + finally: + bpd.close_session() + bpd.options.reset() diff --git a/samples/snippets/st_regionstats_test.py b/samples/snippets/st_regionstats_test.py new file mode 100644 index 0000000000..f0f4963a82 --- /dev/null +++ b/samples/snippets/st_regionstats_test.py @@ -0,0 +1,80 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Code sample for https://docs.cloud.google.com/bigquery/docs/raster-data#analytics-hub-source""" + + +def test_st_regionstats() -> None: + project_id = "bigframes-dev" + + # [START bigquery_dataframes_st_regionstats] + import datetime + from typing import cast + + import bigframes.bigquery as bbq + import bigframes.pandas as bpd + + # TODO: Set the project_id to your Google Cloud project ID. + # project_id = "your-project-id" + bpd.options.bigquery.project = project_id + + # TODO: Set the dataset_id to the ID of the dataset that contains the + # `climate` table. This is likely a linked dataset to Earth Engine. + # See: https://cloud.google.com/bigquery/docs/link-earth-engine + linked_dataset = "era5_land_daily_aggregated" + + # For the best efficiency, use partial ordering mode. + bpd.options.bigquery.ordering_mode = "partial" + + # Load the table of country boundaries. + countries = bpd.read_gbq("bigquery-public-data.overture_maps.division_area") + + # Filter to just the countries. + countries = countries[countries["subtype"] == "country"].copy() + countries["name"] = countries["names"].struct.field("primary") + countries["simplified_geometry"] = bbq.st_simplify( + countries["geometry"], + tolerance_meters=10_000, + ) + + # Get the reference to the temperature data from a linked dataset. + # Note: This sample assumes you have a linked dataset to Earth Engine. + image_href = ( + bpd.read_gbq(f"{project_id}.{linked_dataset}.climate") + .set_index("start_datetime") + .loc[[datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)], :] + ) + raster_id = image_href["assets"].struct.field("image").struct.field("href") + raster_id = raster_id.item() + stats = bbq.st_regionstats( + countries["simplified_geometry"], + raster_id=cast(str, raster_id), + band="temperature_2m", + ) + + # Extract the mean and convert from Kelvin to Celsius. + countries["mean_temperature"] = stats.struct.field("mean") - 273.15 + + # Sort by the mean temperature to find the warmest countries. + result = countries[["name", "mean_temperature"]].sort_values( + "mean_temperature", ascending=False + ) + print(result.head(10)) + # [END bigquery_dataframes_st_regionstats] + + assert len(result) > 0 + + +if __name__ == "__main__": + test_st_regionstats() diff --git a/samples/snippets/text_generation_test.py b/samples/snippets/text_generation_test.py deleted file mode 100644 index c4df1dde3b..0000000000 --- a/samples/snippets/text_generation_test.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -def test_llm_text_generation() -> None: - # Determine project id, in this case prefer the one set in the environment - # variable GOOGLE_CLOUD_PROJECT (if any) - import os - - PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT", "bigframes-dev") - LOCATION = "US" - - # [START bigquery_dataframes_generate_text_tutorial_create_remote_model] - import bigframes - from bigframes.ml.llm import PaLM2TextGenerator - - bigframes.options.bigquery.project = PROJECT_ID - bigframes.options.bigquery.location = LOCATION - - model = PaLM2TextGenerator() - # [END bigquery_dataframes_generate_text_tutorial_create_remote_model] - assert model is not None - - # [START bigquery_dataframes_generate_text_tutorial_perform_keyword_extraction] - import bigframes.pandas as bpd - - df = bpd.read_gbq("bigquery-public-data.imdb.reviews", max_results=5) - df_prompt_prefix = "Extract the key words from the text below: " - df_prompt = df_prompt_prefix + df["review"] - - # Predict using the model - df_pred = model.predict(df_prompt, temperature=0.2, max_output_tokens=100) - df_pred.peek(5) - # [END bigquery_dataframes_generate_text_tutorial_perform_keyword_extraction] - # peek() is used to show a preview of the results. If the output - # of this sample changes, also update the screenshot for the associated - # tutorial on cloud.google.com. - assert df_pred["ml_generate_text_llm_result"] is not None - assert df_pred["ml_generate_text_llm_result"].iloc[0] is not None - - # [START bigquery_dataframes_generate_text_tutorial_perform_sentiment_analysis] - import bigframes.pandas as bpd - - df = bpd.read_gbq("bigquery-public-data.imdb.reviews", max_results=5) - df_prompt_prefix = "perform sentiment analysis on the following text, return one the following categories: positive, negative: " - df_prompt = df_prompt_prefix + df["review"] - - # Predict using the model - df_pred = model.predict(df_prompt, temperature=0.2, max_output_tokens=100) - df_pred.peek(5) - # [END bigquery_dataframes_generate_text_tutorial_perform_sentiment_analysis] - # peek() is used to show a preview of the results. If the output - # of this sample changes, also update the screenshot for the associated - # tutorial on cloud.google.com. - - assert df_pred["ml_generate_text_llm_result"] is not None - assert df_pred["ml_generate_text_llm_result"].iloc[0] is not None diff --git a/samples/snippets/type_system_test.py b/samples/snippets/type_system_test.py new file mode 100644 index 0000000000..88b9e74742 --- /dev/null +++ b/samples/snippets/type_system_test.py @@ -0,0 +1,235 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas.testing + +from bigframes import dtypes + + +def test_type_system_examples() -> None: + # [START bigquery_dataframes_type_sytem_timestamp_local_type_conversion] + import pandas as pd + + import bigframes.pandas as bpd + + s = pd.Series([pd.Timestamp("20250101")]) + assert s.dtype == "datetime64[ns]" + assert bpd.read_pandas(s).dtype == "timestamp[us][pyarrow]" + # [END bigquery_dataframes_type_sytem_timestamp_local_type_conversion] + + # [START bigquery_dataframes_type_system_pyarrow_preference] + import datetime + + import pandas as pd + + import bigframes.pandas as bpd + + s = pd.Series([datetime.date(2025, 1, 1)]) + s + pd.Timedelta(hours=12) + # 0 2025-01-01 + # dtype: object + + bpd.read_pandas(s) + pd.Timedelta(hours=12) + # 0 2025-01-01 12:00:00 + # dtype: timestamp[us][pyarrow] + # [END bigquery_dataframes_type_system_pyarrow_preference] + pandas.testing.assert_series_equal( + s + pd.Timedelta(hours=12), pd.Series([datetime.date(2025, 1, 1)]) + ) + pandas.testing.assert_series_equal( + (bpd.read_pandas(s) + pd.Timedelta(hours=12)).to_pandas(), + pd.Series([pd.Timestamp(2025, 1, 1, 12)], dtype=dtypes.DATETIME_DTYPE), + check_index_type=False, + ) + + # [START bigquery_dataframes_type_system_load_timedelta] + import pandas as pd + + import bigframes.pandas as bpd + + s = pd.Series([pd.Timedelta("1s"), pd.Timedelta("2m")]) + bpd.read_pandas(s) + # 0 0 days 00:00:01 + # 1 0 days 00:02:00 + # dtype: duration[us][pyarrow] + # [END bigquery_dataframes_type_system_load_timedelta] + pandas.testing.assert_series_equal( + bpd.read_pandas(s).to_pandas(), + s.astype(dtypes.TIMEDELTA_DTYPE), + check_index_type=False, + ) + + # [START bigquery_dataframes_type_system_timedelta_precision] + import pandas as pd + + s = pd.Series([pd.Timedelta("999ns")]) + bpd.read_pandas(s.dt.round("us")) + # 0 0 days 00:00:00.000001 + # dtype: duration[us][pyarrow] + # [END bigquery_dataframes_type_system_timedelta_precision] + pandas.testing.assert_series_equal( + bpd.read_pandas(s.dt.round("us")).to_pandas(), + s.dt.round("us").astype(dtypes.TIMEDELTA_DTYPE), + check_index_type=False, + ) + + # [START bigquery_dataframes_type_system_cast_timedelta] + import bigframes.pandas as bpd + + bpd.to_timedelta([1, 2, 3], unit="s") + # 0 0 days 00:00:01 + # 1 0 days 00:00:02 + # 2 0 days 00:00:03 + # dtype: duration[us][pyarrow] + # [END bigquery_dataframes_type_system_cast_timedelta] + pandas.testing.assert_series_equal( + bpd.to_timedelta([1, 2, 3], unit="s").to_pandas(), + pd.Series(pd.to_timedelta([1, 2, 3], unit="s"), dtype=dtypes.TIMEDELTA_DTYPE), + check_index_type=False, + ) + + # [START bigquery_dataframes_type_system_list_accessor] + import bigframes.pandas as bpd + + s = bpd.Series([[1, 2, 3], [4, 5], [6]]) # dtype: list[pyarrow] + + # Access the first elements of each list + s.list[0] + # 0 1 + # 1 4 + # 2 6 + # dtype: Int64 + + # Get the lengths of each list + s.list.len() + # 0 3 + # 1 2 + # 2 1 + # dtype: Int64 + # [END bigquery_dataframes_type_system_list_accessor] + pandas.testing.assert_series_equal( + s.list[0].to_pandas(), + pd.Series([1, 4, 6], dtype="Int64"), + check_index_type=False, + ) + pandas.testing.assert_series_equal( + s.list.len().to_pandas(), + pd.Series([3, 2, 1], dtype="Int64"), + check_index_type=False, + ) + + # [START bigquery_dataframes_type_system_struct_accessor] + import bigframes.pandas as bpd + + structs = [ + {"id": 101, "category": "A"}, + {"id": 102, "category": "B"}, + {"id": 103, "category": "C"}, + ] + s = bpd.Series(structs) + # Get the 'id' field of each struct + s.struct.field("id") + # 0 101 + # 1 102 + # 2 103 + # Name: id, dtype: Int64 + # [END bigquery_dataframes_type_system_struct_accessor] + + # [START bigquery_dataframes_type_system_struct_accessor_shortcut] + import bigframes.pandas as bpd + + structs = [ + {"id": 101, "category": "A"}, + {"id": 102, "category": "B"}, + {"id": 103, "category": "C"}, + ] + s = bpd.Series(structs) + + # not explicitly using the "struct" property + s.id + # 0 101 + # 1 102 + # 2 103 + # Name: id, dtype: Int64 + # [END bigquery_dataframes_type_system_struct_accessor_shortcut] + pandas.testing.assert_series_equal( + s.struct.field("id").to_pandas(), + pd.Series([101, 102, 103], dtype="Int64", name="id"), + check_index_type=False, + ) + pandas.testing.assert_series_equal( + s.id.to_pandas(), + pd.Series([101, 102, 103], dtype="Int64", name="id"), + check_index_type=False, + ) + + # [START bigquery_dataframes_type_system_string_accessor] + import bigframes.pandas as bpd + + s = bpd.Series(["abc", "de", "1"]) # dtype: string[pyarrow] + + # Get the first character of each string + s.str[0] + # 0 a + # 1 d + # 2 1 + # dtype: string + + # Check whether there are only alphabetic characters in each string + s.str.isalpha() + # 0 True + # 1 True + # 2 False + # dtype: boolean + + # Cast the alphabetic characters to their upper cases for each string + s.str.upper() + # 0 ABC + # 1 DE + # 2 1 + # dtype: string + # [END bigquery_dataframes_type_system_string_accessor] + pandas.testing.assert_series_equal( + s.str[0].to_pandas(), + pd.Series(["a", "d", "1"], dtype=dtypes.STRING_DTYPE), + check_index_type=False, + ) + pandas.testing.assert_series_equal( + s.str.isalpha().to_pandas(), + pd.Series([True, True, False], dtype=dtypes.BOOL_DTYPE), + check_index_type=False, + ) + pandas.testing.assert_series_equal( + s.str.upper().to_pandas(), + pd.Series(["ABC", "DE", "1"], dtype=dtypes.STRING_DTYPE), + check_index_type=False, + ) + + # [START bigquery_dataframes_type_system_geo_accessor] + from shapely.geometry import Point + + import bigframes.pandas as bpd + + s = bpd.Series([Point(1, 0), Point(2, 1)]) # dtype: geometry + + s.geo.y + # 0 0.0 + # 1 1.0 + # dtype: Float64 + # [END bigquery_dataframes_type_system_geo_accessor] + pandas.testing.assert_series_equal( + s.geo.y.to_pandas(), + pd.Series([0.0, 1.0], dtype=dtypes.FLOAT_DTYPE), + check_index_type=False, + ) diff --git a/samples/snippets/udf.py b/samples/snippets/udf.py new file mode 100644 index 0000000000..5f7ad8a33f --- /dev/null +++ b/samples/snippets/udf.py @@ -0,0 +1,120 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def run_udf_and_read_gbq_function( + project_id: str, dataset_id: str, routine_id: str +) -> None: + your_gcp_project_id = project_id + your_bq_dataset_id = dataset_id + your_bq_routine_id = routine_id + + # [START bigquery_dataframes_udf] + import bigframes.pandas as bpd + + # Set BigQuery DataFrames options + bpd.options.bigquery.project = your_gcp_project_id + bpd.options.bigquery.location = "US" + + # BigQuery DataFrames gives you the ability to turn your custom functions + # into a BigQuery Python UDF. One can find more details about the usage and + # the requirements via `help` command. + help(bpd.udf) + + # Read a table and inspect the column of interest. + df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + df["body_mass_g"].peek(10) + + # Define a custom function, and specify the intent to turn it into a + # BigQuery Python UDF. Let's try a `pandas`-like use case in which we want + # to apply a user defined function to every value in a `Series`, more + # specifically bucketize the `body_mass_g` value of the penguins, which is a + # real number, into a category, which is a string. + @bpd.udf( + dataset=your_bq_dataset_id, + name=your_bq_routine_id, + ) + def get_bucket(num: float) -> str: + if not num: + return "NA" + boundary = 4000 + return "at_or_above_4000" if num >= boundary else "below_4000" + + # Then we can apply the udf on the `Series` of interest via + # `apply` API and store the result in a new column in the DataFrame. + df = df.assign(body_mass_bucket=df["body_mass_g"].apply(get_bucket)) + + # This will add a new column `body_mass_bucket` in the DataFrame. You can + # preview the original value and the bucketized value side by side. + df[["body_mass_g", "body_mass_bucket"]].peek(10) + + # The above operation was possible by doing all the computation on the + # cloud through an underlying BigQuery Python UDF that was created to + # support the user's operations in the Python code. + + # The BigQuery Python UDF created to support the BigQuery DataFrames + # udf can be located via a property `bigframes_bigquery_function` + # set in the udf object. + print(f"Created BQ Python UDF: {get_bucket.bigframes_bigquery_function}") + + # If you have already defined a custom function in BigQuery, either via the + # BigQuery Google Cloud Console or with the `udf` decorator, + # or otherwise, you may use it with BigQuery DataFrames with the + # `read_gbq_function` method. More details are available via the `help` + # command. + help(bpd.read_gbq_function) + + existing_get_bucket_bq_udf = get_bucket.bigframes_bigquery_function + + # Here is an example of using `read_gbq_function` to load an existing + # BigQuery Python UDF. + df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + get_bucket_function = bpd.read_gbq_function(existing_get_bucket_bq_udf) + + df = df.assign(body_mass_bucket=df["body_mass_g"].apply(get_bucket_function)) + df.peek(10) + + # Let's continue trying other potential use cases of udf. Let's say we + # consider the `species`, `island` and `sex` of the penguins sensitive + # information and want to redact that by replacing with their hash code + # instead. Let's define another scalar custom function and decorate it + # as a udf. The custom function in this example has external package + # dependency, which can be specified via `packages` parameter. + @bpd.udf( + dataset=your_bq_dataset_id, + name=your_bq_routine_id, + packages=["cryptography"], + ) + def get_hash(input: str) -> str: + from cryptography.fernet import Fernet + + # handle missing value + if input is None: + input = "" + + key = Fernet.generate_key() + f = Fernet(key) + return f.encrypt(input.encode()).decode() + + # We can use this udf in another `pandas`-like API `map` that + # can be applied on a DataFrame + df_redacted = df[["species", "island", "sex"]].map(get_hash) + df_redacted.peek(10) + + # If the BigQuery routine is no longer needed, we can clean it up + # to free up any cloud quota + session = bpd.get_global_session() + session.bqclient.delete_routine(f"{your_bq_dataset_id}.{your_bq_routine_id}") + + # [END bigquery_dataframes_udf] diff --git a/samples/snippets/udf_test.py b/samples/snippets/udf_test.py new file mode 100644 index 0000000000..a352b4c8ce --- /dev/null +++ b/samples/snippets/udf_test.py @@ -0,0 +1,38 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.pandas + +from . import udf + + +def test_udf_and_read_gbq_function( + capsys: pytest.CaptureFixture[str], + dataset_id: str, + routine_id: str, +) -> None: + # We need a fresh session since we're modifying connection options. + bigframes.pandas.close_session() + + # Determine project id, in this case prefer the one set in the environment + # variable GOOGLE_CLOUD_PROJECT (if any) + import os + + your_project_id = os.getenv("GOOGLE_CLOUD_PROJECT", "bigframes-dev") + + udf.run_udf_and_read_gbq_function(your_project_id, dataset_id, routine_id) + out, _ = capsys.readouterr() + assert "Created BQ Python UDF:" in out diff --git a/scratch/.gitignore b/scratch/.gitignore new file mode 100644 index 0000000000..b813ccd98e --- /dev/null +++ b/scratch/.gitignore @@ -0,0 +1,2 @@ +# Ignore all files in this directory. +* diff --git a/scripts/conftest.py b/scripts/conftest.py new file mode 100644 index 0000000000..83fd2b19af --- /dev/null +++ b/scripts/conftest.py @@ -0,0 +1,8 @@ +from pathlib import Path +import sys + +# inserts scripts into path so that tests can import +project_root = Path(__file__).parent.parent +scripts_dir = project_root / "scripts" + +sys.path.insert(0, str(scripts_dir)) diff --git a/scripts/create_bigtable.py b/scripts/create_bigtable.py deleted file mode 100644 index da40e9063d..0000000000 --- a/scripts/create_bigtable.py +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# This script create the bigtable resources required for -# bigframes.streaming testing if they don't already exist - -import os -import sys - -from google.cloud.bigtable import column_family -import google.cloud.bigtable as bigtable - -PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT") - -if not PROJECT_ID: - print( - "Please set GOOGLE_CLOUD_PROJECT environment variable before running.", - file=sys.stderr, - ) - sys.exit(1) - - -def create_instance(client): - instance_name = "streaming-testing-instance" - instance = bigtable.instance.Instance( - instance_name, - client, - ) - cluster_id = "streaming-testing-instance-c1" - cluster = instance.cluster( - cluster_id, - location_id="us-west1-a", - serve_nodes=1, - ) - if not instance.exists(): - operation = instance.create( - clusters=[cluster], - ) - operation.result(timeout=480) - print(f"Created instance {instance_name}") - return instance - - -def create_table(instance): - table_id = "table-testing" - table = bigtable.table.Table( - table_id, - instance, - ) - max_versions_rule = column_family.MaxVersionsGCRule(1) - column_family_id = "body_mass_g" - column_families = {column_family_id: max_versions_rule} - if not table.exists(): - table.create(column_families=column_families) - print(f"Created table {table_id}") - - -def main(): - client = bigtable.Client(project=PROJECT_ID, admin=True) - - instance = create_instance(client) - create_table(instance) - - -if __name__ == "__main__": - main() diff --git a/scripts/create_pubsub.py b/scripts/create_pubsub.py deleted file mode 100644 index 5d25398983..0000000000 --- a/scripts/create_pubsub.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# This script create the bigtable resources required for -# bigframes.streaming testing if they don't already exist - -import os -import sys - -from google.cloud import pubsub_v1 - -PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT") - -if not PROJECT_ID: - print( - "Please set GOOGLE_CLOUD_PROJECT environment variable before running.", - file=sys.stderr, - ) - sys.exit(1) - - -def create_topic(topic_id): - # based on - # https://cloud.google.com/pubsub/docs/samples/pubsub-quickstart-create-topic?hl=en - - publisher = pubsub_v1.PublisherClient() - topic_path = publisher.topic_path(PROJECT_ID, topic_id) - - topic = publisher.create_topic(request={"name": topic_path}) - print(f"Created topic: {topic.name}") - - -def main(): - create_topic("penguins") - - -if __name__ == "__main__": - main() diff --git a/scripts/create_read_gbq_colab_benchmark_tables.py b/scripts/create_read_gbq_colab_benchmark_tables.py new file mode 100644 index 0000000000..63419bc660 --- /dev/null +++ b/scripts/create_read_gbq_colab_benchmark_tables.py @@ -0,0 +1,541 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import argparse +import base64 +import concurrent.futures +import datetime +import json +import math +import time +from typing import Any, Iterable, MutableSequence, Sequence + +from google.cloud import bigquery +import numpy as np + +# --- Input Data --- +# Generated by querying bigquery-magics usage. See internal issue b/420984164. +TABLE_STATS: dict[str, list[float]] = { + "percentile": [9, 19, 29, 39, 49, 59, 69, 79, 89, 99], + "materialized_or_scanned_bytes": [ + 0.0, + 0.0, + 4102.0, + 76901.0, + 351693.0, + 500000.0, + 500000.0, + 1320930.0, + 17486432.0, + 1919625975.0, + ], + "avg_row_bytes": [ + 0.00014346299635435792, + 0.005370969708923197, + 0.3692756731526246, + 4.079344721151818, + 7.5418, + 12.528863516404146, + 22.686258546389798, + 48.69689224091025, + 100.90817356205852, + 2020, + ], + "materialized_mb": [ + 0.0, + 0.0, + 0.004102, + 0.076901, + 0.351693, + 0.5, + 0.5, + 1.32093, + 17.486432, + 1919.625975, + ], +} + +BIGQUERY_DATA_TYPE_SIZES = { + "BOOL": 1, + "DATE": 8, + "FLOAT64": 8, + "INT64": 8, + "DATETIME": 8, + "TIMESTAMP": 8, + "TIME": 8, + "NUMERIC": 16, + # Flexible types. + # JSON base size is its content, BYTES/STRING have 2 byte overhead + content + "JSON": 0, + "BYTES": 2, + "STRING": 2, +} +FIXED_TYPES = [ + "BOOL", + "INT64", + "FLOAT64", + "NUMERIC", + "DATE", + "DATETIME", + "TIMESTAMP", + "TIME", +] +FLEXIBLE_TYPES = ["STRING", "BYTES", "JSON"] + +JSON_CHAR_LIST = list("abcdef") +STRING_CHAR_LIST = list("abcdefghijklmnopqrstuvwxyz0123456789") + +# --- Helper Functions --- + + +def get_bq_schema(target_row_size_bytes: int) -> Sequence[tuple[str, str, int | None]]: + """ + Determines the BigQuery table schema to match the target_row_size_bytes. + Prioritizes fixed-size types for diversity, then uses flexible types. + Returns a list of tuples: (column_name, type_name, length_for_flexible_type). + Length is None for fixed-size types. + """ + schema: MutableSequence[tuple[str, str, int | None]] = [] + current_size = 0 + col_idx = 0 + + for bq_type in FIXED_TYPES: + # For simplicity, we'll allow slight overage if only fixed fields are chosen. + if current_size >= target_row_size_bytes: + break + + type_size = BIGQUERY_DATA_TYPE_SIZES[bq_type] + schema.append((f"col_{bq_type.lower()}_{col_idx}", bq_type, None)) + current_size += type_size + col_idx += 1 + + # Use flexible-size types to fill remaining space + + # Attempt to add one of each flexible type if space allows + if current_size < target_row_size_bytes: + remaining_bytes_for_content = target_row_size_bytes - current_size + + # For simplicity, divide the remaing bytes evenly across the flexible + # columns. + target_size = int(math.ceil(remaining_bytes_for_content / len(FLEXIBLE_TYPES))) + + for bq_type in FLEXIBLE_TYPES: + base_cost = BIGQUERY_DATA_TYPE_SIZES[bq_type] + min_content_size = max(0, target_size - base_cost) + + schema.append( + (f"col_{bq_type.lower()}_{col_idx}", bq_type, min_content_size) + ) + current_size += base_cost + min_content_size + col_idx += 1 + + return schema + + +def generate_bool_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + return rng.choice([True, False], size=num_rows) + + +def generate_int64_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + return rng.integers(-(10**18), 10**18, size=num_rows, dtype=np.int64) + + +def generate_float64_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + return rng.random(size=num_rows) * 2 * 10**10 - 10**10 + + +def generate_numeric_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + raw_numerics = rng.random(size=num_rows) * 2 * 10**28 - 10**28 + format_numeric_vectorized = np.vectorize(lambda x: f"{x:.9f}") + return format_numeric_vectorized(raw_numerics) + + +def generate_date_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + start_date_ord = datetime.date(1, 1, 1).toordinal() + max_days = (datetime.date(9999, 12, 31) - datetime.date(1, 1, 1)).days + day_offsets = rng.integers(0, max_days + 1, size=num_rows) + date_ordinals = start_date_ord + day_offsets + return np.array( + [ + datetime.date.fromordinal(int(ordinal)).isoformat() + for ordinal in date_ordinals + ] + ) + + +def generate_numpy_datetimes(num_rows: int, rng: np.random.Generator) -> np.ndarray: + # Generate seconds from a broad range (e.g., year 1 to 9999) + # Note: Python's datetime.timestamp() might be limited by system's C mktime. + # For broader range with np.datetime64, it's usually fine. + # Let's generate epoch seconds relative to Unix epoch for np.datetime64 compatibility + min_epoch_seconds = int( + datetime.datetime(1, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc).timestamp() + ) + # Max for datetime64[s] is far out, but let's bound it reasonably for BQ. + max_epoch_seconds = int( + datetime.datetime( + 9999, 12, 28, 23, 59, 59, tzinfo=datetime.timezone.utc + ).timestamp() + ) + + epoch_seconds = rng.integers( + min_epoch_seconds, + max_epoch_seconds + 1, + size=num_rows, + dtype=np.int64, + ) + microseconds_offset = rng.integers(0, 1000000, size=num_rows, dtype=np.int64) + + # Create datetime64[s] from epoch seconds and add microseconds as timedelta64[us] + np_timestamps_s = epoch_seconds.astype("datetime64[s]") + np_microseconds_td = microseconds_offset.astype("timedelta64[us]") + return np_timestamps_s + np_microseconds_td + + +def generate_datetime_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + np_datetimes = generate_numpy_datetimes(num_rows, rng) + + # np.datetime_as_string produces 'YYYY-MM-DDTHH:MM:SS.ffffff' + # BQ DATETIME typically uses a space separator: 'YYYY-MM-DD HH:MM:SS.ffffff' + datetime_strings = np.datetime_as_string(np_datetimes, unit="us") + return np.array([s.replace("T", " ") for s in datetime_strings]) + + +def generate_timestamp_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + np_datetimes = generate_numpy_datetimes(num_rows, rng) + + # Convert to string with UTC timezone indicator + # np.datetime_as_string with timezone='UTC' produces 'YYYY-MM-DDTHH:MM:SS.ffffffZ' + # BigQuery generally accepts this for TIMESTAMP. + return np.datetime_as_string(np_datetimes, unit="us", timezone="UTC") + + +def generate_time_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + hours = rng.integers(0, 24, size=num_rows) + minutes = rng.integers(0, 60, size=num_rows) + seconds = rng.integers(0, 60, size=num_rows) + microseconds = rng.integers(0, 1000000, size=num_rows) + time_list = [ + datetime.time(hours[i], minutes[i], seconds[i], microseconds[i]).isoformat() + for i in range(num_rows) + ] + return np.array(time_list) + + +def generate_json_row(content_length: int, rng: np.random.Generator) -> str: + json_val_len = max(0, content_length - 5) + json_val_chars = rng.choice(JSON_CHAR_LIST, size=json_val_len) + json_obj = {"k": "".join(json_val_chars)} + return json.dumps(json_obj) + + +def generate_json_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + content_length = content_length if content_length is not None else 10 + json_list = [ + generate_json_row(content_length=content_length, rng=rng) + for _ in range(num_rows) + ] + return np.array(json_list) + + +def generate_string_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + content_length = content_length if content_length is not None else 1 + content_length = max(0, content_length) + chars_array = rng.choice(STRING_CHAR_LIST, size=(num_rows, content_length)) + return np.array(["".join(row_chars) for row_chars in chars_array]) + + +def generate_bytes_batch( + num_rows: int, rng: np.random.Generator, content_length: int | None = None +) -> np.ndarray: + content_length = content_length if content_length is not None else 1 + content_length = max(0, content_length) + return np.array( + [ + base64.b64encode(rng.bytes(content_length)).decode("utf-8") + for _ in range(num_rows) + ] + ) + + +BIGQUERY_DATA_TYPE_GENERATORS = { + "BOOL": generate_bool_batch, + "DATE": generate_date_batch, + "FLOAT64": generate_float64_batch, + "INT64": generate_int64_batch, + "DATETIME": generate_datetime_batch, + "TIMESTAMP": generate_timestamp_batch, + "TIME": generate_time_batch, + "NUMERIC": generate_numeric_batch, + "JSON": generate_json_batch, + "BYTES": generate_bytes_batch, + "STRING": generate_string_batch, +} + + +def generate_work_items( + table_id: str, + schema: Sequence[tuple[str, str, int | None]], + num_rows: int, + batch_size: int, +) -> Iterable[tuple[str, Sequence[tuple[str, str, int | None]], int]]: + """ + Generates work items of appropriate batch sizes. + """ + if num_rows == 0: + return + + generated_rows_total = 0 + + while generated_rows_total < num_rows: + current_batch_size = min(batch_size, num_rows - generated_rows_total) + if current_batch_size == 0: + break + + yield (table_id, schema, current_batch_size) + generated_rows_total += current_batch_size + + +def generate_batch( + schema: Sequence[tuple[str, str, int | None]], + num_rows: int, + rng: np.random.Generator, +) -> list[dict[str, Any]]: + col_names_ordered = [s[0] for s in schema] + + columns_data_batch = {} + for col_name, bq_type, length in schema: + generate_batch = BIGQUERY_DATA_TYPE_GENERATORS[bq_type] + columns_data_batch[col_name] = generate_batch( + num_rows, rng, content_length=length + ) + + # Turn numpy objects into Python objects. + # https://stackoverflow.com/a/32850511/101923 + columns_data_batch_json = {} + for column in columns_data_batch: + columns_data_batch_json[column] = columns_data_batch[column].tolist() + + # Assemble batch of rows + batch_data = [] + for i in range(num_rows): + row = { + col_name: columns_data_batch_json[col_name][i] + for col_name in col_names_ordered + } + batch_data.append(row) + + return batch_data + + +def generate_and_load_batch( + client: bigquery.Client, + table_id: str, + schema_def: Sequence[tuple[str, str, int | None]], + num_rows: int, + rng: np.random.Generator, +): + bq_schema = [] + for col_name, type_name, _ in schema_def: + bq_schema.append(bigquery.SchemaField(col_name, type_name)) + table = bigquery.Table(table_id, schema=bq_schema) + + generated_data_chunk = generate_batch(schema_def, num_rows, rng) + errors = client.insert_rows_json(table, generated_data_chunk) + if errors: + raise ValueError(f"Encountered errors while inserting sub-batch: {errors}") + + +def create_and_load_table( + client: bigquery.Client | None, + project_id: str, + dataset_id: str, + table_name: str, + schema_def: Sequence[tuple[str, str, int | None]], + num_rows: int, + executor: concurrent.futures.Executor, +): + """Creates a BigQuery table and loads data into it by consuming a data generator.""" + + if not client: + print(f"Simulating: Generated schema: {schema_def}") + return + + # BQ client library streaming insert batch size (rows per API call) + # This is different from data_gen_batch_size which is for generating data. + # We can make BQ_LOAD_BATCH_SIZE smaller than data_gen_batch_size if needed. + BQ_LOAD_BATCH_SIZE = 500 + + # Actual BigQuery operations occur here because both project_id and dataset_id are provided + print( + f"Attempting BigQuery operations for table {table_name} in project '{project_id}', dataset '{dataset_id}'." + ) + table_id = f"{project_id}.{dataset_id}.{table_name}" + + bq_schema = [] + for col_name, type_name, _ in schema_def: + bq_schema.append(bigquery.SchemaField(col_name, type_name)) + + table = bigquery.Table(table_id, schema=bq_schema) + print(f"(Re)creating table {table_id}...") + table = client.create_table(table, exists_ok=True) + print(f"Table {table_id} created successfully or already exists.") + + # Query in case there's something in the streaming buffer already. + table_rows = next( + iter(client.query_and_wait(f"SELECT COUNT(*) FROM `{table_id}`")) + )[0] + print(f"Table {table_id} has {table_rows} rows.") + num_rows = max(0, num_rows - table_rows) + + if num_rows <= 0: + print(f"No rows to load. Requested {num_rows} rows. Skipping.") + return + + print(f"Starting to load {num_rows} rows into {table_id} in batches...") + + previous_status_time = 0.0 + generated_rows_total = 0 + + for completed_rows in executor.map( + worker_process_item, + generate_work_items( + table_id, + schema_def, + num_rows, + BQ_LOAD_BATCH_SIZE, + ), + ): + generated_rows_total += completed_rows + + current_time = time.monotonic() + if current_time - previous_status_time > 5: + print(f"Wrote {generated_rows_total} out of {num_rows} rows.") + previous_status_time = current_time + + +worker_client: bigquery.Client | None = None +worker_rng: np.random.Generator | None = None + + +def worker_initializer(project_id: str | None): + global worker_client, worker_rng + + # One client per process, since multiprocessing and client connections don't + # play nicely together. + if project_id is not None: + worker_client = bigquery.Client(project=project_id) + + worker_rng = np.random.default_rng() + + +def worker_process_item( + work_item: tuple[str, Sequence[tuple[str, str, int | None]], int] +): + global worker_client, worker_rng + + if worker_client is None or worker_rng is None: + raise ValueError("Worker not initialized.") + + table_id, schema_def, num_rows = work_item + generate_and_load_batch(worker_client, table_id, schema_def, num_rows, worker_rng) + return num_rows + + +# --- Main Script Logic --- +def main(): + """Main function to create and populate BigQuery tables.""" + + parser = argparse.ArgumentParser( + description="Generate and load BigQuery benchmark tables." + ) + parser.add_argument( + "-p", + "--project_id", + type=str, + default=None, + help="Google Cloud Project ID. If not provided, script runs in simulation mode.", + ) + parser.add_argument( + "-d", + "--dataset_id", + type=str, + default=None, + help="BigQuery Dataset ID within the project. If not provided, script runs in simulation mode.", + ) + args = parser.parse_args() + + num_percentiles = len(TABLE_STATS["percentile"]) + client = None + + if args.project_id and args.dataset_id: + client = bigquery.Client(project=args.project_id) + dataset = bigquery.Dataset(f"{args.project_id}.{args.dataset_id}") + client.create_dataset(dataset, exists_ok=True) + + with concurrent.futures.ProcessPoolExecutor( + initializer=worker_initializer, initargs=(args.project_id,) + ) as executor: + for i in range(num_percentiles): + percentile = TABLE_STATS["percentile"][i] + avg_row_bytes_raw = TABLE_STATS["avg_row_bytes"][i] + table_bytes_raw = TABLE_STATS["materialized_or_scanned_bytes"][i] + + target_table_bytes = max(1, int(math.ceil(table_bytes_raw))) + target_row_bytes = max(1, int(math.ceil(avg_row_bytes_raw))) + num_rows = max(1, int(math.ceil(target_table_bytes / target_row_bytes))) + + table_name = f"percentile_{percentile:02d}" + print(f"\n--- Processing Table: {table_name} ---") + print(f"Target average row bytes (rounded up): {target_row_bytes}") + print(f"Number of rows (rounded up): {num_rows}") + + schema_definition = get_bq_schema(target_row_bytes) + print(f"Generated Schema: {schema_definition}") + + create_and_load_table( + client, + args.project_id or "", + args.dataset_id or "", + table_name, + schema_definition, + num_rows, + executor, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/create_read_gbq_colab_benchmark_tables_test.py b/scripts/create_read_gbq_colab_benchmark_tables_test.py new file mode 100644 index 0000000000..89c49e4243 --- /dev/null +++ b/scripts/create_read_gbq_colab_benchmark_tables_test.py @@ -0,0 +1,333 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import base64 +import datetime +import json +import math +import re + +# Assuming the script to be tested is in the same directory or accessible via PYTHONPATH +from create_read_gbq_colab_benchmark_tables import ( + BIGQUERY_DATA_TYPE_SIZES, + generate_batch, + generate_work_items, + get_bq_schema, +) +import numpy as np +import pytest + + +# Helper function to calculate estimated row size from schema +def _calculate_row_size(schema: list[tuple[str, str, int | None]]) -> int: + """Calculates the estimated byte size of a row based on the schema. + Note: This is a simplified calculation for testing and might not perfectly + match BigQuery's internal storage, especially for complex types or NULLs. + """ + size = 0 + for _, bq_type, length in schema: + if bq_type in ["STRING", "BYTES", "JSON"]: + # Base cost (e.g., 2 bytes) + content length + size += BIGQUERY_DATA_TYPE_SIZES[bq_type] + ( + length if length is not None else 0 + ) + elif bq_type in BIGQUERY_DATA_TYPE_SIZES: + size += BIGQUERY_DATA_TYPE_SIZES[bq_type] + else: + raise AssertionError(f"Got unexpected type {bq_type}") + return size + + +# --- Tests for get_bq_schema --- + + +def test_get_bq_schema_zero_bytes(): + assert get_bq_schema(0) == [] + + +def test_get_bq_schema_one_byte(): + schema = get_bq_schema(1) + + assert len(schema) == 1 + assert schema[0][1] == "BOOL" # ('col_bool_fallback_0', 'BOOL', None) or similar + assert _calculate_row_size(schema) == 1 + + +def test_get_bq_schema_exact_fixed_fit(): + # BOOL (1) + INT64 (8) = 9 bytes + target_size = 9 + schema = get_bq_schema(target_size) + + assert len(schema) == 2 + assert schema[0][1] == "BOOL" + assert schema[1][1] == "INT64" + assert _calculate_row_size(schema) == target_size + + +def test_get_bq_schema_needs_flexible_string(): + # Sum of all fixed types: + # BOOL 1, INT64 8, FLOAT64 8, NUMERIC 16, DATE 8, DATETIME 8, TIMESTAMP 8, TIME 8 + # Total = 1+8+8+16+8+8+8+8 = 65 + target_size = 65 + 1 + schema = get_bq_schema(target_size) + + assert _calculate_row_size(schema) == 65 + 2 + 2 + 1 + + string_cols = [s for s in schema if s[1] == "STRING"] + assert len(string_cols) == 1 + assert string_cols[0][2] == 0 + + bytes_cols = [s for s in schema if s[1] == "BYTES"] + assert len(bytes_cols) == 1 + assert bytes_cols[0][2] == 0 + + json_cols = [s for s in schema if s[1] == "JSON"] + assert len(json_cols) == 1 + assert json_cols[0][2] == 1 + + +def test_get_bq_schema_flexible_expansion(): + # Sum of all fixed types: + # BOOL 1, INT64 8, FLOAT64 8, NUMERIC 16, DATE 8, DATETIME 8, TIMESTAMP 8, TIME 8 + # Total = 1+8+8+16+8+8+8+8 = 65 + target_size = 65 + 3 * 5 + schema = get_bq_schema(target_size) + + assert _calculate_row_size(schema) == target_size + + string_cols = [s for s in schema if s[1] == "STRING"] + assert len(string_cols) == 1 + assert string_cols[0][2] == 3 + + bytes_cols = [s for s in schema if s[1] == "BYTES"] + assert len(bytes_cols) == 1 + assert bytes_cols[0][2] == 3 + + json_cols = [s for s in schema if s[1] == "JSON"] + assert len(json_cols) == 1 + assert json_cols[0][2] == 5 + + +def test_get_bq_schema_all_fixed_types_possible(): + # Sum of all fixed types: + # BOOL 1, INT64 8, FLOAT64 8, NUMERIC 16, DATE 8, DATETIME 8, TIMESTAMP 8, TIME 8 + # Total = 1+8+8+16+8+8+8+8 = 65 + target_size = 65 + schema = get_bq_schema(target_size) + + expected_fixed_types = { + "BOOL", + "INT64", + "FLOAT64", + "NUMERIC", + "DATE", + "DATETIME", + "TIMESTAMP", + "TIME", + } + present_types = {s[1] for s in schema} + + assert expected_fixed_types.issubset(present_types) + + # Check if the size is close to target. + # All fixed (65) + calculated_size = _calculate_row_size(schema) + assert calculated_size == target_size + + +def test_get_bq_schema_uniqueness_of_column_names(): + target_size = 100 # A size that generates multiple columns + schema = get_bq_schema(target_size) + + column_names = [s[0] for s in schema] + assert len(column_names) == len(set(column_names)) + + +# --- Tests for generate_work_items --- + + +def test_generate_work_items_zero_rows(): + schema = [("col_int", "INT64", None)] + data_generator = generate_work_items( + "some_table", schema, num_rows=0, batch_size=10 + ) + + # Expect the generator to be exhausted + with pytest.raises(StopIteration): + next(data_generator) + + +def test_generate_work_items_basic_schema_and_batching(): + schema = [("id", "INT64", None), ("is_active", "BOOL", None)] + num_rows = 25 + batch_size = 10 + + generated_rows_count = 0 + batch_count = 0 + for work_item in generate_work_items("some_table", schema, num_rows, batch_size): + table_id, schema_def, num_rows_in_batch = work_item + assert table_id == "some_table" + assert schema_def == schema + assert num_rows_in_batch <= num_rows + assert num_rows_in_batch <= batch_size + batch_count += 1 + generated_rows_count += num_rows_in_batch + + assert generated_rows_count == num_rows + assert batch_count == math.ceil(num_rows / batch_size) # 25/10 = 2.5 -> 3 batches + + +def test_generate_work_items_batch_size_larger_than_num_rows(): + schema = [("value", "FLOAT64", None)] + num_rows = 5 + batch_size = 100 + + generated_rows_count = 0 + batch_count = 0 + for work_item in generate_work_items("some_table", schema, num_rows, batch_size): + table_id, schema_def, num_rows_in_batch = work_item + assert table_id == "some_table" + assert schema_def == schema + assert num_rows_in_batch == num_rows # Should be one batch with all rows + batch_count += 1 + generated_rows_count += num_rows_in_batch + + assert generated_rows_count == num_rows + assert batch_count == 1 + + +def test_generate_work_items_all_datatypes(rng): + schema = [ + ("c_bool", "BOOL", None), + ("c_int64", "INT64", None), + ("c_float64", "FLOAT64", None), + ("c_numeric", "NUMERIC", None), + ("c_date", "DATE", None), + ("c_datetime", "DATETIME", None), + ("c_timestamp", "TIMESTAMP", None), + ("c_time", "TIME", None), + ("c_string", "STRING", 10), + ("c_bytes", "BYTES", 5), + ("c_json", "JSON", 20), # Length for JSON is content hint + ] + num_rows = 3 + batch_size = 2 # To test multiple batches + + total_rows_processed = 0 + for work_item in generate_work_items("some_table", schema, num_rows, batch_size): + table_id, schema_def, num_rows_in_batch = work_item + assert table_id == "some_table" + assert schema_def == schema + assert num_rows_in_batch <= batch_size + assert num_rows_in_batch <= num_rows + + total_rows_processed += num_rows_in_batch + + assert total_rows_processed == num_rows + + +# --- Pytest Fixture for RNG --- +@pytest.fixture +def rng(): + return np.random.default_rng(seed=42) + + +def test_generate_batch_basic_schema(rng): + schema = [("id", "INT64", None), ("is_active", "BOOL", None)] + batch = generate_batch(schema, 5, rng) + + assert len(batch) == 5 + + for row in batch: + assert isinstance(row, dict) + assert "id" in row + assert "is_active" in row + assert isinstance(row["id"], int) + assert isinstance(row["is_active"], bool) + + +def test_generate_batch_all_datatypes(rng): + schema = [ + ("c_bool", "BOOL", None), + ("c_int64", "INT64", None), + ("c_float64", "FLOAT64", None), + ("c_numeric", "NUMERIC", None), + ("c_date", "DATE", None), + ("c_datetime", "DATETIME", None), + ("c_timestamp", "TIMESTAMP", None), + ("c_time", "TIME", None), + ("c_string", "STRING", 10), + ("c_bytes", "BYTES", 5), + ("c_json", "JSON", 20), # Length for JSON is content hint + ] + num_rows = 3 + + date_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}$") + time_pattern = re.compile(r"^\d{2}:\d{2}:\d{2}(\.\d{1,6})?$") + # BQ DATETIME: YYYY-MM-DD HH:MM:SS.ffffff + datetime_pattern = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,6})?$") + # BQ TIMESTAMP (UTC 'Z'): YYYY-MM-DDTHH:MM:SS.ffffffZ + timestamp_pattern = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$" + ) + numeric_pattern = re.compile(r"^-?\d+\.\d{9}$") + + batch = generate_batch(schema, num_rows, rng) + assert len(batch) == num_rows + + for row in batch: + assert isinstance(row["c_bool"], bool) + assert isinstance(row["c_int64"], int) + assert isinstance(row["c_float64"], float) + + assert isinstance(row["c_numeric"], str) + assert numeric_pattern.match(row["c_numeric"]) + + assert isinstance(row["c_date"], str) + assert date_pattern.match(row["c_date"]) + datetime.date.fromisoformat(row["c_date"]) # Check parsable + + assert isinstance(row["c_datetime"], str) + assert datetime_pattern.match(row["c_datetime"]) + datetime.datetime.fromisoformat(row["c_datetime"]) # Check parsable + + assert isinstance(row["c_timestamp"], str) + assert timestamp_pattern.match(row["c_timestamp"]) + # datetime.fromisoformat can parse 'Z' if Python >= 3.11, or needs replace('Z', '+00:00') + dt_obj = datetime.datetime.fromisoformat( + row["c_timestamp"].replace("Z", "+00:00") + ) + assert dt_obj.tzinfo == datetime.timezone.utc + + assert isinstance(row["c_time"], str) + assert time_pattern.match(row["c_time"]) + datetime.time.fromisoformat(row["c_time"]) # Check parsable + + assert isinstance(row["c_string"], str) + assert len(row["c_string"]) == 10 + + c_bytes = base64.b64decode(row["c_bytes"]) + assert isinstance(c_bytes, bytes) + assert len(c_bytes) == 5 + + assert isinstance(row["c_json"], str) + try: + json.loads(row["c_json"]) # Check if it's valid JSON + except json.JSONDecodeError: + pytest.fail(f"Invalid JSON string generated: {row['c_json']}") + # Note: Exact length check for JSON is hard due to content variability and escaping. + # The 'length' parameter for JSON in schema is a hint for content size. + # We are primarily testing that it's valid JSON. diff --git a/scripts/data/audio/audio_LJ001-0010.wav b/scripts/data/audio/audio_LJ001-0010.wav new file mode 100644 index 0000000000..01a2e68829 Binary files /dev/null and b/scripts/data/audio/audio_LJ001-0010.wav differ diff --git a/scripts/data/images_exif/test_image_exif.jpg b/scripts/data/images_exif/test_image_exif.jpg new file mode 100644 index 0000000000..fdfdaf9ad0 Binary files /dev/null and b/scripts/data/images_exif/test_image_exif.jpg differ diff --git a/scripts/data/pdfs/pdfs_sample-local-pdf.pdf b/scripts/data/pdfs/pdfs_sample-local-pdf.pdf new file mode 100644 index 0000000000..d162cd6877 Binary files /dev/null and b/scripts/data/pdfs/pdfs_sample-local-pdf.pdf differ diff --git a/scripts/data/pdfs/test-protected.pdf b/scripts/data/pdfs/test-protected.pdf new file mode 100644 index 0000000000..0d8cd28baa Binary files /dev/null and b/scripts/data/pdfs/test-protected.pdf differ diff --git a/scripts/publish_api_coverage.py b/scripts/publish_api_coverage.py index 8f305bcc0f..f94cd7e6d7 100644 --- a/scripts/publish_api_coverage.py +++ b/scripts/publish_api_coverage.py @@ -30,38 +30,21 @@ import bigframes.core.groupby import bigframes.core.window import bigframes.operations.datetimes +import bigframes.operations.strings import bigframes.pandas as bpd REPO_ROOT = pathlib.Path(__file__).parent.parent -URL_PREFIX = { - "pandas": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.pandas#bigframes_pandas_" - ), - "dataframe": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.dataframe.DataFrame#bigframes_dataframe_DataFrame_" - ), - "dataframegroupby": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.core.groupby.DataFrameGroupBy#bigframes_core_groupby_DataFrameGroupBy_" - ), - "index": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.core.indexes.base.Index#bigframes_core_indexes_base_Index_" - ), - "series": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.series.Series#bigframes_series_Series_" - ), - "seriesgroupby": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.core.groupby.SeriesGroupBy#bigframes_core_groupby_SeriesGroupBy_" - ), - "datetimemethods": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.operations.datetimes.DatetimeMethods#bigframes_operations_datetimes_DatetimeMethods_" - ), - "stringmethods": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.operations.strings.StringMethods#bigframes_operations_strings_StringMethods_" - ), - "window": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.core.window.Window#bigframes_core_window_Window_" - ), +BIGFRAMES_OBJECT = { + "pandas": "bigframes.pandas", + "dataframe": "bigframes.pandas.DataFrame", + "dataframegroupby": "bigframes.pandas.api.typing.DataFrameGroupBy", + "index": "bigframes.pandas.Index", + "series": "bigframes.pandas.Series", + "seriesgroupby": "bigframes.pandas.api.typing.SeriesGroupBy", + "datetimemethods": "bigframes.pandas.api.typing.DatetimeMethods", + "stringmethods": "bigframes.pandas.api.typing.StringMethods", + "window": "bigframes.pandas.api.typing.Window", } @@ -139,7 +122,7 @@ def generate_pandas_api_coverage(): missing_parameters = "" # skip private functions and properties - if member[0] == "_" and member[1] != "_": + if member[0] == "_": continue # skip members that are also common python methods @@ -204,6 +187,9 @@ def generate_pandas_api_coverage(): def generate_sklearn_api_coverage(): """Explore all SKLearn modules, and for each item contained generate a regex to detect it being imported, and record whether we implement it""" + + import sklearn # noqa + sklearn_modules = [ "sklearn", "sklearn.model_selection", @@ -304,11 +290,19 @@ def build_api_coverage_table(bigframes_version: str, release_version: str): def format_api(api_names, is_in_bigframes, api_prefix): api_names = api_names.str.slice(start=len(f"{api_prefix}.")) formatted = "" + api_names + "" - url_prefix = URL_PREFIX.get(api_prefix) - if url_prefix is None: + bigframes_object = BIGFRAMES_OBJECT.get(api_prefix) + if bigframes_object is None: return formatted - linked = '' + formatted + "" + linked = ( + '' + + formatted + + "" + ) return formatted.mask(is_in_bigframes, linked) diff --git a/scripts/readme-gen/readme_gen.py b/scripts/readme-gen/readme_gen.py index 8f5e248a0d..ceb1eada7c 100644 --- a/scripts/readme-gen/readme_gen.py +++ b/scripts/readme-gen/readme_gen.py @@ -24,7 +24,6 @@ import jinja2 import yaml - jinja_env = jinja2.Environment( trim_blocks=True, loader=jinja2.FileSystemLoader( diff --git a/scripts/run_and_publish_benchmark.py b/scripts/run_and_publish_benchmark.py index 28605a8155..859d68e60e 100644 --- a/scripts/run_and_publish_benchmark.py +++ b/scripts/run_and_publish_benchmark.py @@ -84,67 +84,58 @@ def collect_benchmark_result( path = pathlib.Path(benchmark_path) try: results_dict: Dict[str, List[Union[int, float, None]]] = {} - bytes_files = sorted(path.rglob("*.bytesprocessed")) - millis_files = sorted(path.rglob("*.slotmillis")) - bq_seconds_files = sorted(path.rglob("*.bq_exec_time_seconds")) + # Use local_seconds_files as the baseline local_seconds_files = sorted(path.rglob("*.local_exec_time_seconds")) - query_char_count_files = sorted(path.rglob("*.query_char_count")) - error_files = sorted(path.rglob("*.error")) + benchmarks_with_missing_files = [] + + for local_seconds_file in local_seconds_files: + base_name = local_seconds_file.name.removesuffix(".local_exec_time_seconds") + base_path = local_seconds_file.parent / base_name + filename = base_path.relative_to(path) + + # Construct paths for other metric files + bytes_file = pathlib.Path(f"{base_path}.bytesprocessed") + millis_file = pathlib.Path(f"{base_path}.slotmillis") + bq_seconds_file = pathlib.Path(f"{base_path}.bq_exec_time_seconds") + query_char_count_file = pathlib.Path(f"{base_path}.query_char_count") + + # Check if all corresponding files exist + missing_files = [] + if not bytes_file.exists(): + missing_files.append(bytes_file.name) + if not millis_file.exists(): + missing_files.append(millis_file.name) + if not bq_seconds_file.exists(): + missing_files.append(bq_seconds_file.name) + if not query_char_count_file.exists(): + missing_files.append(query_char_count_file.name) + + if missing_files: + benchmarks_with_missing_files.append((str(filename), missing_files)) + continue - if not ( - len(bytes_files) - == len(millis_files) - == len(local_seconds_files) - == len(bq_seconds_files) - == len(query_char_count_files) - ): - raise ValueError( - "Mismatch in the number of report files for bytes, millis, seconds and query char count." - ) - - for idx in range(len(bytes_files)): - bytes_file = bytes_files[idx] - millis_file = millis_files[idx] - bq_seconds_file = bq_seconds_files[idx] - query_char_count_file = query_char_count_files[idx] - - filename = bytes_file.relative_to(path).with_suffix("") - - if filename != millis_file.relative_to(path).with_suffix( - "" - ) or filename != bq_seconds_file.relative_to(path).with_suffix(""): - raise ValueError( - "File name mismatch among bytes, millis, and seconds reports." - ) + with open(query_char_count_file, "r") as file: + lines = file.read().splitlines() + query_char_count = sum(int(line) for line in lines) / iterations + query_count = len(lines) / iterations - local_seconds_file = local_seconds_files[idx] - if filename != local_seconds_file.relative_to(path).with_suffix(""): - raise ValueError( - "File name mismatch among bytes, millis, and seconds reports." - ) + with open(local_seconds_file, "r") as file: + lines = file.read().splitlines() + local_seconds = sum(float(line) for line in lines) / iterations with open(bytes_file, "r") as file: lines = file.read().splitlines() - query_count = len(lines) / iterations total_bytes = sum(int(line) for line in lines) / iterations with open(millis_file, "r") as file: lines = file.read().splitlines() total_slot_millis = sum(int(line) for line in lines) / iterations - with open(local_seconds_file, "r") as file: - lines = file.read().splitlines() - local_seconds = sum(float(line) for line in lines) / iterations - with open(bq_seconds_file, "r") as file: lines = file.read().splitlines() bq_seconds = sum(float(line) for line in lines) / iterations - with open(query_char_count_file, "r") as file: - lines = file.read().splitlines() - query_char_count = sum(int(line) for line in lines) / iterations - results_dict[str(filename)] = [ query_count, total_bytes, @@ -194,11 +185,11 @@ def collect_benchmark_result( ) print( f"{index} - query count: {row['Query_Count']}," - f" query char count: {row['Query_Char_Count']},", - f" bytes processed sum: {row['Bytes_Processed']}," - f" slot millis sum: {row['Slot_Millis']}," - f" local execution time: {formatted_local_exec_time} seconds," - f" bigquery execution time: {round(row['BigQuery_Execution_Time_Sec'], 1)} seconds", + + f" query char count: {row['Query_Char_Count']}," + + f" bytes processed sum: {row['Bytes_Processed']}," + + f" slot millis sum: {row['Slot_Millis']}," + + f" local execution time: {formatted_local_exec_time}" + + f", bigquery execution time: {round(row['BigQuery_Execution_Time_Sec'], 1)} seconds" ) geometric_mean_queries = geometric_mean_excluding_zeros( @@ -221,25 +212,29 @@ def collect_benchmark_result( ) print( - f"---Geometric mean of queries: {geometric_mean_queries}, " - f"Geometric mean of queries char counts: {geometric_mean_query_char_count}, " - f"Geometric mean of bytes processed: {geometric_mean_bytes}, " - f"Geometric mean of slot millis: {geometric_mean_slot_millis}, " - f"Geometric mean of local execution time: {geometric_mean_local_seconds} seconds, " - f"Geometric mean of BigQuery execution time: {geometric_mean_bq_seconds} seconds---" + f"---Geometric mean of queries: {geometric_mean_queries}," + + f" Geometric mean of queries char counts: {geometric_mean_query_char_count}," + + f" Geometric mean of bytes processed: {geometric_mean_bytes}," + + f" Geometric mean of slot millis: {geometric_mean_slot_millis}," + + f" Geometric mean of local execution time: {geometric_mean_local_seconds} seconds" + + f", Geometric mean of BigQuery execution time: {geometric_mean_bq_seconds} seconds---" ) - error_message = ( - "\n" - + "\n".join( - [ - f"Failed: {error_file.relative_to(path).with_suffix('')}" - for error_file in error_files - ] + all_errors: List[str] = [] + if error_files: + all_errors.extend( + f"Failed: {error_file.relative_to(path).with_suffix('')}" + for error_file in error_files ) - if error_files - else None - ) + if ( + benchmarks_with_missing_files + and os.getenv("BENCHMARK_AND_PUBLISH", "false") == "true" + ): + all_errors.extend( + f"Missing files for benchmark '{name}': {files}" + for name, files in benchmarks_with_missing_files + ) + error_message = "\n" + "\n".join(all_errors) if all_errors else None return ( benchmark_metrics.reset_index().rename(columns={"index": "Benchmark_Name"}), error_message, diff --git a/scripts/test_publish_api_coverage.py b/scripts/test_publish_api_coverage.py index 034a266177..6e366b6854 100644 --- a/scripts/test_publish_api_coverage.py +++ b/scripts/test_publish_api_coverage.py @@ -15,14 +15,15 @@ import sys import pandas +from publish_api_coverage import build_api_coverage_table import pytest -from . import publish_api_coverage +pytest.importorskip("sklearn") @pytest.fixture def api_coverage_df(): - return publish_api_coverage.build_api_coverage_table("my_bf_ver", "my_release_ver") + return build_api_coverage_table("my_bf_ver", "my_release_ver") @pytest.mark.skipif( diff --git a/setup.py b/setup.py index 4386177a5e..fa663f66d5 100644 --- a/setup.py +++ b/setup.py @@ -36,36 +36,33 @@ # please keep these in sync with the minimum versions in testing/constraints-3.9.txt "cloudpickle >= 2.0.0", "fsspec >=2023.3.0", - "gcsfs >=2023.3.0", + "gcsfs >=2023.3.0, !=2025.5.0", "geopandas >=0.12.2", - "google-auth >=2.15.0,<3.0dev", - "google-cloud-bigtable >=2.24.0", - "google-cloud-pubsub >=2.21.4", - "google-cloud-bigquery[bqstorage,pandas] >=3.18.0", + "google-auth >=2.15.0,<3.0", + "google-cloud-bigquery[bqstorage,pandas] >=3.36.0", + # 2.30 needed for arrow support. + "google-cloud-bigquery-storage >= 2.30.0, < 3.0.0", "google-cloud-functions >=1.12.0", "google-cloud-bigquery-connection >=1.12.0", - "google-cloud-iam >=2.12.1", "google-cloud-resource-manager >=1.10.3", "google-cloud-storage >=2.0.0", - # Upper bound due to no windows build for 1.1.2 - "jellyfish >=0.8.9,<1.1.2", + "grpc-google-iam-v1 >= 0.14.2", "numpy >=1.24.0", "pandas >=1.5.3", - "pandas-gbq >=0.26.0", - "pyarrow >=10.0.1", + "pandas-gbq >=0.26.1", + "pyarrow >=15.0.2", "pydata-google-auth >=1.8.2", "requests >=2.27.1", - "scikit-learn >=1.2.2", - "sqlalchemy >=1.4,<3.0dev", - "sqlglot >=23.6.3", + "shapely >=1.8.5", + # 25.20.0 introduces this fix https://github.com/TobikoData/sqlmesh/issues/3095 for rtrim/ltrim. + "sqlglot >=25.20.0", "tabulate >=0.9", "ipywidgets >=7.7.1", "humanize >=4.6.0", "matplotlib >=3.7.1", - "db-dtypes >=1.4.0", + "db-dtypes >=1.4.2", # For vendored ibis-framework. "atpublic>=2.3,<6", - "parsy>=2,<3", "python-dateutil>=2.8.2,<3", "pytz>=2022.7", "toolz>=0.11,<2", @@ -74,11 +71,27 @@ ] extras = { # Optional test dependencies packages. If they're missed, may skip some tests. - "tests": [], - # used for local engine, which is only needed for unit tests at present. - "polars": ["polars >= 1.7.0"], + "tests": [ + "freezegun", + "pytest-snapshot", + "google-cloud-bigtable >=2.24.0", + "google-cloud-pubsub >=2.21.4", + ], + # used for local engine + "polars": ["polars >= 1.21.0"], + "scikit-learn": ["scikit-learn>=1.2.2"], # Packages required for basic development flow. - "dev": ["pytest", "pytest-mock", "pre-commit", "nox", "google-cloud-testutils"], + "dev": [ + "pytest", + "pre-commit", + "nox", + "google-cloud-testutils", + ], + # install anywidget for SQL + "anywidget": [ + "anywidget>=0.9.18", + "traitlets>=5.0.0", + ], } extras["all"] = list(sorted(frozenset(itertools.chain.from_iterable(extras.values())))) @@ -112,6 +125,7 @@ version=version_id, description=description, long_description=readme, + long_description_content_type="text/x-rst", author="Google LLC", author_email="bigframes-feedback@google.com", license="Apache 2.0", diff --git a/specs/2025-08-04-geoseries-scalars.md b/specs/2025-08-04-geoseries-scalars.md new file mode 100644 index 0000000000..e7bc6c61e1 --- /dev/null +++ b/specs/2025-08-04-geoseries-scalars.md @@ -0,0 +1,317 @@ +# Implementing GeoSeries scalar operators + +This project is to implement all GeoSeries scalar properties and methods in the +`bigframes.geopandas.GeoSeries` class. Likewise, all BigQuery GEOGRAPHY +functions should be exposed in the `bigframes.bigquery` module. + +## Background + +*Explain the context and why this change is necessary.* +*Include links to relevant issues or documentation.* + +* https://geopandas.org/en/stable/docs/reference/geoseries.html +* https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions + +## Acceptance Criteria + +*Define the specific, measurable outcomes that indicate the task is complete.* +*Use a checklist format for clarity.* + +### GeoSeries methods and properties + +- [x] Constructor +- [x] GeoSeries.area +- [x] GeoSeries.boundary +- [ ] GeoSeries.bounds +- [ ] GeoSeries.total_bounds +- [x] GeoSeries.length +- [ ] GeoSeries.geom_type +- [ ] GeoSeries.offset_curve +- [x] GeoSeries.distance +- [ ] GeoSeries.hausdorff_distance +- [ ] GeoSeries.frechet_distance +- [ ] GeoSeries.representative_point +- [ ] GeoSeries.exterior +- [ ] GeoSeries.interiors +- [ ] GeoSeries.minimum_bounding_radius +- [ ] GeoSeries.minimum_clearance +- [x] GeoSeries.x +- [x] GeoSeries.y +- [ ] GeoSeries.z +- [ ] GeoSeries.m +- [ ] GeoSeries.get_coordinates +- [ ] GeoSeries.count_coordinates +- [ ] GeoSeries.count_geometries +- [ ] GeoSeries.count_interior_rings +- [ ] GeoSeries.set_precision +- [ ] GeoSeries.get_precision +- [ ] GeoSeries.get_geometry +- [x] GeoSeries.is_closed +- [ ] GeoSeries.is_empty +- [ ] GeoSeries.is_ring +- [ ] GeoSeries.is_simple +- [ ] GeoSeries.is_valid +- [ ] GeoSeries.is_valid_reason +- [ ] GeoSeries.is_valid_coverage +- [ ] GeoSeries.invalid_coverage_edges +- [ ] GeoSeries.has_m +- [ ] GeoSeries.has_z +- [ ] GeoSeries.is_ccw +- [ ] GeoSeries.contains +- [ ] GeoSeries.contains_properly +- [ ] GeoSeries.crosses +- [ ] GeoSeries.disjoint +- [ ] GeoSeries.dwithin +- [ ] GeoSeries.geom_equals +- [ ] GeoSeries.geom_equals_exact +- [ ] GeoSeries.geom_equals_identical +- [ ] GeoSeries.intersects +- [ ] GeoSeries.overlaps +- [ ] GeoSeries.touches +- [ ] GeoSeries.within +- [ ] GeoSeries.covers +- [ ] GeoSeries.covered_by +- [ ] GeoSeries.relate +- [ ] GeoSeries.relate_pattern +- [ ] GeoSeries.clip_by_rect +- [x] GeoSeries.difference +- [x] GeoSeries.intersection +- [ ] GeoSeries.symmetric_difference +- [ ] GeoSeries.union +- [x] GeoSeries.boundary +- [x] GeoSeries.buffer +- [x] GeoSeries.centroid +- [ ] GeoSeries.concave_hull +- [x] GeoSeries.convex_hull +- [ ] GeoSeries.envelope +- [ ] GeoSeries.extract_unique_points +- [ ] GeoSeries.force_2d +- [ ] GeoSeries.force_3d +- [ ] GeoSeries.make_valid +- [ ] GeoSeries.minimum_bounding_circle +- [ ] GeoSeries.maximum_inscribed_circle +- [ ] GeoSeries.minimum_clearance +- [ ] GeoSeries.minimum_clearance_line +- [ ] GeoSeries.minimum_rotated_rectangle +- [ ] GeoSeries.normalize +- [ ] GeoSeries.orient_polygons +- [ ] GeoSeries.remove_repeated_points +- [ ] GeoSeries.reverse +- [ ] GeoSeries.sample_points +- [ ] GeoSeries.segmentize +- [ ] GeoSeries.shortest_line +- [ ] GeoSeries.simplify +- [ ] GeoSeries.simplify_coverage +- [ ] GeoSeries.snap +- [ ] GeoSeries.transform +- [ ] GeoSeries.affine_transform +- [ ] GeoSeries.rotate +- [ ] GeoSeries.scale +- [ ] GeoSeries.skew +- [ ] GeoSeries.translate +- [ ] GeoSeries.interpolate +- [ ] GeoSeries.line_merge +- [ ] GeoSeries.project +- [ ] GeoSeries.shared_paths +- [ ] GeoSeries.build_area +- [ ] GeoSeries.constrained_delaunay_triangles +- [ ] GeoSeries.delaunay_triangles +- [ ] GeoSeries.explode +- [ ] GeoSeries.intersection_all +- [ ] GeoSeries.polygonize +- [ ] GeoSeries.union_all +- [ ] GeoSeries.voronoi_polygons +- [ ] GeoSeries.from_arrow +- [ ] GeoSeries.from_file +- [ ] GeoSeries.from_wkb +- [x] GeoSeries.from_wkt +- [x] GeoSeries.from_xy +- [ ] GeoSeries.to_arrow +- [ ] GeoSeries.to_file +- [ ] GeoSeries.to_json +- [ ] GeoSeries.to_wkb +- [x] GeoSeries.to_wkt +- [ ] GeoSeries.crs +- [ ] GeoSeries.set_crs +- [ ] GeoSeries.to_crs +- [ ] GeoSeries.estimate_utm_crs +- [ ] GeoSeries.fillna +- [ ] GeoSeries.isna +- [ ] GeoSeries.notna +- [ ] GeoSeries.clip +- [ ] GeoSeries.plot +- [ ] GeoSeries.explore +- [ ] GeoSeries.sindex +- [ ] GeoSeries.has_sindex +- [ ] GeoSeries.cx +- [ ] GeoSeries.__geo_interface__ + +### `bigframes.pandas` methods + +Constructors: Functions that build new geography values from coordinates or +existing geographies. + +- [x] ST_GEOGPOINT +- [ ] ST_MAKELINE +- [ ] ST_MAKEPOLYGON +- [ ] ST_MAKEPOLYGONORIENTED + +Parsers ST_GEOGFROM: Functions that create geographies from an external format +such as WKT and GeoJSON. + +- [ ] ST_GEOGFROMGEOJSON +- [x] ST_GEOGFROMTEXT +- [ ] ST_GEOGFROMWKB +- [ ] ST_GEOGPOINTFROMGEOHASH + +Formatters: Functions that export geographies to an external format such as WKT. + +- [ ] ST_ASBINARY +- [ ] ST_ASGEOJSON +- [x] ST_ASTEXT +- [ ] ST_GEOHASH + +Transformations: Functions that generate a new geography based on input. + +- [x] ST_BOUNDARY +- [x] ST_BUFFER +- [ ] ST_BUFFERWITHTOLERANCE +- [x] ST_CENTROID +- [ ] ST_CENTROID_AGG (Aggregate) +- [ ] ST_CLOSESTPOINT +- [x] ST_CONVEXHULL +- [x] ST_DIFFERENCE +- [ ] ST_EXTERIORRING +- [ ] ST_INTERIORRINGS +- [x] ST_INTERSECTION +- [ ] ST_LINEINTERPOLATEPOINT +- [ ] ST_LINESUBSTRING +- [ ] ST_SIMPLIFY +- [ ] ST_SNAPTOGRID +- [ ] ST_UNION +- [ ] ST_UNION_AGG (Aggregate) + +Accessors: Functions that provide access to properties of a geography without +side-effects. + +- [ ] ST_DIMENSION +- [ ] ST_DUMP +- [ ] ST_ENDPOINT +- [ ] ST_GEOMETRYTYPE +- [x] ST_ISCLOSED +- [ ] ST_ISCOLLECTION +- [ ] ST_ISEMPTY +- [ ] ST_ISRING +- [ ] ST_NPOINTS +- [ ] ST_NUMGEOMETRIES +- [ ] ST_NUMPOINTS +- [ ] ST_POINTN +- [ ] ST_STARTPOINT +- [x] ST_X +- [x] ST_Y + +Predicates: Functions that return TRUE or FALSE for some spatial relationship +between two geographies or some property of a geography. These functions are +commonly used in filter clauses. + +- [ ] ST_CONTAINS +- [ ] ST_COVEREDBY +- [ ] ST_COVERS +- [ ] ST_DISJOINT +- [ ] ST_DWITHIN +- [ ] ST_EQUALS +- [ ] ST_HAUSDORFFDWITHIN +- [ ] ST_INTERSECTS +- [ ] ST_INTERSECTSBOX +- [ ] ST_TOUCHES +- [ ] ST_WITHIN + +Measures: Functions that compute measurements of one or more geographies. + +- [ ] ST_ANGLE +- [x] ST_AREA +- [ ] ST_AZIMUTH +- [ ] ST_BOUNDINGBOX +- [x] ST_DISTANCE +- [ ] ST_EXTENT (Aggregate) +- [ ] ST_HAUSDORFFDISTANCE +- [ ] ST_LINELOCATEPOINT +- [x] ST_LENGTH +- [ ] ST_MAXDISTANCE +- [ ] ST_PERIMETER + +Clustering: Functions that perform clustering on geographies. + +- [ ] ST_CLUSTERDBSCAN + +S2 functions: Functions for working with S2 cell coverings of GEOGRAPHY. + +- [ ] S2_CELLIDFROMPOINT +- [ ] S2_COVERINGCELLIDS + +Raster functions: Functions for analyzing geospatial rasters using geographies. + +- [ ] ST_REGIONSTATS + +## Detailed Steps + +*Break down the implementation into small, actionable steps.* +*This section will guide the development process.* + +### Implementing a new scalar geography operation + +- [ ] **Define the operation dataclass:** + - [ ] In `bigframes/operations/geo_ops.py`, create a new dataclass + inheriting from `base_ops.UnaryOp` or `base_ops.BinaryOp`. Note that + BinaryOp is for methods that take two **columns**. Any literal values can + be passed as parameters to a UnaryOp. + - [ ] Define the `name` of the operation and any parameters it requires. + - [ ] Implement the `output_type` method to specify the data type of the result. +- [ ] **Export the new operation:** + - [ ] In `bigframes/operations/__init__.py`, import your new operation dataclass and add it to the `__all__` list. +- [ ] **Implement the compilation logic:** + - [ ] In `bigframes/core/compile/ibis_compiler/operations/geo_ops.py`: + - [ ] If the BigQuery function has a direct equivalent in Ibis, you can often reuse an existing Ibis method. + - [ ] If not, define a new Ibis UDF using `@ibis_udf.scalar.builtin` to map to the specific BigQuery function signature. + - [ ] Create a new compiler implementation function (e.g., `geo_length_op_impl`). + - [ ] Register this function to your operation dataclass using `@register_unary_op` or `@register_binary_op`. + - [ ] In `bigframes/core/compile/sqlglot/expressions/geo_ops.py`: + - [ ] Create a new compiler implementation function that generates the appropriate `sqlglot.exp` expression. + - [ ] Register this function to your operation dataclass using `@register_unary_op` or `@register_binary_op`. +- [ ] **Implement the user-facing function or property:** + - [ ] For a `bigframes.bigquery` function: + - [ ] In `bigframes/bigquery/_operations/geo.py`, create the user-facing function (e.g., `st_length`). + - [ ] The function should take a `Series` and any other parameters. + - [ ] Inside the function, call `series._apply_unary_op` or `series._apply_binary_op`, passing the operation dataclass you created. + - [ ] Add a comprehensive docstring with examples. + - [ ] In `bigframes/bigquery/__init__.py`, import your new user-facing function and add it to the `__all__` list. + - [ ] For a `GeoSeries` property or method: + - [ ] In `bigframes/geopandas/geoseries.py`, create the property or + method. Omit the docstring. + - [ ] If the operation is not possible to be supported, such as if the + geopandas method returns values in units corresponding to the + coordinate system rather than meters that BigQuery uses, raise a + `NotImplementedError` with a helpful message. Likewise, if a + required parameter takes a value in terms of the coordinate + system, but BigQuery uses meters, raise a `NotImplementedError`. + - [ ] Otherwise, call `series._apply_unary_op` or `series._apply_binary_op`, passing the operation dataclass. + - [ ] Add a comprehensive docstring with examples to the superclass in + `third_party/bigframes_vendored/geopandas/geoseries.py`. +- [ ] **Add Tests:** + - [ ] Add system tests in `tests/system/small/bigquery/test_geo.py` or `tests/system/small/geopandas/test_geoseries.py` to verify the end-to-end functionality. Test various inputs, including edge cases and `NULL` values. + - [ ] If you are overriding a pandas or GeoPandas property and raising `NotImplementedError`, add a unit test to ensure the correct error is raised. + +## Verification + +*Specify the commands to run to verify the changes.* + +- [ ] The `nox -r -s format lint lint_setup_py` linter should pass. +- [ ] The `nox -r -s mypy` static type checker should pass. +- [ ] The `nox -r -s docs docfx` docs should successfully build and include relevant docs in the output. +- [ ] All new and existing unit tests `pytest tests/unit` should pass. +- [ ] Identify all related system tests in the `tests/system` directories. +- [ ] All related system tests `pytest tests/system/small/path_to_relevant_test.py::test_name` should pass. + +## Constraints + +Follow the guidelines listed in GEMINI.md at the root of the repository. diff --git a/specs/2025-08-11-anywidget-align-text.md b/specs/2025-08-11-anywidget-align-text.md new file mode 100644 index 0000000000..03305538dc --- /dev/null +++ b/specs/2025-08-11-anywidget-align-text.md @@ -0,0 +1,132 @@ +# Anywidget: align text left and numerics right + +The "anywidget" rendering mode outputs an HTML table per page right now, but +the values need to be aligned according to their data type. + +## Background + +Anywidget currently renders pages like the following: + +```html + + + + + + + + + + + + + + + + + + + + + + + + + + + +
stategenderyearnamenumber
VAM1930Pat6
TXM1968Kennith18
+``` + +* This change fixes internal issue b/437697339. +* Numeric data should be right aligned so that it is easier to compare numbers, + especially if they all are rounded to the same precision. +* Text data is better left aligned, since many languages read left to right. + +## Acceptance Criteria + +- [ ] Header cells should align left. +- [ ] Header cells should use the resize CSS property to allow resizing. +- [ ] STRING columns are left-aligned in the output of `TableWidget` in + `bigframes/display/anywidget.py`. +- [ ] Numeric columns (INT64, FLOAT64, NUMERIC, BIGNUMERIC) are right-aligned + in the output of `TableWidget` in `bigframes/display/anywidget.py`. +- [ ] Create option `DisplayOptions.precision` in + `bigframes/_config/display_options.py` that can override the output + precision (defaults to 6, just like `pandas.options.display.precision`). +- [ ] All other non-numeric column types, including BYTES, BOOLEAN, TIMESTAMP, + and more, are left-aligned in the output of `TableWidget` in + `bigframes/display/anywidget.py`. +- [ ] There are parameterized unit tests verifying the alignment is set + correctly. + +## Detailed Steps + +### 1. Create Display Precision Configuration + +- [ ] In `bigframes/_config/display_options.py`, add a new `precision` attribute to the `DisplayOptions` dataclass. +- [ ] Set the default value to `6`. +- [ ] Add precision to the items in `def pandas_repr` that get passed to the pandas options context. +- [ ] Add a docstring explaining that it controls the floating point output precision, similar to `pandas.options.display.precision`. +- [ ] Check these items off with `[x]` as they are completed. + +### 2. Improve the headers + +- [ ] Create `bigframes/display/html.py`. +- [ ] In `bigframes/display/html.py`, create a `def render_html(*, dataframe: pandas.DataFrame, table_id: str)` method. +- [ ] Loop through the column names to create the table head. +- [ ] Apply the `text-align: left` style to the header. +- [ ] Wrap the cell text in a resizable `div`. +- [ ] Check these items off with `[x]` as they are completed. + +### 3. Implement Alignment and Precision Logic in TableWidget + +- [ ] Create a helper function `_is_dtype_numeric(dtype)` that takes a pandas + dtype returns True for types that that should be right-aligned. These + dtypes should correspond to the BigQuery data types: `INT64`, `FLOAT64`, + `NUMERIC`, `BIGNUMERIC`. Use the `bigframes.dtypes` module to map from + pandas type to BigQuery type. +- [ ] In the loop that generates the table rows (`` elements), add a function to determine the style based on the column's `dtype`. +- [ ] If the column's `dtype` is in the numeric set, apply the CSS style `text-align: right`. +- [ ] For all other `dtypes` (including `STRING`, `BYTES`, `BOOLEAN`, `TIMESTAMP`, etc.), apply `text-align: left`. +- [ ] When formatting floating-point numbers for display, use the `bigframes.options.display.precision` value. +- [ ] In `bigframes/display/anywidget.py`, modify the `_set_table_html` method of the `TableWidget` class to call `bigframes.display.html.render_html(...)`. +- [ ] Render the notebook at `notebooks/dataframes/anywidget_mode.ipynb` with + the `jupyter nbconvert --to notebook --execute notebooks/dataframes/anywidget_mode.ipynb` + command and validate that the rendered notebook includes the desired + changes to the HTML tables. +- [ ] Check these items off with `[x]` as they are completed. + +### 4. Add Parameterized Unit Tests + +- [ ] Create a new test file: `tests/unit/display/test_html.py`. +- [ ] Create a parameterized test method, e.g., `test_render_html_alignment_and_precision`. +- [ ] Use `@pytest.mark.parametrize` to test various scenarios. +- [ ] **Scenario 1: Alignment.** + - Create a sample `bigframes.dataframe.DataFrame` with columns of different types: a string, an integer, a float, and a boolean. + - Render the `pandas.DataFrame` to HTML. + - Assert that the integer and float column headers and data cells (`` and ``) have `style="text-align: right;"`. + - Assert that the string and boolean columns have `style="text-align: left;"`. +- [ ] **Scenario 2: Precision.** + - Create a `bigframes.dataframe.DataFrame` with a `FLOAT64` column containing a number with many decimal places (e.g., `3.14159265`). + - Set `bigframes.options.display.precision = 4`. + - Render the `pandas.DataFrame` to HTML. + - Assert that the output string contains the number formatted to 4 decimal places (e.g., `3.1416`). + - Remember to reset the option value after the test to avoid side effects. +- [ ] Check these items off with `[x]` as they are completed. + +## Verification + +*Specify the commands to run to verify the changes.* + +- [ ] The `nox -r -s format lint lint_setup_py` linter should pass. +- [ ] The `nox -r -s mypy` static type checker should pass. +- [ ] The `nox -r -s docs docfx` docs should successfully build and include relevant docs in the output. +- [ ] All new and existing unit tests `pytest tests/unit` should pass. +- [ ] Identify all related system tests in the `tests/system` directories. +- [ ] All related system tests `pytest tests/system/small/path_to_relevant_test.py::test_name` should pass. +- [ ] Check these items off with `[x]` as they are completed. + +## Constraints + +Follow the guidelines listed in GEMINI.md at the root of the repository. diff --git a/specs/TEMPLATE.md b/specs/TEMPLATE.md new file mode 100644 index 0000000000..0d93035dcc --- /dev/null +++ b/specs/TEMPLATE.md @@ -0,0 +1,47 @@ +# Title of the Specification + +*Provide a brief overview of the feature or bug.* + +## Background + +*Explain the context and why this change is necessary.* +*Include links to relevant issues or documentation.* + +## Acceptance Criteria + +*Define the specific, measurable outcomes that indicate the task is complete.* +*Use a checklist format for clarity.* + +- [ ] Criterion 1 +- [ ] Criterion 2 +- [ ] Criterion 3 + +## Detailed Steps + +*Break down the implementation into small, actionable steps.* +*This section will guide the development process.* + +### 1. Step One + +- [ ] Action 1.1 +- [ ] Action 1.2 + +### 2. Step Two + +- [ ] Action 2.1 +- [ ] Action 2.2 + +## Verification + +*Specify the commands to run to verify the changes.* + +- [ ] The `nox -r -s format lint lint_setup_py` linter should pass. +- [ ] The `nox -r -s mypy` static type checker should pass. +- [ ] The `nox -r -s docs docfx` docs should successfully build and include relevant docs in the output. +- [ ] All new and existing unit tests `pytest tests/unit` should pass. +- [ ] Identify all related system tests in the `tests/system` directories. +- [ ] All related system tests `pytest tests/system/small/path_to_relevant_test.py::test_name` should pass. + +## Constraints + +Follow the guidelines listed in GEMINI.md at the root of the repository. diff --git a/testing/constraints-3.10.txt b/testing/constraints-3.10.txt index b11ab5a88d..1695a4806b 100644 --- a/testing/constraints-3.10.txt +++ b/testing/constraints-3.10.txt @@ -1,4 +1,5 @@ -# Keep in sync with colab/containers/requirements.core.in image +# When we drop Python 3.9, +# please keep these in sync with the minimum versions in setup.py google-auth==2.27.0 ipykernel==5.5.6 ipython==7.34.0 @@ -15,3 +16,4 @@ matplotlib==3.7.1 psutil==5.9.5 seaborn==0.13.1 traitlets==5.7.1 +polars==1.21.0 diff --git a/testing/constraints-3.11.txt b/testing/constraints-3.11.txt index e69de29bb2..8c274bd9fb 100644 --- a/testing/constraints-3.11.txt +++ b/testing/constraints-3.11.txt @@ -0,0 +1,621 @@ +# Keep in sync with %pip freeze in colab. +# Note: These are just constraints, so it's ok to have extra packages we +# aren't installing, except in the version that gets used for prerelease +# tests. +absl-py==1.4.0 +accelerate==1.9.0 +aiofiles==24.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.12.15 +aiosignal==1.4.0 +alabaster==1.0.0 +albucore==0.0.24 +albumentations==2.0.8 +ale-py==0.11.2 +altair==5.5.0 +annotated-types==0.7.0 +antlr4-python3-runtime==4.9.3 +anyio==4.10.0 +anywidget==0.9.18 +argon2-cffi==25.1.0 +argon2-cffi-bindings==25.1.0 +array_record==0.7.2 +arviz==0.22.0 +astropy==7.1.0 +astropy-iers-data==0.2025.8.4.0.42.59 +astunparse==1.6.3 +atpublic==5.1 +attrs==25.3.0 +audioread==3.0.1 +autograd==1.8.0 +babel==2.17.0 +backcall==0.2.0 +backports.tarfile==1.2.0 +beautifulsoup4==4.13.4 +betterproto==2.0.0b6 +bigquery-magics==0.10.2 +bleach==6.2.0 +blinker==1.9.0 +blis==1.3.0 +blobfile==3.0.0 +blosc2==3.6.1 +bokeh==3.7.3 +Bottleneck==1.4.2 +bqplot==0.12.45 +branca==0.8.1 +Brotli==1.1.0 +build==1.3.0 +CacheControl==0.14.3 +cachetools==5.5.2 +catalogue==2.0.10 +certifi==2025.8.3 +cffi==1.17.1 +chardet==5.2.0 +charset-normalizer==3.4.2 +chex==0.1.90 +clarabel==0.11.1 +click==8.2.1 +cloudpathlib==0.21.1 +cloudpickle==3.1.1 +cmake==3.31.6 +cmdstanpy==1.2.5 +colorcet==3.1.0 +colorlover==0.3.0 +colour==0.1.5 +community==1.0.0b1 +confection==0.1.5 +cons==0.4.7 +contourpy==1.3.3 +cramjam==2.11.0 +cryptography==43.0.3 +cuda-python==12.6.2.post1 +cudf-polars-cu12==25.6.0 +cufflinks==0.17.3 +cuml-cu12==25.6.0 +cupy-cuda12x==13.3.0 +curl_cffi==0.12.0 +cuvs-cu12==25.6.1 +cvxopt==1.3.2 +cvxpy==1.6.7 +cycler==0.12.1 +cyipopt==1.5.0 +cymem==2.0.11 +Cython==3.0.12 +dask==2025.5.0 +dask-cuda==25.6.0 +dask-cudf-cu12==25.6.0 +dataproc-spark-connect==0.8.3 +datasets==4.0.0 +db-dtypes==1.4.3 +dbus-python==1.2.18 +debugpy==1.8.15 +decorator==4.4.2 +defusedxml==0.7.1 +diffusers==0.34.0 +dill==0.3.8 +distributed==2025.5.0 +distributed-ucxx-cu12==0.44.0 +distro==1.9.0 +dlib==19.24.6 +dm-tree==0.1.9 +docstring_parser==0.17.0 +docutils==0.21.2 +dopamine_rl==4.1.2 +duckdb==1.3.2 +earthengine-api==1.5.24 +easydict==1.13 +editdistance==0.8.1 +eerepr==0.1.2 +einops==0.8.1 +entrypoints==0.4 +et_xmlfile==2.0.0 +etils==1.13.0 +etuples==0.3.10 +Farama-Notifications==0.0.4 +fastai==2.7.19 +fastapi==0.116.1 +fastcore==1.7.29 +fastdownload==0.0.7 +fastjsonschema==2.21.1 +fastprogress==1.0.3 +fastrlock==0.8.3 +ffmpy==0.6.1 +filelock==3.18.0 +firebase-admin==6.9.0 +Flask==3.1.1 +flatbuffers==25.2.10 +flax==0.10.6 +folium==0.20.0 +fonttools==4.59.0 +frozendict==2.4.6 +frozenlist==1.7.0 +fsspec==2025.3.0 +future==1.0.0 +gast==0.6.0 +gcsfs==2025.3.0 +GDAL==3.8.4 +gdown==5.2.0 +geemap==0.35.3 +geocoder==1.38.1 +geographiclib==2.0 +geopandas==1.1.1 +geopy==2.4.1 +gin-config==0.5.0 +gitdb==4.0.12 +GitPython==3.1.45 +glob2==0.7 +google==2.0.3 +google-ai-generativelanguage==0.6.15 +google-api-core==2.25.1 +google-api-python-client==2.177.0 +google-auth==2.38.0 +google-auth-httplib2==0.2.0 +google-auth-oauthlib==1.2.2 +google-cloud-aiplatform==1.106.0 +google-cloud-bigquery==3.36.0 +google-cloud-bigquery-connection==1.18.3 +google-cloud-bigquery-storage==2.32.0 +google-cloud-core==2.4.3 +google-cloud-dataproc==5.21.0 +google-cloud-datastore==2.21.0 +google-cloud-firestore==2.21.0 +google-cloud-functions==1.20.4 +google-cloud-language==2.17.2 +google-cloud-resource-manager==1.14.2 +google-cloud-spanner==3.56.0 +google-cloud-storage==2.19.0 +google-cloud-translate==3.21.1 +google-crc32c==1.7.1 +google-genai==1.28.0 +google-generativeai==0.8.5 +google-pasta==0.2.0 +google-resumable-media==2.7.2 +googleapis-common-protos==1.70.0 +googledrivedownloader==1.1.0 +gradio==5.39.0 +gradio_client==1.11.0 +graphviz==0.21 +greenlet==3.2.3 +groovy==0.1.2 +grpc-google-iam-v1==0.14.2 +grpc-interceptor==0.15.4 +grpcio==1.74.0 +grpcio-status==1.71.2 +grpclib==0.4.8 +gspread==6.2.1 +gspread-dataframe==4.0.0 +gym==0.25.2 +gym-notices==0.1.0 +gymnasium==1.2.0 +h11==0.16.0 +h2==4.2.0 +h5netcdf==1.6.3 +h5py==3.14.0 +hdbscan==0.8.40 +hf-xet==1.1.5 +hf_transfer==0.1.9 +highspy==1.11.0 +holidays==0.78 +holoviews==1.21.0 +hpack==4.1.0 +html5lib==1.1 +httpcore==1.0.9 +httpimport==1.4.1 +httplib2==0.22.0 +httpx==0.28.1 +huggingface-hub==0.34.3 +humanize==4.12.3 +hyperframe==6.1.0 +hyperopt==0.2.7 +ibis-framework==9.5.0 +idna==3.10 +imageio==2.37.0 +imageio-ffmpeg==0.6.0 +imagesize==1.4.1 +imbalanced-learn==0.13.0 +immutabledict==4.2.1 +importlib_metadata==8.7.0 +importlib_resources==6.5.2 +imutils==0.5.4 +inflect==7.5.0 +iniconfig==2.1.0 +intel-cmplr-lib-ur==2025.2.0 +intel-openmp==2025.2.0 +ipyevents==2.0.2 +ipyfilechooser==0.6.0 +ipykernel==6.17.1 +ipyleaflet==0.20.0 +ipyparallel==8.8.0 +ipython==7.34.0 +ipython-genutils==0.2.0 +ipython-sql==0.5.0 +ipytree==0.2.2 +ipywidgets==7.7.1 +itsdangerous==2.2.0 +jaraco.classes==3.4.0 +jaraco.context==6.0.1 +jaraco.functools==4.2.1 +jax==0.5.3 +jax-cuda12-pjrt==0.5.3 +jax-cuda12-plugin==0.5.3 +jaxlib==0.5.3 +jeepney==0.9.0 +jieba==0.42.1 +Jinja2==3.1.6 +jiter==0.10.0 +joblib==1.5.1 +jsonpatch==1.33 +jsonpickle==4.1.1 +jsonpointer==3.0.0 +jsonschema==4.25.0 +jsonschema-specifications==2025.4.1 +jupyter-client==6.1.12 +jupyter-console==6.1.0 +jupyter-leaflet==0.20.0 +jupyter-server==1.16.0 +jupyter_core==5.8.1 +jupyterlab_pygments==0.3.0 +jupyterlab_widgets==3.0.15 +jupytext==1.17.2 +kaggle==1.7.4.5 +kagglehub==0.3.12 +keras==3.10.0 +keras-hub==0.21.1 +keras-nlp==0.21.1 +keyring==25.6.0 +keyrings.google-artifactregistry-auth==1.1.2 +kiwisolver==1.4.8 +langchain==0.3.27 +langchain-core==0.3.72 +langchain-text-splitters==0.3.9 +langcodes==3.5.0 +langsmith==0.4.10 +language_data==1.3.0 +launchpadlib==1.10.16 +lazr.restfulclient==0.14.4 +lazr.uri==1.0.6 +lazy_loader==0.4 +libclang==18.1.1 +libcugraph-cu12==25.6.0 +libcuml-cu12==25.6.0 +libcuvs-cu12==25.6.1 +libkvikio-cu12==25.6.0 +libpysal==4.13.0 +libraft-cu12==25.6.0 +librmm-cu12==25.6.0 +librosa==0.11.0 +libucx-cu12==1.18.1 +libucxx-cu12==0.44.0 +linkify-it-py==2.0.3 +llvmlite==0.43.0 +locket==1.0.0 +logical-unification==0.4.6 +lxml==5.4.0 +Mako==1.1.3 +marisa-trie==1.2.1 +Markdown==3.8.2 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +matplotlib==3.10.0 +matplotlib-inline==0.1.7 +matplotlib-venn==1.1.2 +mdit-py-plugins==0.4.2 +mdurl==0.1.2 +miniKanren==1.0.5 +missingno==0.5.2 +mistune==3.1.3 +mizani==0.13.5 +mkl==2025.2.0 +ml_dtypes==0.5.3 +mlxtend==0.23.4 +more-itertools==10.7.0 +moviepy==1.0.3 +mpmath==1.3.0 +msgpack==1.1.1 +multidict==6.6.3 +multipledispatch==1.0.0 +multiprocess==0.70.16 +multitasking==0.0.12 +murmurhash==1.0.13 +music21==9.3.0 +namex==0.1.0 +narwhals==2.0.1 +natsort==8.4.0 +nbclassic==1.3.1 +nbclient==0.10.2 +nbconvert==7.16.6 +nbformat==5.10.4 +ndindex==1.10.0 +nest-asyncio==1.6.0 +networkx==3.5 +nibabel==5.3.2 +nltk==3.9.1 +notebook==6.5.7 +notebook_shim==0.2.4 +numba==0.60.0 +numba-cuda==0.11.0 +numexpr==2.11.0 +numpy==2.0.2 +nvidia-cublas-cu12==12.5.3.2 +nvidia-cuda-cupti-cu12==12.5.82 +nvidia-cuda-nvcc-cu12==12.5.82 +nvidia-cuda-nvrtc-cu12==12.5.82 +nvidia-cuda-runtime-cu12==12.5.82 +nvidia-cudnn-cu12==9.3.0.75 +nvidia-cufft-cu12==11.2.3.61 +nvidia-curand-cu12==10.3.6.82 +nvidia-cusolver-cu12==11.6.3.83 +nvidia-cusparse-cu12==12.5.1.3 +nvidia-cusparselt-cu12==0.6.2 +nvidia-ml-py==12.575.51 +nvidia-nccl-cu12==2.23.4 +nvidia-nvjitlink-cu12==12.5.82 +nvidia-nvtx-cu12==12.4.127 +nvtx==0.2.13 +oauth2client==4.1.3 +oauthlib==3.3.1 +omegaconf==2.3.0 +openai==1.98.0 +opencv-contrib-python==4.12.0.88 +opencv-python==4.12.0.88 +opencv-python-headless==4.12.0.88 +openpyxl==3.1.5 +opt_einsum==3.4.0 +optax==0.2.5 +optree==0.17.0 +orbax-checkpoint==0.11.20 +orjson==3.11.1 +osqp==1.0.4 +packaging==25.0 +pandas==2.2.2 +pandas-datareader==0.10.0 +pandas-gbq==0.29.2 +pandas-stubs==2.2.2.240909 +pandocfilters==1.5.1 +panel==1.7.5 +param==2.2.1 +parso==0.8.4 +parsy==2.1 +partd==1.4.2 +patsy==1.0.1 +peewee==3.18.2 +peft==0.17.0 +pexpect==4.9.0 +pickleshare==0.7.5 +pillow==11.3.0 +platformdirs==4.3.8 +plotly==5.24.1 +plotnine==0.14.5 +pluggy==1.6.0 +ply==3.11 +polars==1.25.2 +pooch==1.8.2 +portpicker==1.5.2 +preshed==3.0.10 +prettytable==3.16.0 +proglog==0.1.12 +progressbar2==4.5.0 +prometheus_client==0.22.1 +promise==2.3 +prompt_toolkit==3.0.51 +propcache==0.3.2 +prophet==1.1.7 +proto-plus==1.26.1 +protobuf==5.29.5 +psutil==5.9.5 +psycopg2==2.9.10 +psygnal==0.14.0 +ptyprocess==0.7.0 +py-cpuinfo==9.0.0 +py4j==0.10.9.7 +pyarrow==18.1.0 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +pycairo==1.28.0 +pycocotools==2.0.10 +pycparser==2.22 +pycryptodomex==3.23.0 +pydantic==2.11.7 +pydantic_core==2.33.2 +pydata-google-auth==1.9.1 +pydot==3.0.4 +pydotplus==2.0.2 +PyDrive2==1.21.3 +pydub==0.25.1 +pyerfa==2.0.1.5 +pygame==2.6.1 +pygit2==1.18.1 +Pygments==2.19.2 +PyGObject==3.42.0 +PyJWT==2.10.1 +pylibcugraph-cu12==25.6.0 +pylibraft-cu12==25.6.0 +pymc==5.25.1 +pynndescent==0.5.13 +pynvjitlink-cu12==0.7.0 +pynvml==12.0.0 +pyogrio==0.11.1 +pyomo==6.9.2 +PyOpenGL==3.1.9 +pyOpenSSL==24.2.1 +pyparsing==3.2.3 +pyperclip==1.9.0 +pyproj==3.7.1 +pyproject_hooks==1.2.0 +pyshp==2.3.1 +PySocks==1.7.1 +pyspark==3.5.1 +pytensor==2.31.7 +python-apt==0.0.0 +python-box==7.3.2 +python-dateutil==2.9.0.post0 +python-louvain==0.16 +python-multipart==0.0.20 +python-slugify==8.0.4 +python-snappy==0.7.3 +python-utils==3.9.1 +pytz==2025.2 +pyviz_comms==3.0.6 +PyWavelets==1.9.0 +PyYAML==6.0.2 +pyzmq==26.2.1 +raft-dask-cu12==25.6.0 +rapids-dask-dependency==25.6.0 +rapids-logger==0.1.1 +ratelim==0.1.6 +referencing==0.36.2 +regex==2024.11.6 +requests==2.32.3 +requests-oauthlib==2.0.0 +requests-toolbelt==1.0.0 +requirements-parser==0.9.0 +rich==13.9.4 +rmm-cu12==25.6.0 +roman-numerals-py==3.1.0 +rpds-py==0.26.0 +rpy2==3.5.17 +rsa==4.9.1 +ruff==0.12.7 +safehttpx==0.1.6 +safetensors==0.5.3 +scikit-image==0.25.2 +scikit-learn==1.6.1 +scipy==1.16.1 +scooby==0.10.1 +scs==3.2.7.post2 +seaborn==0.13.2 +SecretStorage==3.3.3 +semantic-version==2.10.0 +Send2Trash==1.8.3 +sentence-transformers==4.1.0 +sentencepiece==0.2.0 +sentry-sdk==2.34.1 +shap==0.48.0 +shapely==2.1.1 +shellingham==1.5.4 +simple-parsing==0.1.7 +simplejson==3.20.1 +simsimd==6.5.0 +six==1.17.0 +sklearn-compat==0.1.3 +sklearn-pandas==2.2.0 +slicer==0.0.8 +smart_open==7.3.0.post1 +smmap==5.0.2 +sniffio==1.3.1 +snowballstemmer==3.0.1 +sortedcontainers==2.4.0 +soundfile==0.13.1 +soupsieve==2.7 +soxr==0.5.0.post1 +spacy==3.8.7 +spacy-legacy==3.0.12 +spacy-loggers==1.0.5 +spanner-graph-notebook==1.1.7 +Sphinx==8.2.3 +sphinxcontrib-applehelp==2.0.0 +sphinxcontrib-devhelp==2.0.0 +sphinxcontrib-htmlhelp==2.1.0 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +SQLAlchemy==2.0.42 +sqlglot==25.20.2 +sqlparse==0.5.3 +srsly==2.5.1 +stanio==0.5.1 +starlette==0.47.2 +statsmodels==0.14.5 +stringzilla==3.12.5 +stumpy==1.13.0 +sympy==1.13.1 +tables==3.10.2 +tabulate==0.9.0 +tbb==2022.2.0 +tblib==3.1.0 +tcmlib==1.4.0 +tenacity==8.5.0 +tensorboard==2.19.0 +tensorboard-data-server==0.7.2 +tensorflow==2.19.0 +tensorflow-datasets==4.9.9 +tensorflow-hub==0.16.1 +tensorflow-io-gcs-filesystem==0.37.1 +tensorflow-metadata==1.17.2 +tensorflow-probability==0.25.0 +tensorflow-text==2.19.0 +tensorflow_decision_forests==1.12.0 +tensorstore==0.1.76 +termcolor==3.1.0 +terminado==0.18.1 +text-unidecode==1.3 +textblob==0.19.0 +tf-slim==1.1.0 +tf_keras==2.19.0 +thinc==8.3.6 +threadpoolctl==3.6.0 +tifffile==2025.6.11 +tiktoken==0.9.0 +timm==1.0.19 +tinycss2==1.4.0 +tokenizers==0.21.4 +toml==0.10.2 +tomlkit==0.13.3 +toolz==0.12.1 +torchao==0.10.0 +torchdata==0.11.0 +torchsummary==1.5.1 +torchtune==0.6.1 +tornado==6.4.2 +tqdm==4.67.1 +traitlets==5.7.1 +traittypes==0.2.1 +transformers==4.54.1 +treelite==4.4.1 +treescope==0.1.9 +triton==3.2.0 +tsfresh==0.21.0 +tweepy==4.16.0 +typeguard==4.4.4 +typer==0.16.0 +types-pytz==2025.2.0.20250516 +types-setuptools==80.9.0.20250801 +typing-inspection==0.4.1 +typing_extensions==4.14.1 +tzdata==2025.2 +tzlocal==5.3.1 +uc-micro-py==1.0.3 +ucx-py-cu12==0.44.0 +ucxx-cu12==0.44.0 +umap-learn==0.5.9.post2 +umf==0.11.0 +uritemplate==4.2.0 +urllib3==2.5.0 +uvicorn==0.35.0 +vega-datasets==0.9.0 +wadllib==1.3.6 +wandb==0.21.0 +wasabi==1.1.3 +wcwidth==0.2.13 +weasel==0.4.1 +webcolors==24.11.1 +webencodings==0.5.1 +websocket-client==1.8.0 +websockets==15.0.1 +Werkzeug==3.1.3 +widgetsnbextension==3.6.10 +wordcloud==1.9.4 +wrapt==1.17.2 +wurlitzer==3.1.1 +xarray==2025.7.1 +xarray-einstats==0.9.1 +xgboost==3.0.3 +xlrd==2.0.2 +xxhash==3.5.0 +xyzservices==2025.4.0 +yarl==1.20.1 +ydf==0.13.0 +yellowbrick==1.5 +yfinance==0.2.65 +zict==3.0.0 +zipp==3.23.0 diff --git a/testing/constraints-3.9.txt b/testing/constraints-3.9.txt index 8b7ad892c0..b8dc8697d6 100644 --- a/testing/constraints-3.9.txt +++ b/testing/constraints-3.9.txt @@ -6,32 +6,34 @@ geopandas==0.12.2 google-auth==2.15.0 google-cloud-bigtable==2.24.0 google-cloud-pubsub==2.21.4 -google-cloud-bigquery==3.18.0 +google-cloud-bigquery==3.36.0 google-cloud-functions==1.12.0 google-cloud-bigquery-connection==1.12.0 google-cloud-iam==2.12.1 google-cloud-resource-manager==1.10.3 google-cloud-storage==2.0.0 -jellyfish==0.8.9 +grpc-google-iam-v1==0.14.2 numpy==1.24.0 pandas==1.5.3 -pandas-gbq==0.26.0 -pyarrow==10.0.1 +pandas-gbq==0.26.1 +pyarrow==15.0.2 pydata-google-auth==1.8.2 requests==2.27.1 scikit-learn==1.2.2 -sqlalchemy==1.4 -sqlglot==23.6.3 +shapely==1.8.5 +sqlglot==25.20.0 tabulate==0.9 ipywidgets==7.7.1 humanize==4.6.0 matplotlib==3.7.1 -db-dtypes==1.4.0 +db-dtypes==1.4.2 # For vendored ibis-framework. atpublic==2.3 -parsy==2.0 python-dateutil==2.8.2 pytz==2022.7 toolz==0.11 typing-extensions==4.5.0 rich==12.4.4 +# For anywidget mode +anywidget>=0.9.18 +traitlets==5.0.0 diff --git a/tests/benchmark/.gitignore b/tests/benchmark/.gitignore new file mode 100644 index 0000000000..f1bf042bf7 --- /dev/null +++ b/tests/benchmark/.gitignore @@ -0,0 +1,6 @@ +*.bytesprocessed +*.bq_exec_time_seconds +*.error +*.local_exec_time_seconds +*.query_char_count +*.slotmillis diff --git a/tests/benchmark/db_benchmark/groupby/q1.py b/tests/benchmark/db_benchmark/groupby/q1.py index dc86817908..0051ed5b59 100644 --- a/tests/benchmark/db_benchmark/groupby/q1.py +++ b/tests/benchmark/db_benchmark/groupby/q1.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.groupby_queries as vendored_dbbenchmark_groupby_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_groupby_queries.q1, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/groupby/q10.py b/tests/benchmark/db_benchmark/groupby/q10.py index 99d28e2f9a..08ca9a7fe4 100644 --- a/tests/benchmark/db_benchmark/groupby/q10.py +++ b/tests/benchmark/db_benchmark/groupby/q10.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.groupby_queries as vendored_dbbenchmark_groupby_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_groupby_queries.q10, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/groupby/q2.py b/tests/benchmark/db_benchmark/groupby/q2.py index b06a4189fe..5b3b683931 100644 --- a/tests/benchmark/db_benchmark/groupby/q2.py +++ b/tests/benchmark/db_benchmark/groupby/q2.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.groupby_queries as vendored_dbbenchmark_groupby_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_groupby_queries.q2, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/groupby/q3.py b/tests/benchmark/db_benchmark/groupby/q3.py index d66dd7b39d..97d005fbf4 100644 --- a/tests/benchmark/db_benchmark/groupby/q3.py +++ b/tests/benchmark/db_benchmark/groupby/q3.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.groupby_queries as vendored_dbbenchmark_groupby_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_groupby_queries.q3, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/groupby/q4.py b/tests/benchmark/db_benchmark/groupby/q4.py index 6c72069a53..709b2107d2 100644 --- a/tests/benchmark/db_benchmark/groupby/q4.py +++ b/tests/benchmark/db_benchmark/groupby/q4.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.groupby_queries as vendored_dbbenchmark_groupby_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_groupby_queries.q4, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/groupby/q5.py b/tests/benchmark/db_benchmark/groupby/q5.py index 3e6db9783e..3d870b0598 100644 --- a/tests/benchmark/db_benchmark/groupby/q5.py +++ b/tests/benchmark/db_benchmark/groupby/q5.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.groupby_queries as vendored_dbbenchmark_groupby_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_groupby_queries.q5, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/groupby/q6.py b/tests/benchmark/db_benchmark/groupby/q6.py index f763280b5b..bceb5599b2 100644 --- a/tests/benchmark/db_benchmark/groupby/q6.py +++ b/tests/benchmark/db_benchmark/groupby/q6.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.groupby_queries as vendored_dbbenchmark_groupby_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_groupby_queries.q6, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/groupby/q7.py b/tests/benchmark/db_benchmark/groupby/q7.py index 4e7f2d58b6..600e26bf16 100644 --- a/tests/benchmark/db_benchmark/groupby/q7.py +++ b/tests/benchmark/db_benchmark/groupby/q7.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.groupby_queries as vendored_dbbenchmark_groupby_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_groupby_queries.q7, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/groupby/q8.py b/tests/benchmark/db_benchmark/groupby/q8.py index 75d5dcaa0c..82082bc7e5 100644 --- a/tests/benchmark/db_benchmark/groupby/q8.py +++ b/tests/benchmark/db_benchmark/groupby/q8.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.groupby_queries as vendored_dbbenchmark_groupby_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_groupby_queries.q8, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/join/q1.py b/tests/benchmark/db_benchmark/join/q1.py index 4ca0ee3389..e9e3c2fad0 100644 --- a/tests/benchmark/db_benchmark/join/q1.py +++ b/tests/benchmark/db_benchmark/join/q1.py @@ -18,22 +18,16 @@ import bigframes_vendored.db_benchmark.join_queries as vendored_dbbenchmark_join_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_join_queries.q1, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/join/q2.py b/tests/benchmark/db_benchmark/join/q2.py index 19efd6fbf2..f4b9f67def 100644 --- a/tests/benchmark/db_benchmark/join/q2.py +++ b/tests/benchmark/db_benchmark/join/q2.py @@ -18,22 +18,16 @@ import bigframes_vendored.db_benchmark.join_queries as vendored_dbbenchmark_join_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_join_queries.q2, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/join/q3.py b/tests/benchmark/db_benchmark/join/q3.py index d0a931bfb2..83be831a46 100644 --- a/tests/benchmark/db_benchmark/join/q3.py +++ b/tests/benchmark/db_benchmark/join/q3.py @@ -18,22 +18,16 @@ import bigframes_vendored.db_benchmark.join_queries as vendored_dbbenchmark_join_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_join_queries.q3, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/join/q4.py b/tests/benchmark/db_benchmark/join/q4.py index ebd7c461d0..6399683472 100644 --- a/tests/benchmark/db_benchmark/join/q4.py +++ b/tests/benchmark/db_benchmark/join/q4.py @@ -18,22 +18,16 @@ import bigframes_vendored.db_benchmark.join_queries as vendored_dbbenchmark_join_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_join_queries.q4, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/join/q5.py b/tests/benchmark/db_benchmark/join/q5.py index 7114acd408..b0b26f9365 100644 --- a/tests/benchmark/db_benchmark/join/q5.py +++ b/tests/benchmark/db_benchmark/join/q5.py @@ -18,22 +18,16 @@ import bigframes_vendored.db_benchmark.join_queries as vendored_dbbenchmark_join_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_join_queries.q5, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/db_benchmark/sort/q1.py b/tests/benchmark/db_benchmark/sort/q1.py index 5f6c404443..d73fe28e30 100644 --- a/tests/benchmark/db_benchmark/sort/q1.py +++ b/tests/benchmark/db_benchmark/sort/q1.py @@ -18,21 +18,15 @@ import bigframes_vendored.db_benchmark.sort_queries as vendored_dbbenchmark_sort_queries if __name__ == "__main__": - ( - project_id, - dataset_id, - table_id, - session, - suffix, - ) = utils.get_configuration(include_table_id=True) + config = utils.get_configuration(include_table_id=True) current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( vendored_dbbenchmark_sort_queries.q1, current_path, - suffix, - project_id, - dataset_id, - table_id, - session, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.table_id, + config.session, ) diff --git a/tests/benchmark/read_gbq_colab/aggregate_output.py b/tests/benchmark/read_gbq_colab/aggregate_output.py new file mode 100644 index 0000000000..e5620d8e16 --- /dev/null +++ b/tests/benchmark/read_gbq_colab/aggregate_output.py @@ -0,0 +1,63 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pathlib + +import benchmark.utils as utils + +import bigframes.pandas as bpd + +PAGE_SIZE = utils.READ_GBQ_COLAB_PAGE_SIZE + + +def aggregate_output(*, project_id, dataset_id, table_id): + # TODO(tswast): Support alternative query if table_id is a local DataFrame, + # e.g. "{local_inline}" or "{local_large}" + df = bpd._read_gbq_colab(f"SELECT * FROM `{project_id}`.{dataset_id}.{table_id}") + + # Simulate getting the first page, since we'll always do that first in the UI. + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) + + # To simulate very small rows that can only fit a boolean, + # some tables don't have an integer column. If an integer column is available, + # we prefer to group by that to get a more realistic number of groups. + group_column = "col_int64_1" + if group_column not in df.columns: + group_column = "col_bool_0" + + # Simulate the user aggregating by a column and visualizing those results + df_aggregated = ( + df.assign(rounded=df[group_column].astype("Int64").round(-9)) + .groupby("rounded") + .sum(numeric_only=True) + ) + + batches = df_aggregated._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) + + +if __name__ == "__main__": + config = utils.get_configuration(include_table_id=True, start_session=False) + current_path = pathlib.Path(__file__).absolute() + + utils.get_execution_time( + aggregate_output, + current_path, + config.benchmark_suffix, + project_id=config.project_id, + dataset_id=config.dataset_id, + table_id=config.table_id, + ) diff --git a/tests/benchmark/read_gbq_colab/config.jsonl b/tests/benchmark/read_gbq_colab/config.jsonl new file mode 100644 index 0000000000..6f1ddf4a5f --- /dev/null +++ b/tests/benchmark/read_gbq_colab/config.jsonl @@ -0,0 +1,10 @@ +{"benchmark_suffix": "percentile_09", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_09", "ordered": false} +{"benchmark_suffix": "percentile_19", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_19", "ordered": false} +{"benchmark_suffix": "percentile_29", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_29", "ordered": false} +{"benchmark_suffix": "percentile_39", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_39", "ordered": false} +{"benchmark_suffix": "percentile_49", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_49", "ordered": false} +{"benchmark_suffix": "percentile_59", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_59", "ordered": false} +{"benchmark_suffix": "percentile_69", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_69", "ordered": false} +{"benchmark_suffix": "percentile_79", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_79", "ordered": false} +{"benchmark_suffix": "percentile_89", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_89", "ordered": false} +{"benchmark_suffix": "percentile_99", "project_id": "bigframes-dev-perf", "dataset_id": "read_gbq_colab_benchmark", "table_id": "percentile_99", "ordered": false} diff --git a/tests/benchmark/read_gbq_colab/dry_run.py b/tests/benchmark/read_gbq_colab/dry_run.py new file mode 100644 index 0000000000..6caf08be72 --- /dev/null +++ b/tests/benchmark/read_gbq_colab/dry_run.py @@ -0,0 +1,41 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pathlib + +import benchmark.utils as utils + +import bigframes.pandas + + +def dry_run(*, project_id, dataset_id, table_id): + # TODO(tswast): Support alternative query if table_id is a local DataFrame, + # e.g. "{local_inline}" or "{local_large}" + bigframes.pandas._read_gbq_colab( + f"SELECT * FROM `{project_id}`.{dataset_id}.{table_id}", + dry_run=True, + ) + + +if __name__ == "__main__": + config = utils.get_configuration(include_table_id=True, start_session=False) + current_path = pathlib.Path(__file__).absolute() + + utils.get_execution_time( + dry_run, + current_path, + config.benchmark_suffix, + project_id=config.project_id, + dataset_id=config.dataset_id, + table_id=config.table_id, + ) diff --git a/tests/benchmark/read_gbq_colab/filter_output.py b/tests/benchmark/read_gbq_colab/filter_output.py new file mode 100644 index 0000000000..dc88d31366 --- /dev/null +++ b/tests/benchmark/read_gbq_colab/filter_output.py @@ -0,0 +1,60 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pathlib + +import benchmark.utils as utils + +import bigframes.pandas as bpd + +PAGE_SIZE = utils.READ_GBQ_COLAB_PAGE_SIZE + + +def filter_output( + *, + project_id, + dataset_id, + table_id, +): + # TODO(tswast): Support alternative query if table_id is a local DataFrame, + # e.g. "{local_inline}" or "{local_large}" + df = bpd._read_gbq_colab(f"SELECT * FROM `{project_id}`.{dataset_id}.{table_id}") + + # Simulate getting the first page, since we'll always do that first in the UI. + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) + + # Simulate the user filtering by a column and visualizing those results + df_filtered = df[df["col_bool_0"]] + batches = df_filtered._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + first_page = next(iter(batches)) + + # It's possible we don't have any pages at all, since we filtered out all + # matching rows. + assert len(first_page.index) <= tr + + +if __name__ == "__main__": + config = utils.get_configuration(include_table_id=True) + current_path = pathlib.Path(__file__).absolute() + + utils.get_execution_time( + filter_output, + current_path, + config.benchmark_suffix, + project_id=config.project_id, + dataset_id=config.dataset_id, + table_id=config.table_id, + ) diff --git a/tests/benchmark/read_gbq_colab/first_page.py b/tests/benchmark/read_gbq_colab/first_page.py new file mode 100644 index 0000000000..33e2a24bd7 --- /dev/null +++ b/tests/benchmark/read_gbq_colab/first_page.py @@ -0,0 +1,47 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pathlib + +import benchmark.utils as utils + +import bigframes.pandas + +PAGE_SIZE = utils.READ_GBQ_COLAB_PAGE_SIZE + + +def first_page(*, project_id, dataset_id, table_id): + # TODO(tswast): Support alternative query if table_id is a local DataFrame, + # e.g. "{local_inline}" or "{local_large}" + df = bigframes.pandas._read_gbq_colab( + f"SELECT * FROM `{project_id}`.{dataset_id}.{table_id}" + ) + + # Get number of rows (to calculate number of pages) and the first page. + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) + + +if __name__ == "__main__": + config = utils.get_configuration(include_table_id=True, start_session=False) + current_path = pathlib.Path(__file__).absolute() + + utils.get_execution_time( + first_page, + current_path, + config.benchmark_suffix, + project_id=config.project_id, + dataset_id=config.dataset_id, + table_id=config.table_id, + ) diff --git a/tests/benchmark/read_gbq_colab/last_page.py b/tests/benchmark/read_gbq_colab/last_page.py new file mode 100644 index 0000000000..2e485a070a --- /dev/null +++ b/tests/benchmark/read_gbq_colab/last_page.py @@ -0,0 +1,48 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pathlib + +import benchmark.utils as utils + +import bigframes.pandas + +PAGE_SIZE = utils.READ_GBQ_COLAB_PAGE_SIZE + + +def last_page(*, project_id, dataset_id, table_id): + # TODO(tswast): Support alternative query if table_id is a local DataFrame, + # e.g. "{local_inline}" or "{local_large}" + df = bigframes.pandas._read_gbq_colab( + f"SELECT * FROM `{project_id}`.{dataset_id}.{table_id}" + ) + + # Get number of rows (to calculate number of pages) and then all pages. + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + for _ in batches: + pass + + +if __name__ == "__main__": + config = utils.get_configuration(include_table_id=True, start_session=False) + current_path = pathlib.Path(__file__).absolute() + + utils.get_execution_time( + last_page, + current_path, + config.benchmark_suffix, + project_id=config.project_id, + dataset_id=config.dataset_id, + table_id=config.table_id, + ) diff --git a/tests/benchmark/read_gbq_colab/sort_output.py b/tests/benchmark/read_gbq_colab/sort_output.py new file mode 100644 index 0000000000..3044e0c2a3 --- /dev/null +++ b/tests/benchmark/read_gbq_colab/sort_output.py @@ -0,0 +1,57 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pathlib + +import benchmark.utils as utils + +import bigframes.pandas + +PAGE_SIZE = utils.READ_GBQ_COLAB_PAGE_SIZE + + +def sort_output(*, project_id, dataset_id, table_id): + # TODO(tswast): Support alternative query if table_id is a local DataFrame, + # e.g. "{local_inline}" or "{local_large}" + df = bigframes.pandas._read_gbq_colab( + f"SELECT * FROM `{project_id}`.{dataset_id}.{table_id}" + ) + + # Simulate getting the first page, since we'll always do that first in the UI. + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) + + # Simulate the user sorting by a column and visualizing those results + sort_column = "col_int64_1" + if sort_column not in df.columns: + sort_column = "col_bool_0" + + df_sorted = df.sort_values(sort_column) + batches = df_sorted._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) + + +if __name__ == "__main__": + config = utils.get_configuration(include_table_id=True, start_session=False) + current_path = pathlib.Path(__file__).absolute() + + utils.get_execution_time( + sort_output, + current_path, + config.benchmark_suffix, + project_id=config.project_id, + dataset_id=config.dataset_id, + table_id=config.table_id, + ) diff --git a/tests/benchmark/tpch/q1.py b/tests/benchmark/tpch/q1.py index a672103931..beacaa436b 100644 --- a/tests/benchmark/tpch/q1.py +++ b/tests/benchmark/tpch/q1.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q1 as vendored_tpch_q1 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q1.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q1.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q10.py b/tests/benchmark/tpch/q10.py index d468a90156..27262ff210 100644 --- a/tests/benchmark/tpch/q10.py +++ b/tests/benchmark/tpch/q10.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q10 as vendored_tpch_q10 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q10.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q10.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q11.py b/tests/benchmark/tpch/q11.py index dbf3fd94de..45a0168bb1 100644 --- a/tests/benchmark/tpch/q11.py +++ b/tests/benchmark/tpch/q11.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q11 as vendored_tpch_q11 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q11.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q11.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q12.py b/tests/benchmark/tpch/q12.py index 57774457ae..d055cd1c0b 100644 --- a/tests/benchmark/tpch/q12.py +++ b/tests/benchmark/tpch/q12.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q12 as vendored_tpch_q12 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q12.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q12.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q13.py b/tests/benchmark/tpch/q13.py index a7f2780e4b..f74ef26448 100644 --- a/tests/benchmark/tpch/q13.py +++ b/tests/benchmark/tpch/q13.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q13 as vendored_tpch_q13 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q13.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q13.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q14.py b/tests/benchmark/tpch/q14.py index e9599f3bd8..01ee0add39 100644 --- a/tests/benchmark/tpch/q14.py +++ b/tests/benchmark/tpch/q14.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q14 as vendored_tpch_q14 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q14.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q14.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q15.py b/tests/benchmark/tpch/q15.py index ff200384a8..b19141797a 100644 --- a/tests/benchmark/tpch/q15.py +++ b/tests/benchmark/tpch/q15.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q15 as vendored_tpch_q15 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q15.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q15.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q16.py b/tests/benchmark/tpch/q16.py index 69fc1b9523..5947bb6ed1 100644 --- a/tests/benchmark/tpch/q16.py +++ b/tests/benchmark/tpch/q16.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q16 as vendored_tpch_q16 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q16.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q16.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q17.py b/tests/benchmark/tpch/q17.py index 14707f4a93..e80f7b23f9 100644 --- a/tests/benchmark/tpch/q17.py +++ b/tests/benchmark/tpch/q17.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q17 as vendored_tpch_q17 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q17.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q17.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q18.py b/tests/benchmark/tpch/q18.py index 54cf0d0432..7e9d6c00c4 100644 --- a/tests/benchmark/tpch/q18.py +++ b/tests/benchmark/tpch/q18.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q18 as vendored_tpch_q18 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q18.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q18.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q19.py b/tests/benchmark/tpch/q19.py index 1ec44391ff..f2c1cfc623 100644 --- a/tests/benchmark/tpch/q19.py +++ b/tests/benchmark/tpch/q19.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q19 as vendored_tpch_q19 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q19.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q19.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q2.py b/tests/benchmark/tpch/q2.py index da8064b400..64907d0d25 100644 --- a/tests/benchmark/tpch/q2.py +++ b/tests/benchmark/tpch/q2.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q2 as vendored_tpch_q2 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q2.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q2.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q20.py b/tests/benchmark/tpch/q20.py index 33e4f72ef6..8a405280ef 100644 --- a/tests/benchmark/tpch/q20.py +++ b/tests/benchmark/tpch/q20.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q20 as vendored_tpch_q20 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q20.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q20.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q21.py b/tests/benchmark/tpch/q21.py index f73f87725f..29b364b387 100644 --- a/tests/benchmark/tpch/q21.py +++ b/tests/benchmark/tpch/q21.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q21 as vendored_tpch_q21 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q21.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q21.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q22.py b/tests/benchmark/tpch/q22.py index 0a6f6d923c..9147115097 100644 --- a/tests/benchmark/tpch/q22.py +++ b/tests/benchmark/tpch/q22.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q22 as vendored_tpch_q22 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q22.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q22.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q3.py b/tests/benchmark/tpch/q3.py index 92322eea21..e4eee0630b 100644 --- a/tests/benchmark/tpch/q3.py +++ b/tests/benchmark/tpch/q3.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q3 as vendored_tpch_q3 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q3.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q3.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q4.py b/tests/benchmark/tpch/q4.py index 2d6931d6b1..f0aa3b77a0 100644 --- a/tests/benchmark/tpch/q4.py +++ b/tests/benchmark/tpch/q4.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q4 as vendored_tpch_q4 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q4.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q4.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q5.py b/tests/benchmark/tpch/q5.py index e8fd83e193..5f82638278 100644 --- a/tests/benchmark/tpch/q5.py +++ b/tests/benchmark/tpch/q5.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q5 as vendored_tpch_q5 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q5.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q5.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q6.py b/tests/benchmark/tpch/q6.py index 152d6c663e..bf06f8d31c 100644 --- a/tests/benchmark/tpch/q6.py +++ b/tests/benchmark/tpch/q6.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q6 as vendored_tpch_q6 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q6.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q6.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q7.py b/tests/benchmark/tpch/q7.py index 1c3e455e1c..f9575dd4d6 100644 --- a/tests/benchmark/tpch/q7.py +++ b/tests/benchmark/tpch/q7.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q7 as vendored_tpch_q7 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q7.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q7.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q8.py b/tests/benchmark/tpch/q8.py index 8d23194834..0af13eaeeb 100644 --- a/tests/benchmark/tpch/q8.py +++ b/tests/benchmark/tpch/q8.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q8 as vendored_tpch_q8 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q8.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q8.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/tpch/q9.py b/tests/benchmark/tpch/q9.py index 329e315c2c..61a319377a 100644 --- a/tests/benchmark/tpch/q9.py +++ b/tests/benchmark/tpch/q9.py @@ -17,9 +17,14 @@ import bigframes_vendored.tpch.queries.q9 as vendored_tpch_q9 if __name__ == "__main__": - project_id, dataset_id, session, suffix = utils.get_configuration() + config = utils.get_configuration() current_path = pathlib.Path(__file__).absolute() utils.get_execution_time( - vendored_tpch_q9.q, current_path, suffix, project_id, dataset_id, session + vendored_tpch_q9.q, + current_path, + config.benchmark_suffix, + config.project_id, + config.dataset_id, + config.session, ) diff --git a/tests/benchmark/utils.py b/tests/benchmark/utils.py index 887d54dba2..9690e0a3bd 100644 --- a/tests/benchmark/utils.py +++ b/tests/benchmark/utils.py @@ -13,12 +13,24 @@ # limitations under the License. import argparse +import dataclasses import time import bigframes +READ_GBQ_COLAB_PAGE_SIZE = 100 -def get_configuration(include_table_id=False): + +@dataclasses.dataclass(frozen=True) +class BenchmarkConfig: + project_id: str + dataset_id: str + session: bigframes.Session | None + benchmark_suffix: str | None + table_id: str | None = None + + +def get_configuration(include_table_id=False, start_session=True) -> BenchmarkConfig: parser = argparse.ArgumentParser() parser.add_argument( "--project_id", @@ -53,23 +65,15 @@ def get_configuration(include_table_id=False): ) args = parser.parse_args() - session = _initialize_session(_str_to_bool(args.ordered)) - - if include_table_id: - return ( - args.project_id, - args.dataset_id, - args.table_id, - session, - args.benchmark_suffix, - ) - else: - return ( - args.project_id, - args.dataset_id, - session, - args.benchmark_suffix, - ) + session = _initialize_session(_str_to_bool(args.ordered)) if start_session else None + + return BenchmarkConfig( + project_id=args.project_id, + dataset_id=args.dataset_id, + table_id=args.table_id if include_table_id else None, + session=session, + benchmark_suffix=args.benchmark_suffix, + ) def get_execution_time(func, current_path, suffix, *args, **kwargs): @@ -94,6 +98,7 @@ def _str_to_bool(value): def _initialize_session(ordered: bool): + # TODO(tswast): add a flag to enable the polars semi-executor. context = bigframes.BigQueryOptions( location="US", ordering_mode="strict" if ordered else "partial" ) diff --git a/tests/data/json.jsonl b/tests/data/json.jsonl index fbf0593612..1abdcc9d56 100644 --- a/tests/data/json.jsonl +++ b/tests/data/json.jsonl @@ -6,10 +6,10 @@ {"rowindex": 5, "json_col": []} {"rowindex": 6, "json_col": [1, 2, 3]} {"rowindex": 7, "json_col": [{"a": 1}, {"a": 2}, {"a": null}, {}]} -{"rowindex": 8, "json_col": {"bool_value": true}} +{"rowindex": 8, "json_col": "100"} {"rowindex": 9, "json_col": {"folat_num": 3.14159}} {"rowindex": 10, "json_col": {"date": "2024-07-16"}} -{"rowindex": 11, "json_col": {"null_filed": null}} +{"rowindex": 11, "json_col": 100} {"rowindex": 12, "json_col": {"int_value": 2, "null_filed": null}} {"rowindex": 13, "json_col": {"list_data": [10, 20, 30]}} {"rowindex": 14, "json_col": {"person": {"name": "Alice", "age": 35}}} diff --git a/tests/data/ratings.jsonl b/tests/data/ratings.jsonl new file mode 100644 index 0000000000..b7cd350d08 --- /dev/null +++ b/tests/data/ratings.jsonl @@ -0,0 +1,20 @@ +{"user_id": 1, "item_id": 2, "rating": 4.0} +{"user_id": 1, "item_id": 5, "rating": 3.0} +{"user_id": 2, "item_id": 1, "rating": 5.0} +{"user_id": 2, "item_id": 3, "rating": 2.0} +{"user_id": 3, "item_id": 4, "rating": 4.5} +{"user_id": 3, "item_id": 7, "rating": 3.5} +{"user_id": 4, "item_id": 2, "rating": 1.0} +{"user_id": 4, "item_id": 8, "rating": 5.0} +{"user_id": 5, "item_id": 3, "rating": 4.0} +{"user_id": 5, "item_id": 9, "rating": 2.5} +{"user_id": 6, "item_id": 1, "rating": 3.0} +{"user_id": 6, "item_id": 6, "rating": 4.5} +{"user_id": 7, "item_id": 5, "rating": 5.0} +{"user_id": 7, "item_id": 10, "rating": 1.5} +{"user_id": 8, "item_id": 4, "rating": 2.0} +{"user_id": 8, "item_id": 7, "rating": 4.0} +{"user_id": 9, "item_id": 2, "rating": 3.5} +{"user_id": 9, "item_id": 9, "rating": 5.0} +{"user_id": 10, "item_id": 3, "rating": 4.5} +{"user_id": 10, "item_id": 8, "rating": 2.5} diff --git a/tests/data/ratings_schema.json b/tests/data/ratings_schema.json new file mode 100644 index 0000000000..9fd0101ec8 --- /dev/null +++ b/tests/data/ratings_schema.json @@ -0,0 +1,17 @@ +[ + { + "mode": "NULLABLE", + "name": "user_id", + "type": "STRING" + }, + { + "mode": "NULLABLE", + "name": "item_id", + "type": "INT64" + }, + { + "mode": "NULLABLE", + "name": "rating", + "type": "FLOAT" + } +] diff --git a/tests/data/scalars.jsonl b/tests/data/scalars.jsonl index 03755c94b7..6e591cfa72 100644 --- a/tests/data/scalars.jsonl +++ b/tests/data/scalars.jsonl @@ -1,9 +1,9 @@ -{"bool_col": true, "bytes_col": "SGVsbG8sIFdvcmxkIQ==", "date_col": "2021-07-21", "datetime_col": "2021-07-21 11:39:45", "geography_col": "POINT(-122.0838511 37.3860517)", "int64_col": "123456789", "int64_too": "0", "numeric_col": "1.23456789", "float64_col": "1.25", "rowindex": 0, "rowindex_2": 0, "string_col": "Hello, World!", "time_col": "11:41:43.076160", "timestamp_col": "2021-07-21T17:43:43.945289Z"} -{"bool_col": false, "bytes_col": "44GT44KT44Gr44Gh44Gv", "date_col": "1991-02-03", "datetime_col": "1991-01-02 03:45:06", "geography_col": "POINT(-71.104 42.315)", "int64_col": "-987654321", "int64_too": "1", "numeric_col": "1.23456789", "float64_col": "2.51", "rowindex": 1, "rowindex_2": 1, "string_col": "こんにちは", "time_col": "11:14:34.701606", "timestamp_col": "2021-07-21T17:43:43.945289Z"} -{"bool_col": true, "bytes_col": "wqFIb2xhIE11bmRvIQ==", "date_col": "2023-03-01", "datetime_col": "2023-03-01 10:55:13", "geography_col": "POINT(-0.124474760143016 51.5007826749545)", "int64_col": "314159", "int64_too": "0", "numeric_col": "101.1010101", "float64_col": "2.5e10", "rowindex": 2, "rowindex_2": 2, "string_col": " ¡Hola Mundo! ", "time_col": "23:59:59.999999", "timestamp_col": "2023-03-01T10:55:13.250125Z"} -{"bool_col": null, "bytes_col": null, "date_col": null, "datetime_col": null, "geography_col": null, "int64_col": null, "int64_too": "1", "numeric_col": null, "float64_col": null, "rowindex": 3, "rowindex_2": 3, "string_col": null, "time_col": null, "timestamp_col": null} -{"bool_col": false, "bytes_col": "44GT44KT44Gr44Gh44Gv", "date_col": "2021-07-21", "datetime_col": null, "geography_col": null, "int64_col": "-234892", "int64_too": "-2345", "numeric_col": null, "float64_col": null, "rowindex": 4, "rowindex_2": 4, "string_col": "Hello, World!", "time_col": null, "timestamp_col": null} -{"bool_col": false, "bytes_col": "R8O8dGVuIFRhZw==", "date_col": "1980-03-14", "datetime_col": "1980-03-14 15:16:17", "geography_col": null, "int64_col": "55555", "int64_too": "0", "numeric_col": "5.555555", "float64_col": "555.555", "rowindex": 5, "rowindex_2": 5, "string_col": "Güten Tag!", "time_col": "15:16:17.181921", "timestamp_col": "1980-03-14T15:16:17.181921Z"} -{"bool_col": true, "bytes_col": "SGVsbG8JQmlnRnJhbWVzIQc=", "date_col": "2023-05-23", "datetime_col": "2023-05-23 11:37:01", "geography_col": "MULTIPOINT (20 20, 10 40, 40 30, 30 10)", "int64_col": "101202303", "int64_too": "2", "numeric_col": "-10.090807", "float64_col": "-123.456", "rowindex": 6, "rowindex_2": 6, "string_col": "capitalize, This ", "time_col": "01:02:03.456789", "timestamp_col": "2023-05-23T11:42:55.000001Z"} -{"bool_col": true, "bytes_col": null, "date_col": "2038-01-20", "datetime_col": "2038-01-19 03:14:08", "geography_col": null, "int64_col": "-214748367", "int64_too": "2", "numeric_col": "11111111.1", "float64_col": "42.42", "rowindex": 7, "rowindex_2": 7, "string_col": " سلام", "time_col": "12:00:00.000001", "timestamp_col": "2038-01-19T03:14:17.999999Z"} -{"bool_col": false, "bytes_col": null, "date_col": null, "datetime_col": null, "geography_col": null, "int64_col": "2", "int64_too": "1", "numeric_col": null, "float64_col": "6.87", "rowindex": 8, "rowindex_2": 8, "string_col": "T", "time_col": null, "timestamp_col": null} \ No newline at end of file +{"bool_col": true, "bytes_col": "SGVsbG8sIFdvcmxkIQ==", "date_col": "2021-07-21", "datetime_col": "2021-07-21 11:39:45", "geography_col": "POINT(-122.0838511 37.3860517)", "int64_col": "123456789", "int64_too": "0", "numeric_col": "1.23456789", "float64_col": "1.25", "rowindex": 0, "rowindex_2": 0, "string_col": "Hello, World!", "time_col": "11:41:43.076160", "timestamp_col": "2021-07-21T17:43:43.945289Z", "duration_col": 4} +{"bool_col": false, "bytes_col": "44GT44KT44Gr44Gh44Gv", "date_col": "1991-02-03", "datetime_col": "1991-01-02 03:45:06", "geography_col": "POINT(-71.104 42.315)", "int64_col": "-987654321", "int64_too": "1", "numeric_col": "1.23456789", "float64_col": "2.51", "rowindex": 1, "rowindex_2": 1, "string_col": "こんにちは", "time_col": "11:14:34.701606", "timestamp_col": "2021-07-21T17:43:43.945289Z", "duration_col": -1000000} +{"bool_col": true, "bytes_col": "wqFIb2xhIE11bmRvIQ==", "date_col": "2023-03-01", "datetime_col": "2023-03-01 10:55:13", "geography_col": "POINT(-0.124474760143016 51.5007826749545)", "int64_col": "314159", "int64_too": "0", "numeric_col": "101.1010101", "float64_col": "2.5e10", "rowindex": 2, "rowindex_2": 2, "string_col": " ¡Hola Mundo! ", "time_col": "23:59:59.999999", "timestamp_col": "2023-03-01T10:55:13.250125Z", "duration_col": 0} +{"bool_col": null, "bytes_col": null, "date_col": null, "datetime_col": null, "geography_col": null, "int64_col": null, "int64_too": "1", "numeric_col": null, "float64_col": null, "rowindex": 3, "rowindex_2": 3, "string_col": null, "time_col": null, "timestamp_col": null, "duration_col": null} +{"bool_col": false, "bytes_col": "44GT44KT44Gr44Gh44Gv", "date_col": "2021-07-21", "datetime_col": null, "geography_col": null, "int64_col": "-234892", "int64_too": "-2345", "numeric_col": null, "float64_col": null, "rowindex": 4, "rowindex_2": 4, "string_col": "Hello, World!", "time_col": null, "timestamp_col": null, "duration_col": 31540000000000} +{"bool_col": false, "bytes_col": "R8O8dGVuIFRhZw==", "date_col": "1980-03-14", "datetime_col": "1980-03-14 15:16:17", "geography_col": null, "int64_col": "55555", "int64_too": "0", "numeric_col": "5.555555", "float64_col": "555.555", "rowindex": 5, "rowindex_2": 5, "string_col": "Güten Tag!", "time_col": "15:16:17.181921", "timestamp_col": "1980-03-14T15:16:17.181921Z", "duration_col": 4} +{"bool_col": true, "bytes_col": "SGVsbG8JQmlnRnJhbWVzIQc=", "date_col": "2023-05-23", "datetime_col": "2023-05-23 11:37:01", "geography_col": "LINESTRING(-0.127959 51.507728, -0.127026 51.507473)", "int64_col": "101202303", "int64_too": "2", "numeric_col": "-10.090807", "float64_col": "-123.456", "rowindex": 6, "rowindex_2": 6, "string_col": "capitalize, This ", "time_col": "01:02:03.456789", "timestamp_col": "2023-05-23T11:42:55.000001Z", "duration_col": null} +{"bool_col": true, "bytes_col": null, "date_col": "2038-01-20", "datetime_col": "2038-01-19 03:14:08", "geography_col": null, "int64_col": "-214748367", "int64_too": "2", "numeric_col": "11111111.1", "float64_col": "42.42", "rowindex": 7, "rowindex_2": 7, "string_col": " سلام", "time_col": "12:00:00.000001", "timestamp_col": "2038-01-19T03:14:17.999999Z", "duration_col": 4} +{"bool_col": false, "bytes_col": null, "date_col": null, "datetime_col": null, "geography_col": null, "int64_col": "2", "int64_too": "1", "numeric_col": null, "float64_col": "6.87", "rowindex": 8, "rowindex_2": 8, "string_col": "T", "time_col": null, "timestamp_col": null, "duration_col": 432000000000} diff --git a/tests/data/scalars_schema.json b/tests/data/scalars_schema.json index 1f5d8cdb65..8be4e95228 100644 --- a/tests/data/scalars_schema.json +++ b/tests/data/scalars_schema.json @@ -71,5 +71,11 @@ "mode": "NULLABLE", "name": "timestamp_col", "type": "TIMESTAMP" + }, + { + "mode": "NULLABLE", + "name": "duration_col", + "type": "INTEGER", + "description": "#microseconds" } ] diff --git a/tests/js/babel.config.cjs b/tests/js/babel.config.cjs new file mode 100644 index 0000000000..549f612a2d --- /dev/null +++ b/tests/js/babel.config.cjs @@ -0,0 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = { + presets: [['@babel/preset-env', {targets: {node: 'current'}}]], +}; diff --git a/tests/js/jest.config.cjs b/tests/js/jest.config.cjs new file mode 100644 index 0000000000..ad7dbf97ee --- /dev/null +++ b/tests/js/jest.config.cjs @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** @type {import('jest').Config} */ +const config = { + testEnvironment: 'jsdom', + transform: { + '^.+\.js$': 'babel-jest', + }, + setupFilesAfterEnv: ['./jest.setup.js'], + transformIgnorePatterns: [], +}; + +module.exports = config; diff --git a/tests/js/jest.setup.js b/tests/js/jest.setup.js new file mode 100644 index 0000000000..b6b5934d76 --- /dev/null +++ b/tests/js/jest.setup.js @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TextDecoder, TextEncoder } from "node:util"; + +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder; diff --git a/tests/js/package-lock.json b/tests/js/package-lock.json new file mode 100644 index 0000000000..5526e0581e --- /dev/null +++ b/tests/js/package-lock.json @@ -0,0 +1,6629 @@ +{ + "name": "js-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "js-tests", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@babel/preset-env": "^7.24.7", + "@testing-library/jest-dom": "^6.4.6", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "^24.1.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", + "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", + "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", + "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz", + "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", + "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz", + "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.5.tgz", + "integrity": "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", + "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", + "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz", + "integrity": "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", + "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.5.tgz", + "integrity": "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.5", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.28.3", + "@babel/plugin-transform-classes": "^7.28.4", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.28.5", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.5", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.4", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.4", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@jest/reporters/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", + "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001756", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", + "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", + "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.259", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", + "integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jest-environment-jsdom/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tests/js/package.json b/tests/js/package.json new file mode 100644 index 0000000000..d34c5a065a --- /dev/null +++ b/tests/js/package.json @@ -0,0 +1,20 @@ +{ + "name": "js-tests", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "jest" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@babel/preset-env": "^7.24.7", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "@testing-library/jest-dom": "^6.4.6", + "jsdom": "^24.1.0" + } +} diff --git a/tests/js/table_widget.test.js b/tests/js/table_widget.test.js new file mode 100644 index 0000000000..6b5dda48d1 --- /dev/null +++ b/tests/js/table_widget.test.js @@ -0,0 +1,262 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { jest } from "@jest/globals"; +import { JSDOM } from "jsdom"; + +describe("TableWidget", () => { + let model; + let el; + let render; + + beforeEach(async () => { + jest.resetModules(); + document.body.innerHTML = "
"; + el = document.body.querySelector("div"); + + const tableWidget = ( + await import("../../bigframes/display/table_widget.js") + ).default; + render = tableWidget.render; + + model = { + get: jest.fn(), + set: jest.fn(), + save_changes: jest.fn(), + on: jest.fn(), + }; + }); + + it("should have a render function", () => { + expect(render).toBeDefined(); + }); + + describe("render", () => { + it("should create the basic structure", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return ""; + } + if (property === "row_count") { + return 100; + } + if (property === "error_message") { + return null; + } + if (property === "page_size") { + return 10; + } + if (property === "page") { + return 0; + } + return null; + }); + + render({ model, el }); + + expect(el.classList.contains("bigframes-widget")).toBe(true); + expect(el.querySelector(".error-message")).not.toBeNull(); + expect(el.querySelector("div")).not.toBeNull(); + expect(el.querySelector("div:nth-child(3)")).not.toBeNull(); + }); + + it("should sort when a sortable column is clicked", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
"; + } + if (property === "orderable_columns") { + return ["col1"]; + } + if (property === "sort_column") { + return ""; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const header = el.querySelector("th"); + header.click(); + + expect(model.set).toHaveBeenCalledWith("sort_column", "col1"); + expect(model.set).toHaveBeenCalledWith("sort_ascending", true); + expect(model.save_changes).toHaveBeenCalled(); + }); + + it("should reverse sort direction when a sorted column is clicked", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
"; + } + if (property === "orderable_columns") { + return ["col1"]; + } + if (property === "sort_column") { + return "col1"; + } + if (property === "sort_ascending") { + return true; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const header = el.querySelector("th"); + header.click(); + + expect(model.set).toHaveBeenCalledWith("sort_ascending", false); + expect(model.save_changes).toHaveBeenCalled(); + }); + + it("should clear sort when a descending sorted column is clicked", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
"; + } + if (property === "orderable_columns") { + return ["col1"]; + } + if (property === "sort_column") { + return "col1"; + } + if (property === "sort_ascending") { + return false; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const header = el.querySelector("th"); + header.click(); + + expect(model.set).toHaveBeenCalledWith("sort_column", ""); + expect(model.set).toHaveBeenCalledWith("sort_ascending", true); + expect(model.save_changes).toHaveBeenCalled(); + }); + + it("should display the correct sort indicator", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
col2
"; + } + if (property === "orderable_columns") { + return ["col1", "col2"]; + } + if (property === "sort_column") { + return "col1"; + } + if (property === "sort_ascending") { + return true; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const headers = el.querySelectorAll("th"); + const indicator1 = headers[0].querySelector(".sort-indicator"); + const indicator2 = headers[1].querySelector(".sort-indicator"); + + expect(indicator1.textContent).toBe("▲"); + expect(indicator2.textContent).toBe("●"); + }); + }); + + it("should render the series as a table with an index and one value column", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return ` +
+
+ + + + + + + + + + + + + + + + + +
value
0a
1b
+
+
`; + } + if (property === "orderable_columns") { + return []; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + // Check that the table has two columns + const headers = el.querySelectorAll( + ".paginated-table-container .col-header-name", + ); + expect(headers).toHaveLength(2); + + // Check that the headers are an empty string (for the index) and "value" + expect(headers[0].textContent).toBe(""); + expect(headers[1].textContent).toBe("value"); + }); +}); diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 29234bc4ef..9c4fcf58b1 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -22,7 +22,6 @@ import typing from typing import Dict, Generator, Optional -import bigframes_vendored.ibis.backends as ibis_backends import google.api_core.exceptions import google.cloud.bigquery as bigquery import google.cloud.bigquery_connection_v1 as bigquery_connection_v1 @@ -41,7 +40,7 @@ import bigframes.dataframe import bigframes.pandas as bpd import bigframes.series -import tests.system.utils +import bigframes.testing.utils # Use this to control the number of cloud functions being deleted in a single # test session. This should help soften the spike of the number of mutations per @@ -91,9 +90,12 @@ def gcs_folder(gcs_client: storage.Client): prefix = prefixer.create_prefix() path = f"gs://{bucket}/{prefix}/" yield path - for blob in gcs_client.list_blobs(bucket, prefix=prefix): - blob = typing.cast(storage.Blob, blob) - blob.delete() + try: + for blob in gcs_client.list_blobs(bucket, prefix=prefix): + blob = typing.cast(storage.Blob, blob) + blob.delete() + except Exception as exc: + traceback.print_exception(type(exc), exc, None) @pytest.fixture(scope="session") @@ -106,11 +108,6 @@ def bigquery_client_tokyo(session_tokyo: bigframes.Session) -> bigquery.Client: return session_tokyo.bqclient -@pytest.fixture(scope="session") -def ibis_client(session: bigframes.Session) -> ibis_backends.BaseBackend: - return session.ibis_client - - @pytest.fixture(scope="session") def bigqueryconnection_client( session: bigframes.Session, @@ -139,9 +136,7 @@ def resourcemanager_client( @pytest.fixture(scope="session") def session() -> Generator[bigframes.Session, None, None]: - context = bigframes.BigQueryOptions( - location="US", - ) + context = bigframes.BigQueryOptions(location="US") session = bigframes.Session(context=context) yield session session.close() # close generated session at cleanup time @@ -180,8 +175,28 @@ def session_tokyo(tokyo_location: str) -> Generator[bigframes.Session, None, Non @pytest.fixture(scope="session") -def bq_connection(bigquery_client: bigquery.Client) -> str: - return f"{bigquery_client.project}.{bigquery_client.location}.bigframes-rf-conn" +def test_session() -> Generator[bigframes.Session, None, None]: + context = bigframes.BigQueryOptions( + client_endpoints_override={ + "bqclient": "https://test-bigquery.sandbox.google.com", + "bqconnectionclient": "test-bigqueryconnection.sandbox.googleapis.com", + "bqstoragereadclient": "test-bigquerystorage-grpc.sandbox.googleapis.com", + }, + ) + session = bigframes.Session(context=context) + yield session + session.close() + + +@pytest.fixture(scope="session") +def bq_connection_name() -> str: + return "bigframes-rf-conn" + + +@pytest.fixture(scope="session") +def bq_connection(bigquery_client: bigquery.Client, bq_connection_name: str) -> str: + # TODO(b/458169181): LOCATION casefold is needed for the mutimodal backend bug. Remove after the bug is fixed. + return f"{bigquery_client.project}.{bigquery_client.location.casefold()}.{bq_connection_name}" @pytest.fixture(scope="session", autouse=True) @@ -251,6 +266,11 @@ def table_id_unique(dataset_id: str): return f"{dataset_id}.{prefixer.create_prefix()}" +@pytest.fixture(scope="function") +def routine_id_unique(dataset_id: str): + return f"{dataset_id}.{prefixer.create_prefix()}" + + @pytest.fixture(scope="session") def scalars_schema(bigquery_client: bigquery.Client): # TODO(swast): Add missing scalar data types such as BIGNUMERIC. @@ -305,6 +325,7 @@ def load_test_data_tables( ("repeated", "repeated_schema.json", "repeated.jsonl"), ("json", "json_schema.json", "json.jsonl"), ("penguins", "penguins_schema.json", "penguins.jsonl"), + ("ratings", "ratings_schema.json", "ratings.jsonl"), ("time_series", "time_series_schema.json", "time_series.jsonl"), ("hockey_players", "hockey_players.json", "hockey_players.jsonl"), ("matrix_2by3", "matrix_2by3.json", "matrix_2by3.jsonl"), @@ -401,6 +422,11 @@ def penguins_table_id(test_data_tables) -> str: return test_data_tables["penguins"] +@pytest.fixture(scope="session") +def ratings_table_id(test_data_tables) -> str: + return test_data_tables["ratings"] + + @pytest.fixture(scope="session") def urban_areas_table_id(test_data_tables) -> str: return test_data_tables["urban_areas"] @@ -450,7 +476,7 @@ def nested_structs_df( @pytest.fixture(scope="session") -def nested_structs_pandas_df() -> pd.DataFrame: +def nested_structs_pandas_df(nested_structs_pandas_type: pd.ArrowDtype) -> pd.DataFrame: """pd.DataFrame pointing at test data.""" df = pd.read_json( @@ -458,6 +484,7 @@ def nested_structs_pandas_df() -> pd.DataFrame: lines=True, ) df = df.set_index("id") + df["person"] = df["person"].astype(nested_structs_pandas_type) return df @@ -539,6 +566,16 @@ def scalars_df_index( return session.read_gbq(scalars_table_id, index_col="rowindex") +@pytest.fixture(scope="session") +def scalars_df_partial_ordering( + scalars_table_id: str, unordered_session: bigframes.Session +) -> bigframes.dataframe.DataFrame: + """DataFrame pointing at test data.""" + return unordered_session.read_gbq( + scalars_table_id, index_col="rowindex" + ).sort_index() + + @pytest.fixture(scope="session") def scalars_df_null_index( scalars_table_id: str, session: bigframes.Session @@ -549,6 +586,18 @@ def scalars_df_null_index( ).sort_values("rowindex") +@pytest.fixture(scope="session") +def scalars_df_unordered( + scalars_table_id: str, unordered_session: bigframes.Session +) -> bigframes.dataframe.DataFrame: + """DataFrame pointing at test data.""" + df = unordered_session.read_gbq( + scalars_table_id, index_col=bigframes.enums.DefaultIndexKind.NULL + ) + assert not df._block.explicitly_ordered + return df + + @pytest.fixture(scope="session") def scalars_df_2_default_index( scalars_df_2_index: bigframes.dataframe.DataFrame, @@ -573,7 +622,7 @@ def scalars_pandas_df_default_index() -> pd.DataFrame: DATA_DIR / "scalars.jsonl", lines=True, ) - tests.system.utils.convert_pandas_dtypes(df, bytes_col=True) + bigframes.testing.utils.convert_pandas_dtypes(df, bytes_col=True) df = df.set_index("rowindex", drop=False) df.index.name = None @@ -743,6 +792,14 @@ def penguins_df_null_index( return unordered_session.read_gbq(penguins_table_id) +@pytest.fixture(scope="session") +def ratings_df_default_index( + ratings_table_id: str, session: bigframes.Session +) -> bigframes.dataframe.DataFrame: + """DataFrame pointing at test data.""" + return session.read_gbq(ratings_table_id) + + @pytest.fixture(scope="session") def time_series_df_default_index( time_series_table_id: str, session: bigframes.Session @@ -1363,15 +1420,21 @@ def floats_product_bf(session, floats_product_pd): return session.read_pandas(floats_product_pd) +@pytest.fixture(scope="session", autouse=True) +def use_fast_query_path(): + with bpd.option_context("compute.allow_large_results", False): + yield + + @pytest.fixture(scope="session", autouse=True) def cleanup_cloud_functions(session, cloudfunctions_client, dataset_id_permanent): """Clean up stale cloud functions.""" - permanent_endpoints = tests.system.utils.get_remote_function_endpoints( + permanent_endpoints = bigframes.testing.utils.get_remote_function_endpoints( session.bqclient, dataset_id_permanent ) delete_count = 0 try: - for cloud_function in tests.system.utils.get_cloud_functions( + for cloud_function in bigframes.testing.utils.get_cloud_functions( cloudfunctions_client, session.bqclient.project, session.bqclient.location, @@ -1391,7 +1454,7 @@ def cleanup_cloud_functions(session, cloudfunctions_client, dataset_id_permanent # Go ahead and delete try: - tests.system.utils.delete_cloud_function( + bigframes.testing.utils.delete_cloud_function( cloudfunctions_client, cloud_function.name ) delete_count += 1 @@ -1421,3 +1484,61 @@ def cleanup_cloud_functions(session, cloudfunctions_client, dataset_id_permanent # # Let's stop further clean up and leave it to later. traceback.print_exception(type(exc), exc, None) + + +@pytest.fixture(scope="session") +def images_gcs_path() -> str: + return "gs://bigframes_blob_test/images/*" + + +@pytest.fixture(scope="session") +def images_uris() -> list[str]: + return [ + "gs://bigframes_blob_test/images/img0.jpg", + "gs://bigframes_blob_test/images/img1.jpg", + ] + + +@pytest.fixture(scope="session") +def images_mm_df( + images_uris, session: bigframes.Session, bq_connection: str +) -> bpd.DataFrame: + blob_series = bpd.Series(images_uris, session=session).str.to_blob( + connection=bq_connection + ) + return blob_series.rename("blob_col").to_frame() + + +@pytest.fixture() +def reset_default_session_and_location(): + bpd.close_session() + with bpd.option_context("bigquery.location", None): + yield + bpd.close_session() + bpd.options.bigquery.location = None + + +@pytest.fixture(scope="session") +def pdf_gcs_path() -> str: + return "gs://bigframes_blob_test/pdfs/*" + + +@pytest.fixture(scope="session") +def pdf_mm_df( + pdf_gcs_path, session: bigframes.Session, bq_connection: str +) -> bpd.DataFrame: + return session.from_glob_path(pdf_gcs_path, name="pdf", connection=bq_connection) + + +@pytest.fixture(scope="session") +def audio_gcs_path() -> str: + return "gs://bigframes_blob_test/audio/*" + + +@pytest.fixture(scope="session") +def audio_mm_df( + audio_gcs_path, session: bigframes.Session, bq_connection: str +) -> bpd.DataFrame: + return session.from_glob_path( + audio_gcs_path, name="audio", connection=bq_connection + ) diff --git a/tests/system/large/blob/test_function.py b/tests/system/large/blob/test_function.py new file mode 100644 index 0000000000..7963fabd0b --- /dev/null +++ b/tests/system/large/blob/test_function.py @@ -0,0 +1,851 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +import traceback +from typing import Generator +import uuid + +from google.cloud import storage +import pandas as pd +import pytest + +import bigframes +from bigframes import dtypes +import bigframes.pandas as bpd + + +@pytest.fixture(scope="function") +def images_output_folder() -> Generator[str, None, None]: + id = uuid.uuid4().hex + folder = os.path.join("gs://bigframes_blob_test/output/", id) + yield folder + + # clean up + try: + cloud_storage_client = storage.Client() + bucket = cloud_storage_client.bucket("bigframes_blob_test") + blobs = bucket.list_blobs(prefix="output/" + id) + for blob in blobs: + blob.delete() + except Exception as exc: + traceback.print_exception(type(exc), exc, None) + + +@pytest.fixture(scope="function") +def images_output_uris(images_output_folder: str) -> list[str]: + return [ + os.path.join(images_output_folder, "img0.jpg"), + os.path.join(images_output_folder, "img1.jpg"), + ] + + +def test_blob_exif( + bq_connection: str, + session: bigframes.Session, +): + exif_image_df = session.from_glob_path( + "gs://bigframes_blob_test/images_exif/*", + name="blob_col", + connection=bq_connection, + ) + + actual = exif_image_df["blob_col"].blob.exif( + engine="pillow", connection=bq_connection, verbose=False + ) + expected = bpd.Series( + ['{"ExifOffset": 47, "Make": "MyCamera"}'], + session=session, + dtype=dtypes.JSON_DTYPE, + ) + pd.testing.assert_series_equal( + actual.to_pandas(), + expected.to_pandas(), + check_dtype=False, + check_index_type=False, + ) + + +def test_blob_exif_verbose( + bq_connection: str, + session: bigframes.Session, +): + exif_image_df = session.from_glob_path( + "gs://bigframes_blob_test/images_exif/*", + name="blob_col", + connection=bq_connection, + ) + + actual = exif_image_df["blob_col"].blob.exif( + engine="pillow", connection=bq_connection, verbose=True + ) + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + assert content_series.dtype == dtypes.JSON_DTYPE + + +def test_blob_image_blur_to_series( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_uris: list[str], + session: bigframes.Session, +): + series = bpd.Series(images_output_uris, session=session).str.to_blob( + connection=bq_connection + ) + + actual = images_mm_df["blob_col"].blob.image_blur( + (8, 8), dst=series, connection=bq_connection, engine="opencv", verbose=False + ) + + expected_df = pd.DataFrame( + { + "uri": images_output_uris, + "version": [None, None], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "details": [None, None], + } + ) + pd.testing.assert_frame_equal( + actual.struct.explode().to_pandas(), + expected_df, + check_dtype=False, + check_index_type=False, + ) + + # verify the files exist + assert not actual.blob.size().isna().any() + + +def test_blob_image_blur_to_series_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_uris: list[str], + session: bigframes.Session, +): + series = bpd.Series(images_output_uris, session=session).str.to_blob( + connection=bq_connection + ) + + actual = images_mm_df["blob_col"].blob.image_blur( + (8, 8), dst=series, connection=bq_connection, engine="opencv", verbose=True + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + # Content should be blob objects for GCS destination + # verify the files exist + assert not actual.blob.size().isna().any() + + +def test_blob_image_blur_to_folder( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_folder: str, + images_output_uris: list[str], +): + actual = images_mm_df["blob_col"].blob.image_blur( + (8, 8), + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=False, + ) + expected_df = pd.DataFrame( + { + "uri": images_output_uris, + "version": [None, None], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "details": [None, None], + } + ) + pd.testing.assert_frame_equal( + actual.struct.explode().to_pandas(), + expected_df, + check_dtype=False, + check_index_type=False, + ) + + # verify the files exist + assert not actual.blob.size().isna().any() + + +def test_blob_image_blur_to_folder_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_folder: str, + images_output_uris: list[str], +): + actual = images_mm_df["blob_col"].blob.image_blur( + (8, 8), + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + # verify the files exist + assert not actual.blob.size().isna().any() + + +def test_blob_image_blur_to_bq(images_mm_df: bpd.DataFrame, bq_connection: str): + actual = images_mm_df["blob_col"].blob.image_blur( + (8, 8), connection=bq_connection, engine="opencv", verbose=False + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + assert actual.dtype == dtypes.BYTES_DTYPE + + +def test_blob_image_blur_to_bq_verbose(images_mm_df: bpd.DataFrame, bq_connection: str): + actual = images_mm_df["blob_col"].blob.image_blur( + (8, 8), connection=bq_connection, engine="opencv", verbose=True + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + assert content_series.dtype == dtypes.BYTES_DTYPE + + +def test_blob_image_resize_to_series( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_uris: list[str], + session: bigframes.Session, +): + series = bpd.Series(images_output_uris, session=session).str.to_blob( + connection=bq_connection + ) + + actual = images_mm_df["blob_col"].blob.image_resize( + (200, 300), + dst=series, + connection=bq_connection, + engine="opencv", + verbose=False, + ) + + expected_df = pd.DataFrame( + { + "uri": images_output_uris, + "version": [None, None], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "details": [None, None], + } + ) + pd.testing.assert_frame_equal( + actual.struct.explode().to_pandas(), + expected_df, + check_dtype=False, + check_index_type=False, + ) + + # verify the files exist + assert not actual.blob.size().isna().any() + + +def test_blob_image_resize_to_series_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_uris: list[str], + session: bigframes.Session, +): + series = bpd.Series(images_output_uris, session=session).str.to_blob( + connection=bq_connection + ) + + actual = images_mm_df["blob_col"].blob.image_resize( + (200, 300), + dst=series, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + # verify the files exist + assert not actual.blob.size().isna().any() + + +def test_blob_image_resize_to_folder( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_folder: str, + images_output_uris: list[str], +): + actual = images_mm_df["blob_col"].blob.image_resize( + (200, 300), + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=False, + ) + + expected_df = pd.DataFrame( + { + "uri": images_output_uris, + "version": [None, None], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "details": [None, None], + } + ) + pd.testing.assert_frame_equal( + actual.struct.explode().to_pandas(), + expected_df, + check_dtype=False, + check_index_type=False, + ) + + # verify the files exist + assert not actual.blob.size().isna().any() + + +def test_blob_image_resize_to_folder_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_folder: str, + images_output_uris: list[str], +): + actual = images_mm_df["blob_col"].blob.image_resize( + (200, 300), + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + # verify the files exist + assert not content_series.blob.size().isna().any() + + +def test_blob_image_resize_to_bq(images_mm_df: bpd.DataFrame, bq_connection: str): + actual = images_mm_df["blob_col"].blob.image_resize( + (200, 300), connection=bq_connection, engine="opencv", verbose=False + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + assert actual.dtype == dtypes.BYTES_DTYPE + + +def test_blob_image_resize_to_bq_verbose( + images_mm_df: bpd.DataFrame, bq_connection: str +): + actual = images_mm_df["blob_col"].blob.image_resize( + (200, 300), connection=bq_connection, engine="opencv", verbose=True + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + assert content_series.dtype == dtypes.BYTES_DTYPE + + +def test_blob_image_normalize_to_series( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_uris: list[str], + session: bigframes.Session, +): + series = bpd.Series(images_output_uris, session=session).str.to_blob( + connection=bq_connection + ) + + actual = images_mm_df["blob_col"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + dst=series, + connection=bq_connection, + engine="opencv", + verbose=False, + ) + + expected_df = pd.DataFrame( + { + "uri": images_output_uris, + "version": [None, None], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "details": [None, None], + } + ) + pd.testing.assert_frame_equal( + actual.struct.explode().to_pandas(), + expected_df, + check_dtype=False, + check_index_type=False, + ) + + # verify the files exist + assert not actual.blob.size().isna().any() + + +def test_blob_image_normalize_to_series_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_uris: list[str], + session: bigframes.Session, +): + series = bpd.Series(images_output_uris, session=session).str.to_blob( + connection=bq_connection + ) + + actual = images_mm_df["blob_col"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + dst=series, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + +def test_blob_image_normalize_to_folder( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_folder: str, + images_output_uris: list[str], +): + actual = images_mm_df["blob_col"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=False, + ) + + expected_df = pd.DataFrame( + { + "uri": images_output_uris, + "version": [None, None], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "details": [None, None], + } + ) + pd.testing.assert_frame_equal( + actual.struct.explode().to_pandas(), + expected_df, + check_dtype=False, + check_index_type=False, + ) + + # verify the files exist + assert not actual.blob.size().isna().any() + + +def test_blob_image_normalize_to_folder_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_folder: str, + images_output_uris: list[str], +): + actual = images_mm_df["blob_col"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + +def test_blob_image_normalize_to_bq(images_mm_df: bpd.DataFrame, bq_connection: str): + actual = images_mm_df["blob_col"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + connection=bq_connection, + engine="opencv", + verbose=False, + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + assert actual.dtype == dtypes.BYTES_DTYPE + + +def test_blob_image_normalize_to_bq_verbose( + images_mm_df: bpd.DataFrame, bq_connection: str +): + actual = images_mm_df["blob_col"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + assert content_series.dtype == dtypes.BYTES_DTYPE + + +def test_blob_pdf_extract( + pdf_mm_df: bpd.DataFrame, + bq_connection: str, +): + actual = ( + pdf_mm_df["pdf"] + .blob.pdf_extract(connection=bq_connection, verbose=False, engine="pypdf") + .explode() + .to_pandas() + ) + + # check relative length + expected_text = "Sample PDF This is a testing file. Some dummy messages are used for testing purposes." + expected_len = len(expected_text) + + actual_text = actual[actual != ""].iloc[0] + actual_len = len(actual_text) + + relative_length_tolerance = 0.25 + min_acceptable_len = expected_len * (1 - relative_length_tolerance) + max_acceptable_len = expected_len * (1 + relative_length_tolerance) + assert min_acceptable_len <= actual_len <= max_acceptable_len, ( + f"Item (verbose=False): Extracted text length {actual_len} is outside the acceptable range " + f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " + f"Expected reference length was {expected_len}. " + ) + + # check for major keywords + major_keywords = ["Sample", "PDF", "testing", "dummy", "messages"] + for keyword in major_keywords: + assert ( + keyword.lower() in actual_text.lower() + ), f"Item (verbose=False): Expected keyword '{keyword}' not found in extracted text. " + + +def test_blob_pdf_extract_verbose( + pdf_mm_df: bpd.DataFrame, + bq_connection: str, +): + actual = ( + pdf_mm_df["pdf"] + .blob.pdf_extract(connection=bq_connection, verbose=True, engine="pypdf") + .explode() + .to_pandas() + ) + + # check relative length + expected_text = "Sample PDF This is a testing file. Some dummy messages are used for testing purposes." + expected_len = len(expected_text) + + # The first entry is for a file that doesn't exist, so we check the second one + successful_results = actual[actual.apply(lambda x: x["status"] == "")] + actual_text = successful_results.apply(lambda x: x["content"]).iloc[0] + actual_len = len(actual_text) + + relative_length_tolerance = 0.25 + min_acceptable_len = expected_len * (1 - relative_length_tolerance) + max_acceptable_len = expected_len * (1 + relative_length_tolerance) + assert min_acceptable_len <= actual_len <= max_acceptable_len, ( + f"Item (verbose=True): Extracted text length {actual_len} is outside the acceptable range " + f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " + f"Expected reference length was {expected_len}. " + ) + + # check for major keywords + major_keywords = ["Sample", "PDF", "testing", "dummy", "messages"] + for keyword in major_keywords: + assert ( + keyword.lower() in actual_text.lower() + ), f"Item (verbose=True): Expected keyword '{keyword}' not found in extracted text. " + + +def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, bq_connection: str): + actual = ( + pdf_mm_df["pdf"] + .blob.pdf_chunk( + connection=bq_connection, + chunk_size=50, + overlap_size=10, + verbose=False, + engine="pypdf", + ) + .explode() + .to_pandas() + ) + + # check relative length + expected_text = "Sample PDF This is a testing file. Some dummy messages are used for testing purposes." + expected_len = len(expected_text) + + # First entry is NA + actual_text = "".join(actual.dropna()) + actual_len = len(actual_text) + + relative_length_tolerance = 0.25 + min_acceptable_len = expected_len * (1 - relative_length_tolerance) + max_acceptable_len = expected_len * (1 + relative_length_tolerance) + assert min_acceptable_len <= actual_len <= max_acceptable_len, ( + f"Item (verbose=False): Extracted text length {actual_len} is outside the acceptable range " + f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " + f"Expected reference length was {expected_len}. " + ) + + # check for major keywords + major_keywords = ["Sample", "PDF", "testing", "dummy", "messages"] + for keyword in major_keywords: + assert ( + keyword.lower() in actual_text.lower() + ), f"Item (verbose=False): Expected keyword '{keyword}' not found in extracted text. " + + +def test_blob_pdf_chunk_verbose(pdf_mm_df: bpd.DataFrame, bq_connection: str): + actual = ( + pdf_mm_df["pdf"] + .blob.pdf_chunk( + connection=bq_connection, + chunk_size=50, + overlap_size=10, + verbose=True, + engine="pypdf", + ) + .explode() + .to_pandas() + ) + + # check relative length + expected_text = "Sample PDF This is a testing file. Some dummy messages are used for testing purposes." + expected_len = len(expected_text) + + # The first entry is for a file that doesn't exist, so we check the second one + successful_results = actual[actual.apply(lambda x: x["status"] == "")] + actual_text = "".join(successful_results.apply(lambda x: x["content"]).iloc[0]) + actual_len = len(actual_text) + + relative_length_tolerance = 0.25 + min_acceptable_len = expected_len * (1 - relative_length_tolerance) + max_acceptable_len = expected_len * (1 + relative_length_tolerance) + assert min_acceptable_len <= actual_len <= max_acceptable_len, ( + f"Item (verbose=True): Extracted text length {actual_len} is outside the acceptable range " + f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " + f"Expected reference length was {expected_len}. " + ) + + # check for major keywords + major_keywords = ["Sample", "PDF", "testing", "dummy", "messages"] + for keyword in major_keywords: + assert ( + keyword.lower() in actual_text.lower() + ), f"Item (verbose=True): Expected keyword '{keyword}' not found in extracted text. " + + +@pytest.mark.parametrize( + "model_name", + [ + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ], +) +def test_blob_transcribe( + audio_mm_df: bpd.DataFrame, + model_name: str, +): + actual = ( + audio_mm_df["audio"] + .blob.audio_transcribe( + model_name=model_name, # type: ignore + verbose=False, + ) + .to_pandas() + ) + + # check relative length + expected_text = "Now, as all books not primarily intended as picture-books consist principally of types composed to form letterpress" + expected_len = len(expected_text) + + actual_text = actual[0] + + if pd.isna(actual_text) or actual_text == "": + # Ensure the tests are robust to flakes in the model, which isn't + # particularly useful information for the bigframes team. + logging.warning(f"blob_transcribe() model {model_name} verbose=False failure") + return + + actual_len = len(actual_text) + + relative_length_tolerance = 0.2 + min_acceptable_len = expected_len * (1 - relative_length_tolerance) + max_acceptable_len = expected_len * (1 + relative_length_tolerance) + assert min_acceptable_len <= actual_len <= max_acceptable_len, ( + f"Item (verbose=False): Transcribed text length {actual_len} is outside the acceptable range " + f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " + f"Expected reference length was {expected_len}. " + ) + + # check for major keywords + major_keywords = ["book", "picture"] + for keyword in major_keywords: + assert ( + keyword.lower() in actual_text.lower() + ), f"Item (verbose=False): Expected keyword '{keyword}' not found in transcribed text. " + + +@pytest.mark.parametrize( + "model_name", + [ + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ], +) +def test_blob_transcribe_verbose( + audio_mm_df: bpd.DataFrame, + model_name: str, +): + actual = ( + audio_mm_df["audio"] + .blob.audio_transcribe( + model_name=model_name, # type: ignore + verbose=True, + ) + .to_pandas() + ) + + # check relative length + expected_text = "Now, as all books not primarily intended as picture-books consist principally of types composed to form letterpress" + expected_len = len(expected_text) + + actual_text = actual[0]["content"] + + if pd.isna(actual_text) or actual_text == "": + # Ensure the tests are robust to flakes in the model, which isn't + # particularly useful information for the bigframes team. + logging.warning(f"blob_transcribe() model {model_name} verbose=True failure") + return + + actual_len = len(actual_text) + + relative_length_tolerance = 0.2 + min_acceptable_len = expected_len * (1 - relative_length_tolerance) + max_acceptable_len = expected_len * (1 + relative_length_tolerance) + assert min_acceptable_len <= actual_len <= max_acceptable_len, ( + f"Item (verbose=True): Transcribed text length {actual_len} is outside the acceptable range " + f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " + f"Expected reference length was {expected_len}. " + ) + + # check for major keywords + major_keywords = ["book", "picture"] + for keyword in major_keywords: + assert ( + keyword.lower() in actual_text.lower() + ), f"Item (verbose=True): Expected keyword '{keyword}' not found in transcribed text. " diff --git a/tests/system/large/functions/test_managed_function.py b/tests/system/large/functions/test_managed_function.py new file mode 100644 index 0000000000..732123ec84 --- /dev/null +++ b/tests/system/large/functions/test_managed_function.py @@ -0,0 +1,1333 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import warnings + +import google.api_core.exceptions +import pandas +import pyarrow +import pytest +import test_utils.prefixer + +import bigframes +import bigframes.dataframe +import bigframes.dtypes +import bigframes.exceptions as bfe +import bigframes.pandas as bpd +from bigframes.testing.utils import cleanup_function_assets + +prefixer = test_utils.prefixer.Prefixer("bigframes", "") + + +def test_managed_function_array_output(session, scalars_dfs, dataset_id): + try: + + with warnings.catch_warnings(record=True) as record: + + @session.udf( + dataset=dataset_id, + name=prefixer.create_prefix(), + ) + def featurize(x: int) -> list[float]: + return [float(i) for i in [x, x + 1, x + 2]] + + # No following conflict warning when there is no redundant type hints. + input_type_warning = "Conflicting input types detected" + return_type_warning = "Conflicting return type detected" + assert not any(input_type_warning in str(warning.message) for warning in record) + assert not any( + return_type_warning in str(warning.message) for warning in record + ) + + scalars_df, scalars_pandas_df = scalars_dfs + + bf_int64_col = scalars_df["int64_too"] + bf_result = bf_int64_col.apply(featurize).to_pandas() + + pd_int64_col = scalars_pandas_df["int64_too"] + pd_result = pd_int64_col.apply(featurize) + + # Ignore any dtype disparity. + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + # Make sure the read_gbq_function path works for this function. + featurize_ref = session.read_gbq_function(featurize.bigframes_bigquery_function) + + assert hasattr(featurize_ref, "bigframes_bigquery_function") + assert featurize_ref.bigframes_remote_function is None + assert ( + featurize_ref.bigframes_bigquery_function + == featurize.bigframes_bigquery_function + ) + + # Test on the function from read_gbq_function. + got = featurize_ref(10) + assert got == [10.0, 11.0, 12.0] + + bf_result_gbq = bf_int64_col.apply(featurize_ref).to_pandas() + pandas.testing.assert_series_equal(bf_result_gbq, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(featurize, session.bqclient, ignore_failures=False) + + +def test_managed_function_series_apply(session, dataset_id, scalars_dfs): + try: + + # An explicit name with "def" in it is used to test the robustness of + # the user code extraction logic, which depends on that term. + bq_name = f"{prefixer.create_prefix()}_def_to_test_code_extraction" + assert "def" in bq_name, "The substring 'def' was not found in 'bq_name'" + + @session.udf(dataset=dataset_id, name=bq_name) + def foo(x: int) -> bytes: + return bytes(abs(x)) + + # Function should still work normally. + assert foo(-2) == bytes(2) + + assert hasattr(foo, "bigframes_bigquery_function") + assert hasattr(foo, "input_dtypes") + assert hasattr(foo, "output_dtype") + assert hasattr(foo, "bigframes_bigquery_function_output_dtype") + + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result_col = scalars_df["int64_too"].apply(foo) + bf_result = ( + scalars_df["int64_too"].to_frame().assign(result=bf_result_col).to_pandas() + ) + + pd_result_col = scalars_pandas_df["int64_too"].apply(foo) + pd_result = ( + scalars_pandas_df["int64_too"].to_frame().assign(result=pd_result_col) + ) + + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + # Make sure the read_gbq_function path works for this function. + foo_ref = session.read_gbq_function( + function_name=foo.bigframes_bigquery_function, # type: ignore + ) + assert hasattr(foo_ref, "bigframes_bigquery_function") + assert foo_ref.bigframes_remote_function is None + assert foo.bigframes_bigquery_function == foo_ref.bigframes_bigquery_function # type: ignore + + bf_result_col_gbq = scalars_df["int64_too"].apply(foo_ref) + bf_result_gbq = ( + scalars_df["int64_too"] + .to_frame() + .assign(result=bf_result_col_gbq) + .to_pandas() + ) + + pandas.testing.assert_frame_equal(bf_result_gbq, pd_result, check_dtype=False) + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(foo, session.bqclient, ignore_failures=False) + + +def test_managed_function_series_apply_array_output( + session, + dataset_id, + scalars_dfs, +): + try: + + with pytest.warns(bfe.PreviewWarning, match="udf is in preview."): + + @session.udf(dataset=dataset_id, name=prefixer.create_prefix()) + def foo_list(x: int) -> list[float]: + return [float(abs(x)), float(abs(x) + 1)] + + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result_col = scalars_df["int64_too"].apply(foo_list) + bf_result = ( + scalars_df["int64_too"].to_frame().assign(result=bf_result_col).to_pandas() + ) + + pd_result_col = scalars_pandas_df["int64_too"].apply(foo_list) + pd_result = ( + scalars_pandas_df["int64_too"].to_frame().assign(result=pd_result_col) + ) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(foo_list, session.bqclient, ignore_failures=False) + + +def test_managed_function_series_combine(session, dataset_id, scalars_dfs): + try: + # This function is deliberately written to not work with NA input. + def add(x: int, y: int) -> int: + return x + y + + scalars_df, scalars_pandas_df = scalars_dfs + int_col_name_with_nulls = "int64_col" + int_col_name_no_nulls = "int64_too" + bf_df = scalars_df[[int_col_name_with_nulls, int_col_name_no_nulls]] + pd_df = scalars_pandas_df[[int_col_name_with_nulls, int_col_name_no_nulls]] + + # make sure there are NA values in the test column. + assert any([pandas.isna(val) for val in bf_df[int_col_name_with_nulls]]) + + add_managed_func = session.udf( + dataset=dataset_id, name=prefixer.create_prefix() + )(add) + + # with nulls in the series the managed function application would fail. + with pytest.raises( + google.api_core.exceptions.BadRequest, match="unsupported operand" + ): + bf_df[int_col_name_with_nulls].combine( + bf_df[int_col_name_no_nulls], add_managed_func + ).to_pandas() + + # after filtering out nulls the managed function application should work + # similar to pandas. + pd_filter = pd_df[int_col_name_with_nulls].notnull() + pd_result = pd_df[pd_filter][int_col_name_with_nulls].combine( + pd_df[pd_filter][int_col_name_no_nulls], add + ) + bf_filter = bf_df[int_col_name_with_nulls].notnull() + bf_result = ( + bf_df[bf_filter][int_col_name_with_nulls] + .combine(bf_df[bf_filter][int_col_name_no_nulls], add_managed_func) + .to_pandas() + ) + + # ignore any dtype difference. + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + # Make sure the read_gbq_function path works for this function. + add_managed_func_ref = session.read_gbq_function( + add_managed_func.bigframes_bigquery_function + ) + bf_result = ( + bf_df[bf_filter][int_col_name_with_nulls] + .combine(bf_df[bf_filter][int_col_name_no_nulls], add_managed_func_ref) + .to_pandas() + ) + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets( + add_managed_func, session.bqclient, ignore_failures=False + ) + + +def test_managed_function_series_combine_array_output(session, dataset_id, scalars_dfs): + try: + + # The type hints in this function's signature has conflicts. The + # `input_types` and `output_type` arguments from udf decorator take + # precedence and will be used instead. + def add_list(x, y: bool) -> list[bool]: + return [x, y] + + scalars_df, scalars_pandas_df = scalars_dfs + int_col_name_with_nulls = "int64_col" + int_col_name_no_nulls = "int64_too" + bf_df = scalars_df[[int_col_name_with_nulls, int_col_name_no_nulls]] + pd_df = scalars_pandas_df[[int_col_name_with_nulls, int_col_name_no_nulls]] + + # Make sure there are NA values in the test column. + assert any([pandas.isna(val) for val in bf_df[int_col_name_with_nulls]]) + + with warnings.catch_warnings(record=True) as record: + add_list_managed_func = session.udf( + input_types=[int, int], + output_type=list[int], + dataset=dataset_id, + name=prefixer.create_prefix(), + )(add_list) + + input_type_warning = "Conflicting input types detected" + assert any(input_type_warning in str(warning.message) for warning in record) + return_type_warning = "Conflicting return type detected" + assert any(return_type_warning in str(warning.message) for warning in record) + + # After filtering out nulls the managed function application should work + # similar to pandas. + pd_filter = pd_df[int_col_name_with_nulls].notnull() + pd_result = pd_df[pd_filter][int_col_name_with_nulls].combine( + pd_df[pd_filter][int_col_name_no_nulls], add_list + ) + bf_filter = bf_df[int_col_name_with_nulls].notnull() + bf_result = ( + bf_df[bf_filter][int_col_name_with_nulls] + .combine(bf_df[bf_filter][int_col_name_no_nulls], add_list_managed_func) + .to_pandas() + ) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + # Make sure the read_gbq_function path works for this function. + add_list_managed_func_ref = session.read_gbq_function( + function_name=add_list_managed_func.bigframes_bigquery_function, # type: ignore + ) + + assert hasattr(add_list_managed_func_ref, "bigframes_bigquery_function") + assert add_list_managed_func_ref.bigframes_remote_function is None + assert ( + add_list_managed_func_ref.bigframes_bigquery_function + == add_list_managed_func.bigframes_bigquery_function + ) + + # Test on the function from read_gbq_function. + got = add_list_managed_func_ref(10, 38) + assert got == [10, 38] + + bf_result_gbq = ( + bf_df[bf_filter][int_col_name_with_nulls] + .combine(bf_df[bf_filter][int_col_name_no_nulls], add_list_managed_func_ref) + .to_pandas() + ) + + pandas.testing.assert_series_equal(bf_result_gbq, pd_result, check_dtype=False) + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets( + add_list_managed_func, session.bqclient, ignore_failures=False + ) + + +def test_managed_function_dataframe_map(session, dataset_id, scalars_dfs): + try: + + def add_one(x): + return x + 1 + + mf_add_one = session.udf( + input_types=[int], + output_type=int, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(add_one) + + scalars_df, scalars_pandas_df = scalars_dfs + int64_cols = ["int64_col", "int64_too"] + + bf_int64_df = scalars_df[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + bf_result = bf_int64_df_filtered.map(mf_add_one).to_pandas() + + pd_int64_df = scalars_pandas_df[int64_cols] + pd_int64_df_filtered = pd_int64_df.dropna() + pd_result = pd_int64_df_filtered.map(add_one) + # TODO(shobs): Figure why pandas .map() changes the dtype, i.e. + # pd_int64_df_filtered.dtype is Int64Dtype() + # pd_int64_df_filtered.map(lambda x: x).dtype is int64. + # For this test let's force the pandas dtype to be same as input. + for col in pd_result: + pd_result[col] = pd_result[col].astype(pd_int64_df_filtered[col].dtype) + + pandas.testing.assert_frame_equal(bf_result, pd_result) + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(mf_add_one, session.bqclient, ignore_failures=False) + + +def test_managed_function_dataframe_map_array_output(session, scalars_dfs, dataset_id): + try: + + def add_one_list(x): + return [x + 1] * 3 + + mf_add_one_list = session.udf( + input_types=[int], + output_type=list[int], + dataset=dataset_id, + name=prefixer.create_prefix(), + )(add_one_list) + + scalars_df, scalars_pandas_df = scalars_dfs + int64_cols = ["int64_col", "int64_too"] + + bf_int64_df = scalars_df[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + bf_result = bf_int64_df_filtered.map(mf_add_one_list).to_pandas() + + pd_int64_df = scalars_pandas_df[int64_cols] + pd_int64_df_filtered = pd_int64_df.dropna() + pd_result = pd_int64_df_filtered.map(add_one_list) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + # Make sure the read_gbq_function path works for this function. + mf_add_one_list_ref = session.read_gbq_function( + function_name=mf_add_one_list.bigframes_bigquery_function, # type: ignore + ) + + bf_result_gbq = bf_int64_df_filtered.map(mf_add_one_list_ref).to_pandas() + pandas.testing.assert_frame_equal(bf_result_gbq, pd_result, check_dtype=False) + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets( + mf_add_one_list, session.bqclient, ignore_failures=False + ) + + +def test_managed_function_dataframe_apply_axis_1(session, dataset_id, scalars_dfs): + try: + scalars_df, scalars_pandas_df = scalars_dfs + series = scalars_df["int64_too"] + series_pandas = scalars_pandas_df["int64_too"] + + def add_ints(x, y): + return x + y + + add_ints_mf = session.udf( + input_types=[int, int], + output_type=int, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(add_ints) + assert add_ints_mf.bigframes_bigquery_function # type: ignore + + with pytest.warns( + bigframes.exceptions.PreviewWarning, match="axis=1 scenario is in preview." + ): + bf_result = ( + bpd.DataFrame({"x": series, "y": series}) + .apply(add_ints_mf, axis=1) + .to_pandas() + ) + + pd_result = pandas.DataFrame({"x": series_pandas, "y": series_pandas}).apply( + lambda row: add_ints(row["x"], row["y"]), axis=1 + ) + + pandas.testing.assert_series_equal( + pd_result, bf_result, check_dtype=False, check_exact=True + ) + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(add_ints_mf, session.bqclient, ignore_failures=False) + + +def test_managed_function_dataframe_apply_axis_1_array_output(session, dataset_id): + bf_df = bigframes.dataframe.DataFrame( + { + "Id": [1, 2, 3], + "Age": [22.5, 23, 23.5], + "Name": ["alpha", "beta", "gamma"], + } + ) + + expected_dtypes = ( + bigframes.dtypes.INT_DTYPE, + bigframes.dtypes.FLOAT_DTYPE, + bigframes.dtypes.STRING_DTYPE, + ) + + # Assert the dataframe dtypes. + assert tuple(bf_df.dtypes) == expected_dtypes + + @session.udf( + input_types=[int, float, str], + output_type=list[str], + dataset=dataset_id, + name=prefixer.create_prefix(), + ) + def foo(x, y, z): + return [str(x), str(y), z] + + try: + + assert getattr(foo, "is_row_processor") is False + assert getattr(foo, "input_dtypes") == expected_dtypes + assert getattr(foo, "output_dtype") == pandas.ArrowDtype( + pyarrow.list_( + bigframes.dtypes.bigframes_dtype_to_arrow_dtype( + bigframes.dtypes.STRING_DTYPE + ) + ) + ) + assert getattr(foo, "output_dtype") == getattr( + foo, "bigframes_bigquery_function_output_dtype" + ) + + # Fails to apply on dataframe with incompatible number of columns. + with pytest.raises( + ValueError, + match="^Parameter count mismatch:.* expected 3 parameters but received 2 DataFrame columns.", + ): + bf_df[["Id", "Age"]].apply(foo, axis=1) + + with pytest.raises( + ValueError, + match="^Parameter count mismatch:.* expected 3 parameters but received 4 DataFrame columns.", + ): + bf_df.assign(Country="lalaland").apply(foo, axis=1) + + # Fails to apply on dataframe with incompatible column datatypes. + with pytest.raises( + ValueError, + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", + ): + bf_df.assign(Age=bf_df["Age"].astype("Int64")).apply(foo, axis=1) + + # Successfully applies to dataframe with matching number of columns. + # and their datatypes. + with pytest.warns( + bigframes.exceptions.PreviewWarning, + match="axis=1 scenario is in preview.", + ): + bf_result = bf_df.apply(foo, axis=1).to_pandas() + + # Since this scenario is not pandas-like, let's handcraft the + # expected result. + expected_result = pandas.Series( + [ + ["1", "22.5", "alpha"], + ["2", "23.0", "beta"], + ["3", "23.5", "gamma"], + ] + ) + + pandas.testing.assert_series_equal( + expected_result, bf_result, check_dtype=False, check_index_type=False + ) + + # Make sure the read_gbq_function path works for this function. + foo_ref = session.read_gbq_function(foo.bigframes_bigquery_function) + + assert hasattr(foo_ref, "bigframes_bigquery_function") + assert foo_ref.bigframes_remote_function is None + assert foo_ref.bigframes_bigquery_function == foo.bigframes_bigquery_function + + # Test on the function from read_gbq_function. + got = foo_ref(10, 38, "hello") + assert got == ["10", "38.0", "hello"] + + with pytest.warns( + bigframes.exceptions.PreviewWarning, + match="axis=1 scenario is in preview.", + ): + bf_result_gbq = bf_df.apply(foo_ref, axis=1).to_pandas() + + pandas.testing.assert_series_equal( + bf_result_gbq, expected_result, check_dtype=False, check_index_type=False + ) + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(foo, session.bqclient, ignore_failures=False) + + +@pytest.mark.parametrize( + "connection_fixture", + [ + "bq_connection_name", + "bq_connection", + ], +) +def test_managed_function_with_connection( + session, scalars_dfs, dataset_id, request, connection_fixture +): + try: + bigquery_connection = request.getfixturevalue(connection_fixture) + + @session.udf( + bigquery_connection=bigquery_connection, + dataset=dataset_id, + name=prefixer.create_prefix(), + ) + def foo(x: int) -> int: + return x + 10 + + # Function should still work normally. + assert foo(-2) == 8 + + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result_col = scalars_df["int64_too"].apply(foo) + bf_result = ( + scalars_df["int64_too"].to_frame().assign(result=bf_result_col).to_pandas() + ) + + pd_result_col = scalars_pandas_df["int64_too"].apply(foo) + pd_result = ( + scalars_pandas_df["int64_too"].to_frame().assign(result=pd_result_col) + ) + + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(foo, session.bqclient, ignore_failures=False) + + +def test_managed_function_options(session, dataset_id, scalars_dfs): + try: + + def multiply_five(x: int) -> int: + return x * 5 + + mf_multiply_five = session.udf( + dataset=dataset_id, + name=prefixer.create_prefix(), + max_batching_rows=100, + container_cpu=2, + container_memory="2Gi", + )(multiply_five) + + scalars_df, scalars_pandas_df = scalars_dfs + + bf_int64_df = scalars_df["int64_col"] + bf_int64_df_filtered = bf_int64_df.dropna() + bf_result = bf_int64_df_filtered.apply(mf_multiply_five).to_pandas() + + pd_int64_df = scalars_pandas_df["int64_col"] + pd_int64_df_filtered = pd_int64_df.dropna() + pd_result = pd_int64_df_filtered.apply(multiply_five) + + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + # Make sure the read_gbq_function path works for this function. + multiply_five_ref = session.read_gbq_function( + function_name=mf_multiply_five.bigframes_bigquery_function, # type: ignore + ) + assert mf_multiply_five.bigframes_bigquery_function == multiply_five_ref.bigframes_bigquery_function # type: ignore + + bf_result = bf_int64_df_filtered.apply(multiply_five_ref).to_pandas() + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + # Retrieve the routine and validate its runtime configuration. + routine = session.bqclient.get_routine( + mf_multiply_five.bigframes_bigquery_function + ) + + # TODO(jialuo): Use the newly exposed class properties instead of + # accessing the hidden _properties after resolve of this issue: + # https://github.com/googleapis/python-bigquery/issues/2240. + assert routine._properties["externalRuntimeOptions"]["maxBatchingRows"] == "100" + assert routine._properties["externalRuntimeOptions"]["containerCpu"] == 2 + assert routine._properties["externalRuntimeOptions"]["containerMemory"] == "2Gi" + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets( + mf_multiply_five, session.bqclient, ignore_failures=False + ) + + +def test_managed_function_options_errors(session, dataset_id): + def foo(x: int) -> int: + return 0 + + with pytest.raises( + google.api_core.exceptions.BadRequest, + # For CPU Value >= 1.0, the value must be one of [1, 2, ...]. + match="Invalid container_cpu function OPTIONS value", + ): + session.udf( + dataset=dataset_id, + name=prefixer.create_prefix(), + max_batching_rows=100, + container_cpu=2.5, + container_memory="2Gi", + )(foo) + + with pytest.raises( + google.api_core.exceptions.BadRequest, + # For less than 1.0 CPU, the value must be no less than 0.33. + match="Invalid container_cpu function OPTIONS value", + ): + session.udf( + dataset=dataset_id, + name=prefixer.create_prefix(), + max_batching_rows=100, + container_cpu=0.10, + container_memory="512Mi", + )(foo) + + with pytest.raises( + google.api_core.exceptions.BadRequest, + # For 2.00 CPU, the memory must be in the range of [256Mi, 8Gi]. + match="Invalid container_memory function OPTIONS value", + ): + session.udf( + dataset=dataset_id, + name=prefixer.create_prefix(), + max_batching_rows=100, + container_cpu=2, + container_memory="64Mi", + )(foo) + + +def test_managed_function_df_apply_axis_1(session, dataset_id, scalars_dfs): + columns = ["bool_col", "int64_col", "int64_too", "float64_col", "string_col"] + scalars_df, scalars_pandas_df = scalars_dfs + try: + + def serialize_row(row): + # TODO(b/435021126): Remove explicit type conversion of the field + # "name" after the issue has been addressed. It is added only to + # accept partial pandas parity for the time being. + custom = { + "name": int(row.name), + "index": [idx for idx in row.index], + "values": [ + val.item() if hasattr(val, "item") else val for val in row.values + ], + } + + return str( + { + "default": row.to_json(), + "split": row.to_json(orient="split"), + "records": row.to_json(orient="records"), + "index": row.to_json(orient="index"), + "table": row.to_json(orient="table"), + "custom": custom, + } + ) + + with pytest.raises( + TypeError, + match="Argument type hint must be Pandas Series, not BigFrames Series.", + ): + serialize_row_mf = session.udf( + input_types=bigframes.series.Series, + output_type=str, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(serialize_row) + + serialize_row_mf = session.udf( + input_types=pandas.Series, + output_type=str, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(serialize_row) + + assert getattr(serialize_row_mf, "is_row_processor") + + bf_result = scalars_df[columns].apply(serialize_row_mf, axis=1).to_pandas() + pd_result = scalars_pandas_df[columns].apply(serialize_row, axis=1) + + # bf_result.dtype is 'string[pyarrow]' while pd_result.dtype is 'object' + # , ignore this mismatch by using check_dtype=False. + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + # Let's make sure the read_gbq_function path works for this function. + serialize_row_reuse = session.read_gbq_function( + serialize_row_mf.bigframes_bigquery_function, is_row_processor=True + ) + bf_result = scalars_df[columns].apply(serialize_row_reuse, axis=1).to_pandas() + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + finally: + # clean up the gcp assets created for the managed function. + cleanup_function_assets( + serialize_row_mf, session.bqclient, ignore_failures=False + ) + + +def test_managed_function_df_apply_axis_1_aggregates(session, dataset_id, scalars_dfs): + columns = ["int64_col", "int64_too", "float64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + + try: + + def analyze(row): + # TODO(b/435021126): Remove explicit type conversion of the fields + # after the issue has been addressed. It is added only to accept + # partial pandas parity for the time being. + return str( + { + "dtype": row.dtype, + "count": int(row.count()), + "min": int(row.min()), + "max": int(row.max()), + "mean": float(row.mean()), + "std": float(row.std()), + "var": float(row.var()), + } + ) + + with pytest.warns( + bfe.FunctionPackageVersionWarning, + match=( + "numpy, pandas, and pyarrow versions in the function execution" + "\nenvironment may not precisely match your local environment." + ), + ): + + analyze_mf = session.udf( + input_types=pandas.Series, + output_type=str, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(analyze) + + assert getattr(analyze_mf, "is_row_processor") + + bf_result = scalars_df[columns].dropna().apply(analyze_mf, axis=1).to_pandas() + pd_result = scalars_pandas_df[columns].dropna().apply(analyze, axis=1) + + # bf_result.dtype is 'string[pyarrow]' while pd_result.dtype is 'object' + # , ignore this mismatch by using check_dtype=False. + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + finally: + # clean up the gcp assets created for the managed function. + cleanup_function_assets(analyze_mf, session.bqclient, ignore_failures=False) + + +@pytest.mark.parametrize( + ("pd_df",), + [ + pytest.param( + pandas.DataFrame( + { + "2": [1, 2, 3], + 2: [1.5, 3.75, 5], + "name, [with. special'- chars\")/\\": [10, 20, 30], + (3, 4): ["pq", "rs", "tu"], + (5.0, "six", 7): [8, 9, 10], + 'raise Exception("hacked!")': [11, 12, 13], + }, + # Default pandas index has non-numpy type, whereas bigframes is + # always numpy-based type, so let's use the index compatible + # with bigframes. See more details in b/369689696. + index=pandas.Index([0, 1, 2], dtype=pandas.Int64Dtype()), + ), + id="all-kinds-of-column-names", + ), + pytest.param( + pandas.DataFrame( + { + "x": [1, 2, 3], + "y": [1.5, 3.75, 5], + "z": ["pq", "rs", "tu"], + }, + index=pandas.MultiIndex.from_frame( + pandas.DataFrame( + { + "idx0": pandas.Series( + ["a", "a", "b"], dtype=pandas.StringDtype() + ), + "idx1": pandas.Series( + [100, 200, 300], dtype=pandas.Int64Dtype() + ), + } + ) + ), + ), + id="multiindex", + marks=pytest.mark.skip( + reason="TODO: revert this skip after this pandas bug is fixed: https://github.com/pandas-dev/pandas/issues/59908" + ), + ), + pytest.param( + pandas.DataFrame( + [ + [10, 1.5, "pq"], + [20, 3.75, "rs"], + [30, 8.0, "tu"], + ], + # Default pandas index has non-numpy type, whereas bigframes is + # always numpy-based type, so let's use the index compatible + # with bigframes. See more details in b/369689696. + index=pandas.Index([0, 1, 2], dtype=pandas.Int64Dtype()), + columns=pandas.MultiIndex.from_arrays( + [ + ["first", "last_two", "last_two"], + [1, 2, 3], + ] + ), + ), + id="column-multiindex", + ), + ], +) +def test_managed_function_df_apply_axis_1_complex(session, dataset_id, pd_df): + bf_df = session.read_pandas(pd_df) + + try: + + def serialize_row(row): + # TODO(b/435021126): Remove explicit type conversion of the field + # "name" after the issue has been addressed. It is added only to + # accept partial pandas parity for the time being. + custom = { + "name": int(row.name), + "index": [idx for idx in row.index], + "values": [ + val.item() if hasattr(val, "item") else val for val in row.values + ], + } + return str( + { + "default": row.to_json(), + "split": row.to_json(orient="split"), + "records": row.to_json(orient="records"), + "index": row.to_json(orient="index"), + "custom": custom, + } + ) + + serialize_row_mf = session.udf( + input_types=pandas.Series, + output_type=str, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(serialize_row) + + assert getattr(serialize_row_mf, "is_row_processor") + + bf_result = bf_df.apply(serialize_row_mf, axis=1).to_pandas() + pd_result = pd_df.apply(serialize_row, axis=1) + + # ignore known dtype difference between pandas and bigframes. + pandas.testing.assert_series_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + finally: + # clean up the gcp assets created for the managed function. + cleanup_function_assets( + serialize_row_mf, session.bqclient, ignore_failures=False + ) + + +@pytest.mark.skip(reason="Revert after this bug b/435018880 is fixed.") +def test_managed_function_df_apply_axis_1_na_nan_inf(dataset_id, session): + """This test is for special cases of float values, to make sure any (nan, + inf, -inf) produced by user code is honored. + """ + bf_df = session.read_gbq( + """\ +SELECT "1" AS text, 1 AS num +UNION ALL +SELECT "2.5" AS text, 2.5 AS num +UNION ALL +SELECT "nan" AS text, IEEE_DIVIDE(0, 0) AS num +UNION ALL +SELECT "inf" AS text, IEEE_DIVIDE(1, 0) AS num +UNION ALL +SELECT "-inf" AS text, IEEE_DIVIDE(-1, 0) AS num +UNION ALL +SELECT "numpy nan" AS text, IEEE_DIVIDE(0, 0) AS num +UNION ALL +SELECT "pandas na" AS text, NULL AS num + """ + ) + + pd_df = bf_df.to_pandas() + + try: + + def float_parser(row: pandas.Series): + import numpy as mynp + import pandas as mypd + + if row["text"] == "pandas na": + return mypd.NA + if row["text"] == "numpy nan": + return mynp.nan + return float(row["text"]) + + float_parser_mf = session.udf( + input_types=pandas.Series, + output_type=float, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(float_parser) + + assert getattr(float_parser_mf, "is_row_processor") + + pd_result = pd_df.apply(float_parser, axis=1) + bf_result = bf_df.apply(float_parser_mf, axis=1).to_pandas() + + # bf_result.dtype is 'Float64' while pd_result.dtype is 'object' + # , ignore this mismatch by using check_dtype=False. + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + # Let's also assert that the data is consistent in this round trip + # (BQ -> BigFrames -> BQ -> GCF -> BQ -> BigFrames) w.r.t. their + # expected values in BQ. + bq_result = bf_df["num"].to_pandas() + bq_result.name = None + pandas.testing.assert_series_equal(bq_result, bf_result) + finally: + # clean up the gcp assets created for the managed function. + cleanup_function_assets( + float_parser_mf, session.bqclient, ignore_failures=False + ) + + +def test_managed_function_df_apply_axis_1_args(session, dataset_id, scalars_dfs): + columns = ["int64_col", "int64_too"] + scalars_df, scalars_pandas_df = scalars_dfs + + try: + + def the_sum(s1, s2, x): + return s1 + s2 + x + + the_sum_mf = session.udf( + input_types=[int, int, int], + output_type=int, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(the_sum) + + args1 = (1,) + + # Fails to apply on dataframe with incompatible number of columns and args. + with pytest.raises( + ValueError, + match="^Parameter count mismatch:.* expected 3 parameters but received 4 values \\(3 DataFrame columns and 1 args\\)", + ): + scalars_df[columns + ["float64_col"]].apply(the_sum_mf, axis=1, args=args1) + + # Fails to apply on dataframe with incompatible column datatypes. + with pytest.raises( + ValueError, + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", + ): + scalars_df[columns].assign( + int64_col=lambda df: df["int64_col"].astype("Float64") + ).apply(the_sum_mf, axis=1, args=args1) + + # Fails to apply on dataframe with incompatible args datatypes. + with pytest.raises( + ValueError, + match="^Data type mismatch for 'args' parameter: Expected .* Received .*", + ): + scalars_df[columns].apply(the_sum_mf, axis=1, args=(1.3,)) + + bf_result = ( + scalars_df[columns] + .dropna() + .apply(the_sum_mf, axis=1, args=args1) + .to_pandas() + ) + pd_result = scalars_pandas_df[columns].dropna().apply(sum, axis=1, args=args1) + + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + finally: + # clean up the gcp assets created for the managed function. + cleanup_function_assets(the_sum_mf, session.bqclient, ignore_failures=False) + + +def test_managed_function_df_apply_axis_1_series_args(session, dataset_id, scalars_dfs): + columns = ["int64_col", "float64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + + try: + + def analyze(s: pandas.Series, x: bool, y: float) -> str: + value = f"value is {s['int64_col']} and {s['float64_col']}" + if x: + return f"{value}, x is True!" + if y > 0: + return f"{value}, x is False, y is positive!" + return f"{value}, x is False, y is non-positive!" + + analyze_mf = session.udf( + dataset=dataset_id, + name=prefixer.create_prefix(), + )(analyze) + + args1 = (True, 10.0) + bf_result = ( + scalars_df[columns] + .dropna() + .apply(analyze_mf, axis=1, args=args1) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df[columns].dropna().apply(analyze, axis=1, args=args1) + ) + + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + args2 = (False, -10.0) + analyze_mf_ref = session.read_gbq_function( + analyze_mf.bigframes_bigquery_function, is_row_processor=True + ) + bf_result = ( + scalars_df[columns] + .dropna() + .apply(analyze_mf_ref, axis=1, args=args2) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df[columns].dropna().apply(analyze, axis=1, args=args2) + ) + + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + finally: + # clean up the gcp assets created for the managed function. + cleanup_function_assets(analyze_mf, session.bqclient, ignore_failures=False) + + +def test_managed_function_df_where_mask(session, dataset_id, scalars_dfs): + try: + + # The return type has to be bool type for callable where condition. + def is_sum_positive(a, b): + return a + b > 0 + + is_sum_positive_mf = session.udf( + input_types=[int, int], + output_type=bool, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(is_sum_positive) + + scalars_df, scalars_pandas_df = scalars_dfs + int64_cols = ["int64_col", "int64_too"] + + bf_int64_df = scalars_df[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + pd_int64_df = scalars_pandas_df[int64_cols] + pd_int64_df_filtered = pd_int64_df.dropna() + + # Test callable condition in dataframe.where method. + bf_result = bf_int64_df_filtered.where(is_sum_positive_mf).to_pandas() + # Pandas doesn't support such case, use following as workaround. + pd_result = pd_int64_df_filtered.where(pd_int64_df_filtered.sum(axis=1) > 0) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + # Make sure the read_gbq_function path works for dataframe.where method. + is_sum_positive_ref = session.read_gbq_function( + function_name=is_sum_positive_mf.bigframes_bigquery_function + ) + + bf_result_gbq = bf_int64_df_filtered.where( + is_sum_positive_ref, -bf_int64_df_filtered + ).to_pandas() + pd_result_gbq = pd_int64_df_filtered.where( + pd_int64_df_filtered.sum(axis=1) > 0, -pd_int64_df_filtered + ) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal( + bf_result_gbq, pd_result_gbq, check_dtype=False + ) + + # Test callable condition in dataframe.mask method. + bf_result_gbq = bf_int64_df_filtered.mask( + is_sum_positive_ref, -bf_int64_df_filtered + ).to_pandas() + pd_result_gbq = pd_int64_df_filtered.mask( + pd_int64_df_filtered.sum(axis=1) > 0, -pd_int64_df_filtered + ) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal( + bf_result_gbq, pd_result_gbq, check_dtype=False + ) + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets( + is_sum_positive_mf, session.bqclient, ignore_failures=False + ) + + +def test_managed_function_df_where_mask_series(session, dataset_id, scalars_dfs): + try: + + # The return type has to be bool type for callable where condition. + def is_sum_positive_series(s): + return s["int64_col"] + s["int64_too"] > 0 + + is_sum_positive_series_mf = session.udf( + input_types=pandas.Series, + output_type=bool, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(is_sum_positive_series) + + scalars_df, scalars_pandas_df = scalars_dfs + int64_cols = ["int64_col", "int64_too"] + + bf_int64_df = scalars_df[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + pd_int64_df = scalars_pandas_df[int64_cols] + pd_int64_df_filtered = pd_int64_df.dropna() + + # Test callable condition in dataframe.where method. + bf_result = bf_int64_df_filtered.where(is_sum_positive_series_mf).to_pandas() + pd_result = pd_int64_df_filtered.where(is_sum_positive_series) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + # Make sure the read_gbq_function path works for dataframe.where method. + is_sum_positive_series_ref = session.read_gbq_function( + function_name=is_sum_positive_series_mf.bigframes_bigquery_function, + is_row_processor=True, + ) + + # This is for callable `other` arg in dataframe.where method. + def func_for_other(x): + return -x + + bf_result_gbq = bf_int64_df_filtered.where( + is_sum_positive_series_ref, func_for_other + ).to_pandas() + pd_result_gbq = pd_int64_df_filtered.where( + is_sum_positive_series, func_for_other + ) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal( + bf_result_gbq, pd_result_gbq, check_dtype=False + ) + + # Test callable condition in dataframe.mask method. + bf_result_gbq = bf_int64_df_filtered.mask( + is_sum_positive_series_ref, func_for_other + ).to_pandas() + pd_result_gbq = pd_int64_df_filtered.mask( + is_sum_positive_series, func_for_other + ) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal( + bf_result_gbq, pd_result_gbq, check_dtype=False + ) + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets( + is_sum_positive_series_mf, session.bqclient, ignore_failures=False + ) + + +def test_managed_function_df_where_other_issue(session, dataset_id, scalars_df_index): + try: + + def the_sum(s: pandas.Series) -> int: + return s["int64_col"] + s["int64_too"] + + the_sum_mf = session.udf( + dataset=dataset_id, + name=prefixer.create_prefix(), + )(the_sum) + + int64_cols = ["int64_col", "int64_too"] + + bf_int64_df = scalars_df_index[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + + with pytest.raises( + ValueError, + match="Seires is not a supported replacement type!", + ): + # The execution of the callable other=the_sum_mf will return a + # Series, which is not a supported replacement type. + bf_int64_df_filtered.where(cond=bf_int64_df_filtered, other=the_sum_mf) + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(the_sum_mf, session.bqclient, ignore_failures=False) + + +def test_managed_function_series_where_mask_map(session, dataset_id, scalars_dfs): + try: + + # The return type has to be bool type for callable where condition. + def _is_positive(s): + return s + 1000 > 0 + + is_positive_mf = session.udf( + input_types=int, + output_type=bool, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(_is_positive) + + scalars, scalars_pandas = scalars_dfs + + bf_int64 = scalars["int64_col"] + bf_int64_filtered = bf_int64.dropna() + pd_int64 = scalars_pandas["int64_col"] + pd_int64_filtered = pd_int64.dropna() + + # Test series.where method: the cond is a callable (managed function) + # and the other is not a callable. + bf_result = bf_int64_filtered.where( + cond=is_positive_mf, other=-bf_int64_filtered + ).to_pandas() + pd_result = pd_int64_filtered.where(cond=_is_positive, other=-pd_int64_filtered) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + # Test series.mask method: the cond is a callable (managed function) + # and the other is not a callable. + bf_result = bf_int64_filtered.mask( + cond=is_positive_mf, other=-bf_int64_filtered + ).to_pandas() + pd_result = pd_int64_filtered.mask(cond=_is_positive, other=-pd_int64_filtered) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + # Test series.map method. + bf_result = bf_int64_filtered.map(is_positive_mf).to_pandas() + pd_result = pd_int64_filtered.map(_is_positive) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(is_positive_mf, session.bqclient, ignore_failures=False) + + +def test_managed_function_series_apply_args(session, dataset_id, scalars_dfs): + try: + + with pytest.warns(bfe.PreviewWarning, match="udf is in preview."): + + @session.udf(dataset=dataset_id, name=prefixer.create_prefix()) + def foo_list(x: int, y0: float, y1: bytes, y2: bool) -> list[str]: + return [str(x), str(y0), str(y1), str(y2)] + + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = ( + scalars_df["int64_too"] + .apply(foo_list, args=(12.34, b"hello world", False)) + .to_pandas() + ) + pd_result = scalars_pandas_df["int64_too"].apply( + foo_list, args=(12.34, b"hello world", False) + ) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(foo_list, session.bqclient, ignore_failures=False) diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index 54ba0549a0..2591c0c13a 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -18,14 +18,13 @@ import math # must keep this at top level to test udf referring global import import os.path import shutil -import sys import tempfile import textwrap +import warnings import google.api_core.exceptions from google.cloud import bigquery, functions_v2, storage import pandas -import pyarrow import pytest import test_utils.prefixer @@ -36,8 +35,9 @@ import bigframes.functions._utils as bff_utils import bigframes.pandas as bpd import bigframes.series -from tests.system.utils import ( - assert_pandas_df_equal, +from bigframes.testing.utils import ( + assert_frame_equal, + cleanup_function_assets, delete_cloud_function, get_cloud_functions, ) @@ -48,36 +48,6 @@ _team_euler = "Team Euler" -pytestmark = pytest.mark.skipif( - sys.version_info >= (3, 13), - reason="Runtime 'python313' is not supported yet. Skip for now.", -) - - -def cleanup_remote_function_assets( - bigquery_client, cloudfunctions_client, remote_udf, ignore_failures=True -): - """Clean up the GCP assets behind a bigframes remote function.""" - - # Clean up BQ remote function - try: - bigquery_client.delete_routine(remote_udf.bigframes_remote_function) - except Exception: - # By default don't raise exception in cleanup - if not ignore_failures: - raise - - # Clean up cloud function - try: - delete_cloud_function( - cloudfunctions_client, remote_udf.bigframes_cloud_function - ) - except Exception: - # By default don't raise exception in cleanup - if not ignore_failures: - raise - - def make_uniq_udf(udf): """Transform a udf to another with same behavior but a unique name. Use this to test remote functions with reuse=True, in which case parallel @@ -126,114 +96,6 @@ def bq_cf_connection() -> str: return "bigframes-rf-conn" -@pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_multiply_with_ibis( - session, - scalars_table_id, - bigquery_client, - ibis_client, - dataset_id, - bq_cf_connection, -): - try: - - @session.remote_function( - [int, int], - int, - dataset_id, - bq_cf_connection, - reuse=False, - ) - def multiply(x, y): - return x * y - - _, dataset_name, table_name = scalars_table_id.split(".") - if not ibis_client.dataset: - ibis_client.dataset = dataset_name - - col_name = "int64_col" - table = ibis_client.tables[table_name] - table = table.filter(table[col_name].notnull()).order_by("rowindex").head(10) - sql = table.compile() - pandas_df_orig = bigquery_client.query(sql).to_dataframe() - - col = table[col_name] - col_2x = multiply(col, 2).name("int64_col_2x") - col_square = multiply(col, col).name("int64_col_square") - table = table.mutate([col_2x, col_square]) - sql = table.compile() - pandas_df_new = bigquery_client.query(sql).to_dataframe() - - pandas.testing.assert_series_equal( - pandas_df_orig[col_name] * 2, - pandas_df_new["int64_col_2x"], - check_names=False, - ) - - pandas.testing.assert_series_equal( - pandas_df_orig[col_name] * pandas_df_orig[col_name], - pandas_df_new["int64_col_square"], - check_names=False, - ) - finally: - # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - bigquery_client, session.cloudfunctionsclient, multiply - ) - - -@pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_stringify_with_ibis( - session, - scalars_table_id, - bigquery_client, - ibis_client, - dataset_id, - bq_cf_connection, -): - try: - - @session.remote_function( - [int], - str, - dataset_id, - bq_cf_connection, - reuse=False, - ) - def stringify(x): - return f"I got {x}" - - # Function should work locally. - assert stringify(42) == "I got 42" - - _, dataset_name, table_name = scalars_table_id.split(".") - if not ibis_client.dataset: - ibis_client.dataset = dataset_name - - col_name = "int64_col" - table = ibis_client.tables[table_name] - table = table.filter(table[col_name].notnull()).order_by("rowindex").head(10) - sql = table.compile() - pandas_df_orig = bigquery_client.query(sql).to_dataframe() - - col = table[col_name] - col_2x = stringify.ibis_node(col).name("int64_str_col") - table = table.mutate([col_2x]) - sql = table.compile() - pandas_df_new = bigquery_client.query(sql).to_dataframe() - - pandas.testing.assert_series_equal( - pandas_df_orig[col_name].apply(lambda x: f"I got {x}"), - pandas_df_new["int64_str_col"], - check_names=False, - ) - finally: - # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - bigquery_client, session.cloudfunctionsclient, stringify - ) - - @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_binop(session, scalars_dfs, dataset_id, bq_cf_connection): try: @@ -242,11 +104,14 @@ def func(x, y): return x * abs(y % 4) remote_func = session.remote_function( + # Make sure that the input/output types can be used positionally. + # This avoids the worst of the breaking change from 1.x to 2.x. [str, int], str, dataset_id, - bq_cf_connection, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(func) scalars_df, scalars_pandas_df = scalars_dfs @@ -264,8 +129,8 @@ def func(x, y): pandas.testing.assert_series_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, remote_func + cleanup_function_assets( + remote_func, session.bqclient, session.cloudfunctionsclient ) @@ -279,11 +144,14 @@ def func(x, y): return [len(x), abs(y % 4)] remote_func = session.remote_function( + # Make sure that the input/output types can be used positionally. + # This avoids the worst of the breaking change from 1.x to 2.x. [str, int], list[int], dataset_id, - bq_cf_connection, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(func) scalars_df, scalars_pandas_df = scalars_dfs @@ -301,8 +169,8 @@ def func(x, y): pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, remote_func + cleanup_function_assets( + remote_func, session.bqclient, session.cloudfunctionsclient ) @@ -313,11 +181,14 @@ def test_remote_function_decorator_with_bigframes_series( try: @session.remote_function( + # Make sure that the input/output types can be used positionally. + # This avoids the worst of the breaking change from 1.x to 2.x. [int], int, dataset_id, - bq_cf_connection, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", ) def square(x): return x * x @@ -343,12 +214,10 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square - ) + cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @pytest.mark.flaky(retries=2, delay=120) @@ -361,11 +230,14 @@ def add_one(x): return x + 1 remote_add_one = session.remote_function( + # Make sure that the input/output types can be used positionally. + # This avoids the worst of the breaking change from 1.x to 2.x. [int], int, dataset_id, - bq_cf_connection, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(add_one) scalars_df, scalars_pandas_df = scalars_dfs @@ -389,11 +261,11 @@ def add_one(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, remote_add_one + cleanup_function_assets( + remote_add_one, session.bqclient, session.cloudfunctionsclient ) @@ -411,7 +283,14 @@ def test_remote_function_input_types(session, scalars_dfs, input_types): def add_one(x): return x + 1 - remote_add_one = session.remote_function(input_types, int, reuse=False)(add_one) + remote_add_one = session.remote_function( + # Make sure that the input/output types can be used positionally. + # This avoids the worst of the breaking change from 1.x to 2.x. + input_types, + int, + reuse=False, + cloud_function_service_account="default", + )(add_one) assert remote_add_one.input_dtypes == (bigframes.dtypes.INT_DTYPE,) scalars_df, scalars_pandas_df = scalars_dfs @@ -422,8 +301,8 @@ def add_one(x): pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, remote_add_one + cleanup_function_assets( + remote_add_one, session.bqclient, session.cloudfunctionsclient ) @@ -437,11 +316,14 @@ def test_remote_function_explicit_dataset_not_created( try: @session.remote_function( + # Make sure that the input/output types can be used positionally. + # This avoids the worst of the breaking change from 1.x to 2.x. [int], int, - dataset_id_not_created, - bq_cf_connection, + dataset=dataset_id_not_created, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", ) def square(x): return x * x @@ -467,12 +349,10 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square - ) + cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @pytest.mark.flaky(retries=2, delay=120) @@ -492,11 +372,14 @@ def sign(num): return NO_SIGN remote_sign = session.remote_function( + # Make sure that the input/output types can be used positionally. + # This avoids the worst of the breaking change from 1.x to 2.x. [int], int, dataset_id, - bq_cf_connection, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(sign) scalars_df, scalars_pandas_df = scalars_dfs @@ -520,11 +403,11 @@ def sign(num): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, remote_sign + cleanup_function_assets( + remote_sign, session.bqclient, session.cloudfunctionsclient ) @@ -539,11 +422,14 @@ def circumference(radius): return 2 * mymath.pi * radius remote_circumference = session.remote_function( + # Make sure that the input/output types can be used positionally. + # This avoids the worst of the breaking change from 1.x to 2.x. [float], float, dataset_id, - bq_cf_connection, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(circumference) scalars_df, scalars_pandas_df = scalars_dfs @@ -567,11 +453,11 @@ def circumference(radius): pd_result_col = pd_result_col.astype(pandas.Float64Dtype()) pd_result = pd_float64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, remote_circumference + cleanup_function_assets( + remote_circumference, session.bqclient, session.cloudfunctionsclient ) @@ -588,11 +474,12 @@ def find_team(num): return _team_pi remote_find_team = session.remote_function( - [float], - str, - dataset_id, - bq_cf_connection, + input_types=[float], + output_type=str, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(find_team) scalars_df, scalars_pandas_df = scalars_dfs @@ -616,11 +503,11 @@ def find_team(num): pd_result_col = pd_result_col.astype(pandas.StringDtype(storage="pyarrow")) pd_result = pd_float64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, remote_find_team + cleanup_function_assets( + remote_find_team, session.bqclient, session.cloudfunctionsclient ) @@ -640,8 +527,8 @@ def add_one(x): add_one_uniq, add_one_uniq_dir = make_uniq_udf(add_one) # Expected cloud function name for the unique udf - package_requirements = bff_utils._get_updated_package_requirements() - add_one_uniq_hash = bff_utils._get_hash(add_one_uniq, package_requirements) + package_requirements = bff_utils.get_updated_package_requirements() + add_one_uniq_hash = bff_utils.get_hash(add_one_uniq, package_requirements) add_one_uniq_cf_name = bff_utils.get_cloud_function_name( add_one_uniq_hash, session.session_id ) @@ -660,11 +547,12 @@ def add_one(x): # The first time both the cloud function and the bq remote function don't # exist and would be created remote_add_one = session.remote_function( - [int], - int, - dataset_id, - bq_cf_connection, + input_types=[int], + output_type=int, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, reuse=True, + cloud_function_service_account="default", )(add_one_uniq) # There should have been excactly one cloud function created at this point @@ -703,7 +591,7 @@ def inner_test(): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) # Test that the remote function works as expected inner_test() @@ -730,11 +618,12 @@ def inner_test(): # exist even though the remote function exists, and goes ahead and recreates # the cloud function remote_add_one = session.remote_function( - [int], - int, - dataset_id, - bq_cf_connection, + input_types=[int], + output_type=int, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, reuse=True, + cloud_function_service_account="default", )(add_one_uniq) # There should be excactly one cloud function again @@ -756,8 +645,8 @@ def inner_test(): shutil.rmtree(add_one_uniq_dir) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, remote_add_one + cleanup_function_assets( + remote_add_one, session.bqclient, session.cloudfunctionsclient ) @@ -776,11 +665,12 @@ def is_odd(num): return flag is_odd_remote = session.remote_function( - [int], - bool, - dataset_id, - bq_cf_connection, + input_types=[int], + output_type=bool, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(is_odd) scalars_df, scalars_pandas_df = scalars_dfs @@ -793,11 +683,11 @@ def is_odd(num): pd_result_col = pd_int64_col.mask(is_odd) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, is_odd_remote + cleanup_function_assets( + is_odd_remote, session.bqclient, session.cloudfunctionsclient ) @@ -816,11 +706,12 @@ def is_odd(num): return flag is_odd_remote = session.remote_function( - [int], - bool, - dataset_id, - bq_cf_connection, + input_types=[int], + output_type=bool, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(is_odd) scalars_df, scalars_pandas_df = scalars_dfs @@ -836,11 +727,11 @@ def is_odd(num): pd_result_col = pd_int64_col[pd_int64_col.notnull()].mask(is_odd, -1) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, is_odd_remote + cleanup_function_assets( + is_odd_remote, session.bqclient, session.cloudfunctionsclient ) @@ -850,11 +741,12 @@ def test_remote_udf_lambda(session, scalars_dfs, dataset_id, bq_cf_connection): add_one_lambda = lambda x: x + 1 # noqa: E731 add_one_lambda_remote = session.remote_function( - [int], - int, - dataset_id, - bq_cf_connection, + input_types=[int], + output_type=int, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(add_one_lambda) scalars_df, scalars_pandas_df = scalars_dfs @@ -878,11 +770,11 @@ def test_remote_udf_lambda(session, scalars_dfs, dataset_id, bq_cf_connection): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, add_one_lambda_remote + cleanup_function_assets( + add_one_lambda_remote, session.bqclient, session.cloudfunctionsclient ) @@ -905,16 +797,18 @@ def square(x): # Create the remote function with the name provided explicitly square_remote = session.remote_function( - [int], - int, - dataset_id, - bq_cf_connection, + input_types=[int], + output_type=int, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, reuse=False, name=rf_name, + cloud_function_service_account="default", )(square) # The remote function should reflect the explicitly provided name assert square_remote.bigframes_remote_function == expected_remote_function + assert square_remote.bigframes_bigquery_function == expected_remote_function # Now the expected BQ remote function should exist session.bqclient.get_routine(expected_remote_function) @@ -935,11 +829,11 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square_remote + cleanup_function_assets( + square_remote, session.bqclient, session.cloudfunctionsclient ) @@ -949,21 +843,31 @@ def test_remote_function_with_external_package_dependencies( ): try: - def pd_np_foo(x): + # The return type hint in this function's signature has conflict. The + # `output_type` argument from remote_function decorator takes precedence + # and will be used instead. + def pd_np_foo(x) -> None: import numpy as mynp import pandas as mypd return mypd.Series([x, mynp.sqrt(mynp.abs(x))]).sum() - # Create the remote function with the name provided explicitly - pd_np_foo_remote = session.remote_function( - [int], - float, - dataset_id, - bq_cf_connection, - reuse=False, - packages=["numpy", "pandas >= 2.0.0"], - )(pd_np_foo) + with warnings.catch_warnings(record=True) as record: + # Create the remote function with the name provided explicitly + pd_np_foo_remote = session.remote_function( + input_types=[int], + output_type=float, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, + reuse=False, + packages=["numpy", "pandas >= 2.0.0"], + cloud_function_service_account="default", + )(pd_np_foo) + + input_type_warning = "Conflicting input types detected" + assert not any(input_type_warning in str(warning.message) for warning in record) + return_type_warning = "Conflicting return type detected" + assert any(return_type_warning in str(warning.message) for warning in record) # The behavior of the created remote function should be as expected scalars_df, scalars_pandas_df = scalars_dfs @@ -980,11 +884,11 @@ def pd_np_foo(x): # comparing for the purpose of this test pd_result.result = pd_result.result.astype(pandas.Float64Dtype()) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, pd_np_foo_remote + cleanup_function_assets( + pd_np_foo_remote, session.bqclient, session.cloudfunctionsclient ) @@ -1024,7 +928,7 @@ def test_internal(rf, udf): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) # Create an explicit name for the remote function prefixer = test_utils.prefixer.Prefixer("foo", "") @@ -1037,15 +941,17 @@ def test_internal(rf, udf): # Create a new remote function with the name provided explicitly square_remote1 = session.remote_function( - [int], - int, - dataset_id, - bq_cf_connection, + input_types=[int], + output_type=int, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, name=rf_name, + cloud_function_service_account="default", )(square_uniq) # The remote function should reflect the explicitly provided name assert square_remote1.bigframes_remote_function == expected_remote_function + assert square_remote1.bigframes_bigquery_function == expected_remote_function # Now the expected BQ remote function should exist routine = session.bqclient.get_routine(expected_remote_function) @@ -1061,15 +967,17 @@ def test_internal(rf, udf): # explicitly. Since reuse is True by default, the previously created # remote function with the same name will be reused. square_remote2 = session.remote_function( - [int], - int, - dataset_id, - bq_cf_connection, + input_types=[int], + output_type=int, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, name=rf_name, + cloud_function_service_account="default", )(square_uniq) # The new remote function should still reflect the explicitly provided name assert square_remote2.bigframes_remote_function == expected_remote_function + assert square_remote2.bigframes_bigquery_function == expected_remote_function # The expected BQ remote function should still exist routine = session.bqclient.get_routine(expected_remote_function) @@ -1104,15 +1012,17 @@ def plusone(x): # created remote function with the same name should not be reused since # this time it is a different user code. plusone_remote = session.remote_function( - [int], - int, - dataset_id, - bq_cf_connection, + input_types=[int], + output_type=int, + dataset=dataset_id, + bigquery_connection=bq_cf_connection, name=rf_name, + cloud_function_service_account="default", )(plusone_uniq) # The new remote function should still reflect the explicitly provided name assert plusone_remote.bigframes_remote_function == expected_remote_function + assert plusone_remote.bigframes_bigquery_function == expected_remote_function # The expected BQ remote function should still exist routine = session.bqclient.get_routine(expected_remote_function) @@ -1136,14 +1046,14 @@ def plusone(x): test_internal(plusone_remote, plusone) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square_remote1 + cleanup_function_assets( + square_remote1, session.bqclient, session.cloudfunctionsclient ) - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square_remote2 + cleanup_function_assets( + square_remote2, session.bqclient, session.cloudfunctionsclient ) - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, plusone_remote + cleanup_function_assets( + plusone_remote, session.bqclient, session.cloudfunctionsclient ) for dir_ in dirs_to_cleanup: shutil.rmtree(dir_) @@ -1168,7 +1078,13 @@ def test_remote_function_via_session_context_connection_setter( # unique dataset_id, even though the cloud function would be reused, the bq # remote function would still be created, making use of the bq connection # set in the BigQueryOptions above. - @session.remote_function([int], int, dataset=dataset_id, reuse=False) + @session.remote_function( + input_types=[int], + output_type=int, + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + ) def square(x): return x * x @@ -1193,19 +1109,23 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square - ) + cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_default_connection(session, scalars_dfs, dataset_id): try: - @session.remote_function([int], int, dataset=dataset_id, reuse=False) + @session.remote_function( + input_types=[int], + output_type=int, + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + ) def square(x): return x * x @@ -1230,19 +1150,23 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square - ) + cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_runtime_error(session, scalars_dfs, dataset_id): try: - @session.remote_function([int], int, dataset=dataset_id, reuse=False) + @session.remote_function( + input_types=[int], + output_type=int, + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + ) def square(x): return x * x @@ -1256,9 +1180,7 @@ def square(x): scalars_df["int64_col"].apply(square).to_pandas() finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square - ) + cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @pytest.mark.flaky(retries=2, delay=120) @@ -1268,12 +1190,17 @@ def test_remote_function_anonymous_dataset(session, scalars_dfs): # function in the bigframes session's anonymous dataset. Use reuse=False # param to make sure parallel instances of the test don't step over each # other due to the common anonymous dataset. - @session.remote_function([int], int, reuse=False) + @session.remote_function( + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + ) def square(x): return x * x assert ( - bigquery.Routine(square.bigframes_remote_function).dataset_id + bigquery.Routine(square.bigframes_bigquery_function).dataset_id == session._anonymous_dataset.dataset_id ) @@ -1298,12 +1225,10 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square - ) + cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @pytest.mark.flaky(retries=2, delay=120) @@ -1313,7 +1238,7 @@ def test_remote_function_via_session_custom_sa(scalars_dfs): # For upfront convenience, the following set up has been statically created # in the project bigfrmames-dev-perf via cloud console: # - # 1. Create a service account as per + # 1. Create a service account bigframes-dev-perf-1@bigframes-dev-perf.iam.gserviceaccount.com as per # https://cloud.google.com/iam/docs/service-accounts-create#iam-service-accounts-create-console # 2. Give necessary roles as per # https://cloud.google.com/functions/docs/reference/iam/roles#additional-configuration @@ -1327,14 +1252,27 @@ def test_remote_function_via_session_custom_sa(scalars_dfs): try: + # TODO(shobs): Figure out why the default ingress setting + # (internal-only) does not work here @rf_session.remote_function( - [int], int, reuse=False, cloud_function_service_account=gcf_service_account + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account=gcf_service_account, + cloud_function_ingress_settings="all", ) def square_num(x): if x is None: return x return x * x + # assert that the GCF is created with the intended SA + gcf = rf_session.cloudfunctionsclient.get_function( + name=square_num.bigframes_cloud_function + ) + assert gcf.service_config.service_account_email == gcf_service_account + + # assert that the function works as expected on data scalars_df, scalars_pandas_df = scalars_dfs bf_int64_col = scalars_df["int64_col"] @@ -1345,20 +1283,96 @@ def square_num(x): pd_result_col = pd_int64_col.apply(lambda x: x if x is None else x * x) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) + finally: + # clean up the gcp assets created for the remote function + cleanup_function_assets( + square_num, rf_session.bqclient, rf_session.cloudfunctionsclient + ) - # Assert that the GCF is created with the intended SA + +@pytest.mark.parametrize( + ("set_build_service_account"), + [ + pytest.param( + "projects/bigframes-dev-perf/serviceAccounts/bigframes-dev-perf-1@bigframes-dev-perf.iam.gserviceaccount.com", + id="fully-qualified-sa", + ), + pytest.param( + "bigframes-dev-perf-1@bigframes-dev-perf.iam.gserviceaccount.com", + id="just-sa-email", + ), + ], +) +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_via_session_custom_build_sa( + scalars_dfs, set_build_service_account +): + # TODO(shobs): Automate the following set-up during testing in the test project. + # + # For upfront convenience, the following set up has been statically created + # in the project bigfrmames-dev-perf via cloud console: + # + # 1. Create a service account bigframes-dev-perf-1@bigframes-dev-perf.iam.gserviceaccount.com as per + # https://cloud.google.com/iam/docs/service-accounts-create#iam-service-accounts-create-console + # 2. Give "Cloud Build Service Account (roles/cloudbuild.builds.builder)" role as per + # https://cloud.google.com/build/docs/cloud-build-service-account#default_permissions_of_the_legacy_service_account + # + project = "bigframes-dev-perf" + expected_build_service_account = "projects/bigframes-dev-perf/serviceAccounts/bigframes-dev-perf-1@bigframes-dev-perf.iam.gserviceaccount.com" + + rf_session = bigframes.Session(context=bigframes.BigQueryOptions(project=project)) + + try: + + # TODO(shobs): Figure out why the default ingress setting + # (internal-only) does not work here + @rf_session.remote_function( + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + cloud_build_service_account=set_build_service_account, + cloud_function_ingress_settings="all", + ) + def square_num(x): + if x is None: + return x + return x * x + + # assert that the GCF is created with the intended SA gcf = rf_session.cloudfunctionsclient.get_function( name=square_num.bigframes_cloud_function ) - assert gcf.service_config.service_account_email == gcf_service_account + assert gcf.build_config.service_account == expected_build_service_account + + # assert that the function works as expected on data + scalars_df, scalars_pandas_df = scalars_dfs + + bf_int64_col = scalars_df["int64_col"] + bf_result_col = bf_int64_col.apply(square_num) + bf_result = bf_int64_col.to_frame().assign(result=bf_result_col).to_pandas() + + pd_int64_col = scalars_pandas_df["int64_col"] + pd_result_col = pd_int64_col.apply(lambda x: x if x is None else x * x) + pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) + + assert_frame_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - rf_session.bqclient, rf_session.cloudfunctionsclient, square_num + cleanup_function_assets( + square_num, rf_session.bqclient, rf_session.cloudfunctionsclient ) +def test_remote_function_throws_none_cloud_function_service_account(session): + with pytest.raises( + ValueError, + match='^You must provide a user managed cloud_function_service_account, or "default" if you would like to let the default service account be used.$', + ): + session.remote_function(cloud_function_service_account=None) + + @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_with_gcf_cmek(): # TODO(shobs): Automate the following set-up during testing in the test project. @@ -1381,9 +1395,10 @@ def test_remote_function_with_gcf_cmek(): try: @session.remote_function( - [int], - int, + input_types=[int], + output_type=int, reuse=False, + cloud_function_service_account="default", cloud_function_kms_key_name=cmek, cloud_function_docker_repository=docker_repository, ) @@ -1401,7 +1416,7 @@ def square_num(x): pd_result_col = df["num"].apply(lambda x: x if x is None else x * x) pd_result = df.assign(result=pd_result_col) - assert_pandas_df_equal( + assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -1419,8 +1434,8 @@ def square_num(x): finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square_num + cleanup_function_assets( + square_num, session.bqclient, session.cloudfunctionsclient ) @@ -1455,10 +1470,30 @@ def square_num(x): return x return x * x + # TODO(shobs): See if the test vpc can be configured to make this flow + # work with the default ingress setting (internal-only) square_num_remote = rf_session.remote_function( - [int], int, reuse=False, cloud_function_vpc_connector=gcf_vpc_connector + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + cloud_function_vpc_connector=gcf_vpc_connector, + cloud_function_vpc_connector_egress_settings="all", + cloud_function_ingress_settings="all", )(square_num) + gcf = rf_session.cloudfunctionsclient.get_function( + name=square_num_remote.bigframes_cloud_function + ) + + # assert that the GCF is created with the intended vpc connector and + # egress settings. + assert gcf.service_config.vpc_connector == gcf_vpc_connector + # The value is since we set + # cloud_function_vpc_connector_egress_settings="all" earlier. + assert gcf.service_config.vpc_connector_egress_settings == 2 + + # assert that the function works as expected on data scalars_df, scalars_pandas_df = scalars_dfs bf_int64_col = scalars_df["int64_col"] @@ -1469,20 +1504,54 @@ def square_num(x): pd_result_col = pd_int64_col.apply(square_num) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) - - # Assert that the GCF is created with the intended vpc connector - gcf = rf_session.cloudfunctionsclient.get_function( - name=square_num_remote.bigframes_cloud_function - ) - assert gcf.service_config.vpc_connector == gcf_vpc_connector + assert_frame_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - rf_session.bqclient, rf_session.cloudfunctionsclient, square_num_remote + cleanup_function_assets( + square_num_remote, rf_session.bqclient, rf_session.cloudfunctionsclient ) +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_no_vpc_connector(session): + def foo(x): + return x + + with pytest.raises( + ValueError, + match="^cloud_function_vpc_connector must be specified before cloud_function_vpc_connector_egress_settings", + ): + session.remote_function( + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + cloud_function_vpc_connector=None, + cloud_function_vpc_connector_egress_settings="all", + cloud_function_ingress_settings="all", + )(foo) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_wrong_vpc_egress_value(session): + def foo(x): + return x + + with pytest.raises( + ValueError, + match="^'wrong-egress-value' is not one of the supported vpc egress settings values:", + ): + session.remote_function( + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + cloud_function_vpc_connector="dummy-value", + cloud_function_vpc_connector_egress_settings="wrong-egress-value", + cloud_function_ingress_settings="all", + )(foo) + + @pytest.mark.parametrize( ("max_batching_rows"), [ @@ -1498,11 +1567,15 @@ def square(x): return x * x square_remote = session.remote_function( - [int], int, reuse=False, max_batching_rows=max_batching_rows + input_types=[int], + output_type=int, + reuse=False, + max_batching_rows=max_batching_rows, + cloud_function_service_account="default", )(square) bq_routine = session.bqclient.get_routine( - square_remote.bigframes_remote_function + square_remote.bigframes_bigquery_function ) assert bq_routine.remote_function_options.max_batching_rows == max_batching_rows @@ -1514,8 +1587,8 @@ def square(x): pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square_remote + cleanup_function_assets( + square_remote, session.bqclient, session.cloudfunctionsclient ) @@ -1537,7 +1610,11 @@ def square(x): return x * x square_remote = session.remote_function( - [int], int, reuse=False, **timeout_args + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + **timeout_args, )(square) # Assert that the GCF is created with the intended maximum timeout @@ -1554,8 +1631,8 @@ def square(x): pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square_remote + cleanup_function_assets( + square_remote, session.bqclient, session.cloudfunctionsclient ) @@ -1563,16 +1640,24 @@ def square(x): def test_remote_function_gcf_timeout_max_supported_exceeded(session): with pytest.raises(ValueError): - @session.remote_function([int], int, reuse=False, cloud_function_timeout=1201) + @session.remote_function( + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + cloud_function_timeout=1201, + ) def square(x): return x * x +# Note: Zero represents default, which is 100 instances actually, which is why the remote function still works +# in the df.apply() call here @pytest.mark.parametrize( ("max_instances_args", "expected_max_instances"), [ - pytest.param({}, 100, id="no-set"), - pytest.param({"cloud_function_max_instances": None}, 100, id="set-None"), + pytest.param({}, 0, id="no-set"), + pytest.param({"cloud_function_max_instances": None}, 0, id="set-None"), pytest.param({"cloud_function_max_instances": 1000}, 1000, id="set-explicit"), ], ) @@ -1586,7 +1671,11 @@ def square(x): return x * x square_remote = session.remote_function( - [int], int, reuse=False, **max_instances_args + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + **max_instances_args, )(square) # Assert that the GCF is created with the intended max instance count @@ -1603,8 +1692,8 @@ def square(x): pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square_remote + cleanup_function_assets( + square_remote, session.bqclient, session.cloudfunctionsclient ) @@ -1635,7 +1724,10 @@ def serialize_row(row): ) serialize_row_remote = session.remote_function( - bigframes.series.Series, str, reuse=False + input_types=pandas.Series, + output_type=str, + reuse=False, + cloud_function_service_account="default", )(serialize_row) assert getattr(serialize_row_remote, "is_row_processor") @@ -1649,14 +1741,14 @@ def serialize_row(row): # Let's make sure the read_gbq_function path works for this function serialize_row_reuse = session.read_gbq_function( - serialize_row_remote.bigframes_remote_function, is_row_processor=True + serialize_row_remote.bigframes_bigquery_function, is_row_processor=True ) bf_result = scalars_df[columns].apply(serialize_row_reuse, axis=1).to_pandas() pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, serialize_row_remote + cleanup_function_assets( + serialize_row_remote, session.bqclient, session.cloudfunctionsclient ) @@ -1672,7 +1764,7 @@ def analyze(row): { "dtype": row.dtype, "count": row.count(), - "min": row.max(), + "min": row.min(), "max": row.max(), "mean": row.mean(), "std": row.std(), @@ -1681,7 +1773,10 @@ def analyze(row): ) analyze_remote = session.remote_function( - bigframes.series.Series, str, reuse=False + input_types=pandas.Series, + output_type=str, + reuse=False, + cloud_function_service_account="default", )(analyze) assert getattr(analyze_remote, "is_row_processor") @@ -1696,8 +1791,8 @@ def analyze(row): pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, analyze_remote + cleanup_function_assets( + analyze_remote, session.bqclient, session.cloudfunctionsclient ) @@ -1802,7 +1897,10 @@ def serialize_row(row): ) serialize_row_remote = session.remote_function( - bigframes.series.Series, str, reuse=False + input_types=pandas.Series, + output_type=str, + reuse=False, + cloud_function_service_account="default", )(serialize_row) assert getattr(serialize_row_remote, "is_row_processor") @@ -1816,8 +1914,8 @@ def serialize_row(row): ) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, serialize_row_remote + cleanup_function_assets( + serialize_row_remote, session.bqclient, session.cloudfunctionsclient ) @@ -1848,7 +1946,7 @@ def test_df_apply_axis_1_na_nan_inf(session): try: - def float_parser(row): + def float_parser(row: pandas.Series): import numpy as mynp import pandas as mypd @@ -1859,7 +1957,9 @@ def float_parser(row): return float(row["text"]) float_parser_remote = session.remote_function( - bigframes.series.Series, float, reuse=False + output_type=float, + reuse=False, + cloud_function_service_account="default", )(float_parser) assert getattr(float_parser_remote, "is_row_processor") @@ -1879,9 +1979,117 @@ def float_parser(row): pandas.testing.assert_series_equal(bq_result, bf_result) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, float_parser_remote + cleanup_function_assets( + float_parser_remote, session.bqclient, session.cloudfunctionsclient + ) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_df_apply_axis_1_args(session, scalars_dfs): + columns = ["int64_col", "int64_too"] + scalars_df, scalars_pandas_df = scalars_dfs + + try: + + def the_sum(s1, s2, x): + return s1 + s2 + x + + the_sum_mf = session.remote_function( + input_types=[int, int, int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + )(the_sum) + + args1 = (1,) + + # Fails to apply on dataframe with incompatible number of columns and args. + with pytest.raises( + ValueError, + match="^Parameter count mismatch:.* expected 3 parameters but received 4 values \\(2 DataFrame columns and 2 args\\)", + ): + scalars_df[columns].apply( + the_sum_mf, + axis=1, + args=( + 1, + 1, + ), + ) + + # Fails to apply on dataframe with incompatible column datatypes. + with pytest.raises( + ValueError, + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", + ): + scalars_df[columns].assign( + int64_col=lambda df: df["int64_col"].astype("Float64") + ).apply(the_sum_mf, axis=1, args=args1) + + # Fails to apply on dataframe with incompatible args datatypes. + with pytest.raises( + ValueError, + match="^Data type mismatch for 'args' parameter: Expected .* Received .*", + ): + scalars_df[columns].apply(the_sum_mf, axis=1, args=("hello world",)) + + bf_result = ( + scalars_df[columns] + .dropna() + .apply(the_sum_mf, axis=1, args=args1) + .to_pandas() + ) + pd_result = scalars_pandas_df[columns].dropna().apply(sum, axis=1, args=args1) + + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + finally: + # clean up the gcp assets created for the remote function. + cleanup_function_assets(the_sum_mf, session.bqclient, ignore_failures=False) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_df_apply_axis_1_series_args(session, scalars_dfs): + columns = ["int64_col", "float64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + + try: + + @session.remote_function( + input_types=[pandas.Series, float, str, bool], + output_type=list[str], + reuse=False, + cloud_function_service_account="default", + ) + def foo_list(x: pandas.Series, y0: float, y1, y2) -> list[str]: + return ( + [str(x["int64_col"]), str(y0), str(y1), str(y2)] + if y2 + else [str(x["float64_col"])] + ) + + args1 = (12.34, "hello world", True) + bf_result = scalars_df[columns].apply(foo_list, axis=1, args=args1).to_pandas() + pd_result = scalars_pandas_df[columns].apply(foo_list, axis=1, args=args1) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + args2 = (43.21, "xxx3yyy", False) + foo_list_ref = session.read_gbq_function( + foo_list.bigframes_bigquery_function, is_row_processor=True + ) + bf_result = ( + scalars_df[columns].apply(foo_list_ref, axis=1, args=args2).to_pandas() ) + pd_result = scalars_pandas_df[columns].apply(foo_list, axis=1, args=args2) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the remote function. + cleanup_function_assets(foo_list, session.bqclient, ignore_failures=False) @pytest.mark.parametrize( @@ -1904,7 +2112,9 @@ def test_remote_function_gcf_memory( def square(x: int) -> int: return x * x - square_remote = session.remote_function(reuse=False, **memory_mib_args)(square) + square_remote = session.remote_function( + reuse=False, cloud_function_service_account="default", **memory_mib_args + )(square) # Assert that the GCF is created with the intended memory gcf = session.cloudfunctionsclient.get_function( @@ -1920,8 +2130,8 @@ def square(x: int) -> int: pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square_remote + cleanup_function_assets( + square_remote, session.bqclient, session.cloudfunctionsclient ) @@ -1939,7 +2149,11 @@ def test_remote_function_gcf_memory_unsupported(session, memory_mib): match="Invalid value specified for container memory", ): - @session.remote_function(reuse=False, cloud_function_memory_mib=memory_mib) + @session.remote_function( + reuse=False, + cloud_function_service_account="default", + cloud_function_memory_mib=memory_mib, + ) def square(x: int) -> int: return x * x @@ -1949,14 +2163,31 @@ def test_remote_function_unnamed_removed_w_session_cleanup(): # create a clean session session = bigframes.connect() - # create an unnamed remote function in the session - @session.remote_function(reuse=False) - def foo(x: int) -> int: - return x + 1 + with warnings.catch_warnings(record=True) as record: + # create an unnamed remote function in the session. + # The type hints in this function's signature are redundant. The + # `input_types` and `output_type` arguments from remote_function + # decorator take precedence and will be used instead. + @session.remote_function( + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + ) + def foo(x: int) -> int: + return x + 1 + + # No following warning with only redundant type hints (no conflict). + input_type_warning = "Conflicting input types detected" + assert not any(input_type_warning in str(warning.message) for warning in record) + return_type_warning = "Conflicting return type detected" + assert not any(return_type_warning in str(warning.message) for warning in record) # ensure that remote function artifacts are created assert foo.bigframes_remote_function is not None session.bqclient.get_routine(foo.bigframes_remote_function) is not None + assert foo.bigframes_bigquery_function is not None + session.bqclient.get_routine(foo.bigframes_bigquery_function) is not None assert foo.bigframes_cloud_function is not None session.cloudfunctionsclient.get_function( name=foo.bigframes_cloud_function @@ -1967,7 +2198,7 @@ def foo(x: int) -> int: # ensure that the bq remote function is deleted with pytest.raises(google.cloud.exceptions.NotFound): - session.bqclient.get_routine(foo.bigframes_remote_function) + session.bqclient.get_routine(foo.bigframes_bigquery_function) # the deletion of cloud function happens in a non-blocking way, ensure that # it either exists in a being-deleted state, or is already deleted @@ -1990,13 +2221,17 @@ def test_remote_function_named_perists_w_session_cleanup(): name = test_utils.prefixer.Prefixer("bigframes", "").create_prefix() # create an unnamed remote function in the session - @session.remote_function(reuse=False, name=name) + @session.remote_function( + reuse=False, name=name, cloud_function_service_account="default" + ) def foo(x: int) -> int: return x + 1 # ensure that remote function artifacts are created assert foo.bigframes_remote_function is not None session.bqclient.get_routine(foo.bigframes_remote_function) is not None + assert foo.bigframes_bigquery_function is not None + session.bqclient.get_routine(foo.bigframes_bigquery_function) is not None assert foo.bigframes_cloud_function is not None session.cloudfunctionsclient.get_function( name=foo.bigframes_cloud_function @@ -2006,7 +2241,7 @@ def foo(x: int) -> int: session.close() # ensure that the bq remote function still exists - session.bqclient.get_routine(foo.bigframes_remote_function) is not None + session.bqclient.get_routine(foo.bigframes_bigquery_function) is not None # the deletion of cloud function happens in a non-blocking way, ensure # that it was not deleted and still exists in active state @@ -2016,9 +2251,7 @@ def foo(x: int) -> int: assert gcf.state is functions_v2.Function.State.ACTIVE finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, foo - ) + cleanup_function_assets(foo, session.bqclient, session.cloudfunctionsclient) @pytest.mark.flaky(retries=2, delay=120) @@ -2031,14 +2264,16 @@ def test_remote_function_clean_up_by_session_id(): # without it, and later confirm that the former is deleted when the session # is cleaned up by session id, but the latter remains ## unnamed - @session.remote_function(reuse=False) + @session.remote_function(reuse=False, cloud_function_service_account="default") def foo_unnamed(x: int) -> int: return x + 1 ## named rf_name = test_utils.prefixer.Prefixer("bigframes", "").create_prefix() - @session.remote_function(reuse=False, name=rf_name) + @session.remote_function( + reuse=False, name=rf_name, cloud_function_service_account="default" + ) def foo_named(x: int) -> int: return x + 2 @@ -2047,6 +2282,8 @@ def foo_named(x: int) -> int: for foo in [foo_unnamed, foo_named]: assert foo.bigframes_remote_function is not None session.bqclient.get_routine(foo.bigframes_remote_function) is not None + assert foo.bigframes_bigquery_function is not None + session.bqclient.get_routine(foo.bigframes_bigquery_function) is not None assert foo.bigframes_cloud_function is not None session.cloudfunctionsclient.get_function( name=foo.bigframes_cloud_function @@ -2060,7 +2297,7 @@ def foo_named(x: int) -> int: # ensure that the unnamed bq remote function is deleted along with its # corresponding cloud function with pytest.raises(google.cloud.exceptions.NotFound): - session.bqclient.get_routine(foo_unnamed.bigframes_remote_function) + session.bqclient.get_routine(foo_unnamed.bigframes_bigquery_function) try: gcf = session.cloudfunctionsclient.get_function( name=foo_unnamed.bigframes_cloud_function @@ -2071,15 +2308,15 @@ def foo_named(x: int) -> int: # ensure that the named bq remote function still exists along with its # corresponding cloud function - session.bqclient.get_routine(foo_named.bigframes_remote_function) is not None + session.bqclient.get_routine(foo_named.bigframes_bigquery_function) is not None gcf = session.cloudfunctionsclient.get_function( name=foo_named.bigframes_cloud_function ) assert gcf.state is functions_v2.Function.State.ACTIVE finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, foo_named + cleanup_function_assets( + foo_named, session.bqclient, session.cloudfunctionsclient ) @@ -2103,7 +2340,12 @@ def test_df_apply_axis_1_multiple_params(session): try: - @session.remote_function([int, float, str], str, reuse=False) + @session.remote_function( + input_types=[int, float, str], + output_type=str, + reuse=False, + cloud_function_service_account="default", + ) def foo(x, y, z): return f"I got {x}, {y} and {z}" @@ -2113,19 +2355,19 @@ def foo(x, y, z): # Fails to apply on dataframe with incompatible number of columns with pytest.raises( ValueError, - match="^Remote function takes 3 arguments but DataFrame has 2 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 2 DataFrame columns.", ): bf_df[["Id", "Age"]].apply(foo, axis=1) with pytest.raises( ValueError, - match="^Remote function takes 3 arguments but DataFrame has 4 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 4 DataFrame columns.", ): bf_df.assign(Country="lalaland").apply(foo, axis=1) # Fails to apply on dataframe with incompatible column datatypes with pytest.raises( ValueError, - match="^Remote function takes arguments of types .* but DataFrame dtypes are .*", + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", ): bf_df.assign(Age=bf_df["Age"].astype("Int64")).apply(foo, axis=1) @@ -2148,16 +2390,14 @@ def foo(x, y, z): ) # Let's make sure the read_gbq_function path works for this function - foo_reuse = session.read_gbq_function(foo.bigframes_remote_function) + foo_reuse = session.read_gbq_function(foo.bigframes_bigquery_function) bf_result = bf_df.apply(foo_reuse, axis=1).to_pandas() pandas.testing.assert_series_equal( expected_result, bf_result, check_dtype=False, check_index_type=False ) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, foo - ) + cleanup_function_assets(foo, session.bqclient, session.cloudfunctionsclient) def test_df_apply_axis_1_multiple_params_array_output(session): @@ -2180,36 +2420,38 @@ def test_df_apply_axis_1_multiple_params_array_output(session): try: - @session.remote_function([int, float, str], list[str], reuse=False) + @session.remote_function( + input_types=[int, float, str], + output_type=list[str], + reuse=False, + cloud_function_service_account="default", + ) def foo(x, y, z): return [str(x), str(y), z] assert getattr(foo, "is_row_processor") is False assert getattr(foo, "input_dtypes") == expected_dtypes - assert getattr(foo, "output_dtype") == pandas.ArrowDtype( - pyarrow.list_( - bigframes.dtypes.bigframes_dtype_to_arrow_dtype( - bigframes.dtypes.STRING_DTYPE - ) - ) + assert ( + getattr(foo, "bigframes_bigquery_function_output_dtype") + == bigframes.dtypes.STRING_DTYPE ) # Fails to apply on dataframe with incompatible number of columns with pytest.raises( ValueError, - match="^Remote function takes 3 arguments but DataFrame has 2 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 2 DataFrame columns.", ): bf_df[["Id", "Age"]].apply(foo, axis=1) with pytest.raises( ValueError, - match="^Remote function takes 3 arguments but DataFrame has 4 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 4 DataFrame columns.", ): bf_df.assign(Country="lalaland").apply(foo, axis=1) # Fails to apply on dataframe with incompatible column datatypes with pytest.raises( ValueError, - match="^Remote function takes arguments of types .* but DataFrame dtypes are .*", + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", ): bf_df.assign(Age=bf_df["Age"].astype("Int64")).apply(foo, axis=1) @@ -2232,16 +2474,14 @@ def foo(x, y, z): ) # Let's make sure the read_gbq_function path works for this function - foo_reuse = session.read_gbq_function(foo.bigframes_remote_function) + foo_reuse = session.read_gbq_function(foo.bigframes_bigquery_function) bf_result = bf_df.apply(foo_reuse, axis=1).to_pandas() pandas.testing.assert_series_equal( expected_result, bf_result, check_dtype=False, check_index_type=False ) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, foo - ) + cleanup_function_assets(foo, session.bqclient, session.cloudfunctionsclient) def test_df_apply_axis_1_single_param_non_series(session): @@ -2258,7 +2498,12 @@ def test_df_apply_axis_1_single_param_non_series(session): try: - @session.remote_function([int], str, reuse=False) + @session.remote_function( + input_types=[int], + output_type=str, + reuse=False, + cloud_function_service_account="default", + ) def foo(x): return f"I got {x}" @@ -2268,19 +2513,19 @@ def foo(x): # Fails to apply on dataframe with incompatible number of columns with pytest.raises( ValueError, - match="^Remote function takes 1 arguments but DataFrame has 0 columns\\.$", + match="^Parameter count mismatch:.* expected 1 parameters but received 0 DataFrame.*", ): bf_df[[]].apply(foo, axis=1) with pytest.raises( ValueError, - match="^Remote function takes 1 arguments but DataFrame has 2 columns\\.$", + match="^Parameter count mismatch:.* expected 1 parameters but received 2 DataFrame.*", ): bf_df.assign(Country="lalaland").apply(foo, axis=1) # Fails to apply on dataframe with incompatible column datatypes with pytest.raises( ValueError, - match="^Remote function takes arguments of types .* but DataFrame dtypes are .*", + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", ): bf_df.assign(Id=bf_df["Id"].astype("Float64")).apply(foo, axis=1) @@ -2303,9 +2548,7 @@ def foo(x): ) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, foo - ) + cleanup_function_assets(foo, session.bqclient, session.cloudfunctionsclient) @pytest.mark.flaky(retries=2, delay=120) @@ -2314,7 +2557,7 @@ def test_df_apply_axis_1_array_output(session, scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs try: - @session.remote_function(reuse=False) + @session.remote_function(reuse=False, cloud_function_service_account="default") def generate_stats(row: pandas.Series) -> list[int]: import pandas as pd @@ -2336,52 +2579,86 @@ def generate_stats(row: pandas.Series) -> list[int]: # Let's make sure the read_gbq_function path works for this function generate_stats_reuse = session.read_gbq_function( - generate_stats.bigframes_remote_function, + generate_stats.bigframes_bigquery_function, is_row_processor=True, ) bf_result = scalars_df[columns].apply(generate_stats_reuse, axis=1).to_pandas() pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, generate_stats + cleanup_function_assets( + generate_stats, session.bqclient, session.cloudfunctionsclient ) @pytest.mark.parametrize( - ("ingress_settings_args", "effective_ingress_settings"), + ( + "ingress_settings_args", + "effective_ingress_settings", + "expect_default_ingress_setting_warning", + ), [ pytest.param( - {}, functions_v2.ServiceConfig.IngressSettings.ALLOW_ALL, id="no-set" + {}, + functions_v2.ServiceConfig.IngressSettings.ALLOW_INTERNAL_ONLY, + False, + id="no-set", + ), + pytest.param( + {"cloud_function_ingress_settings": None}, + functions_v2.ServiceConfig.IngressSettings.ALLOW_INTERNAL_ONLY, + True, + id="set-none", ), pytest.param( {"cloud_function_ingress_settings": "all"}, functions_v2.ServiceConfig.IngressSettings.ALLOW_ALL, + False, id="set-all", ), pytest.param( {"cloud_function_ingress_settings": "internal-only"}, functions_v2.ServiceConfig.IngressSettings.ALLOW_INTERNAL_ONLY, + False, id="set-internal-only", ), pytest.param( {"cloud_function_ingress_settings": "internal-and-gclb"}, functions_v2.ServiceConfig.IngressSettings.ALLOW_INTERNAL_AND_GCLB, + False, id="set-internal-and-gclb", ), ], ) @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_ingress_settings( - session, scalars_dfs, ingress_settings_args, effective_ingress_settings + session, + scalars_dfs, + ingress_settings_args, + effective_ingress_settings, + expect_default_ingress_setting_warning, ): try: + # Verify the function raises the expected security warning message. + with warnings.catch_warnings(record=True) as record: - def square(x: int) -> int: - return x * x + def square(x: int) -> int: + return x * x - square_remote = session.remote_function(reuse=False, **ingress_settings_args)( - square + square_remote = session.remote_function( + reuse=False, + cloud_function_service_account="default", + **ingress_settings_args, + )(square) + + default_ingress_setting_warnings = [ + warn + for warn in record + if isinstance(warn.message, UserWarning) + and "The `cloud_function_ingress_settings` is being set to 'internal-only' by default." + ] + assert len(default_ingress_setting_warnings) == ( + 1 if expect_default_ingress_setting_warning else 0 ) # Assert that the GCF is created with the intended maximum timeout @@ -2398,8 +2675,8 @@ def square(x: int) -> int: pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, square_remote + cleanup_function_assets( + square_remote, session.bqclient, session.cloudfunctionsclient ) @@ -2409,7 +2686,11 @@ def test_remote_function_ingress_settings_unsupported(session): ValueError, match="'unknown' not one of the supported ingress settings values" ): - @session.remote_function(reuse=False, cloud_function_ingress_settings="unknown") + @session.remote_function( + reuse=False, + cloud_function_service_account="default", + cloud_function_ingress_settings="unknown", + ) def square(x: int) -> int: return x * x @@ -2441,10 +2722,11 @@ def add_one(x: int) -> int: dataset=dataset_id, bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", )(add_one) temporary_bigquery_remote_function = ( - add_one_remote_temp.bigframes_remote_function + add_one_remote_temp.bigframes_bigquery_function ) assert temporary_bigquery_remote_function is not None assert ( @@ -2484,8 +2766,8 @@ def add_one(x: int) -> int: # clean up the gcp assets created for the temporary remote function, # just in case it was not explicitly cleaned up in the try clause due # to assertion failure or exception earlier than that - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, add_one_remote_temp + cleanup_function_assets( + add_one_remote_temp, session.bqclient, session.cloudfunctionsclient ) @@ -2518,10 +2800,11 @@ def add_one(x: int) -> int: bigquery_connection=bq_cf_connection, reuse=False, name=name, + cloud_function_service_account="default", )(add_one) persistent_bigquery_remote_function = ( - add_one_remote_persist.bigframes_remote_function + add_one_remote_persist.bigframes_bigquery_function ) assert persistent_bigquery_remote_function is not None assert ( @@ -2561,8 +2844,8 @@ def add_one(x: int) -> int: ) finally: # clean up the gcp assets created for the persistent remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, add_one_remote_persist + cleanup_function_assets( + add_one_remote_persist, session.bqclient, session.cloudfunctionsclient ) @@ -2585,6 +2868,7 @@ def test_remote_function_array_output( dataset=dataset_id, bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", ) def featurize(x: int) -> list[array_dtype]: # type: ignore return [array_dtype(i) for i in [x, x + 1, x + 2]] @@ -2602,14 +2886,14 @@ def featurize(x: int) -> list[array_dtype]: # type: ignore # Let's make sure the read_gbq_function path works for this function featurize_reuse = session.read_gbq_function( - featurize.bigframes_remote_function # type: ignore + featurize.bigframes_bigquery_function # type: ignore ) bf_result = scalars_df["int64_too"].apply(featurize_reuse).to_pandas() pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, featurize + cleanup_function_assets( + featurize, session.bqclient, session.cloudfunctionsclient ) @@ -2623,6 +2907,7 @@ def test_remote_function_array_output_partial_ordering_mode( dataset=dataset_id, bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", ) def featurize(x: float) -> list[float]: # type: ignore return [x, x + 1, x + 2] @@ -2640,17 +2925,17 @@ def featurize(x: float) -> list[float]: # type: ignore # Let's make sure the read_gbq_function path works for this function featurize_reuse = unordered_session.read_gbq_function( - featurize.bigframes_remote_function # type: ignore + featurize.bigframes_bigquery_function # type: ignore ) bf_int64_col = scalars_df["float64_col"].dropna() bf_result = bf_int64_col.apply(featurize_reuse).to_pandas() pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( + cleanup_function_assets( + featurize, unordered_session.bqclient, unordered_session.cloudfunctionsclient, - featurize, ) @@ -2664,6 +2949,7 @@ def test_remote_function_array_output_multiindex( dataset=dataset_id, bigquery_connection=bq_cf_connection, reuse=False, + cloud_function_service_account="default", ) def featurize(x: int) -> list[float]: return [x, x + 0.5, x + 0.33] @@ -2683,6 +2969,265 @@ def featurize(x: int) -> list[float]: pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function - cleanup_remote_function_assets( - session.bqclient, session.cloudfunctionsclient, featurize + cleanup_function_assets( + featurize, session.bqclient, session.cloudfunctionsclient ) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_connection_path_format( + session, scalars_dfs, dataset_id, bq_cf_connection +): + try: + + @session.remote_function( + dataset=dataset_id, + bigquery_connection=f"projects/{session.bqclient.project}/locations/{session._location}/connections/{bq_cf_connection}", + reuse=False, + cloud_function_service_account="default", + ) + def foo(x: int) -> int: + return x + 1 + + scalars_df, scalars_pandas_df = scalars_dfs + + bf_int64_col = scalars_df["int64_too"] + bf_result = bf_int64_col.apply(foo).to_pandas() + + pd_int64_col = scalars_pandas_df["int64_too"] + pd_result = pd_int64_col.apply(foo) + + # ignore any dtype disparity + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + finally: + # clean up the gcp assets created for the remote function + cleanup_function_assets(foo, session.bqclient, session.cloudfunctionsclient) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_df_where_mask(session, dataset_id, scalars_dfs): + try: + + # The return type has to be bool type for callable where condition. + def is_sum_positive(a, b): + return a + b > 0 + + is_sum_positive_mf = session.remote_function( + input_types=[int, int], + output_type=bool, + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + )(is_sum_positive) + + scalars_df, scalars_pandas_df = scalars_dfs + int64_cols = ["int64_col", "int64_too"] + + bf_int64_df = scalars_df[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + pd_int64_df = scalars_pandas_df[int64_cols] + pd_int64_df_filtered = pd_int64_df.dropna() + + # Test callable condition in dataframe.where method. + bf_result = bf_int64_df_filtered.where(is_sum_positive_mf, 0).to_pandas() + # Pandas doesn't support such case, use following as workaround. + pd_result = pd_int64_df_filtered.where(pd_int64_df_filtered.sum(axis=1) > 0, 0) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + # Test callable condition in dataframe.mask method. + bf_result = bf_int64_df_filtered.mask(is_sum_positive_mf, 0).to_pandas() + # Pandas doesn't support such case, use following as workaround. + pd_result = pd_int64_df_filtered.mask(pd_int64_df_filtered.sum(axis=1) > 0, 0) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the remote function. + cleanup_function_assets( + is_sum_positive_mf, session.bqclient, ignore_failures=False + ) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_df_where_other_issue(session, dataset_id, scalars_df_index): + try: + + def the_sum(a, b): + return a + b + + the_sum_mf = session.remote_function( + input_types=[int, float], + output_type=float, + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + )(the_sum) + + int64_cols = ["int64_col", "float64_col"] + bf_int64_df = scalars_df_index[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + + with pytest.raises( + ValueError, + match="Seires is not a supported replacement type!", + ): + # The execution of the callable other=the_sum_mf will return a + # Series, which is not a supported replacement type. + bf_int64_df_filtered.where(cond=bf_int64_df > 100, other=the_sum_mf) + + finally: + # Clean up the gcp assets created for the remote function. + cleanup_function_assets(the_sum_mf, session.bqclient, ignore_failures=False) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_df_where_mask_series(session, dataset_id, scalars_dfs): + try: + + # The return type has to be bool type for callable where condition. + def is_sum_positive_series(s: pandas.Series) -> bool: + return s["int64_col"] + s["int64_too"] > 0 + + with pytest.raises( + TypeError, + match="Argument type hint must be Pandas Series, not BigFrames Series.", + ): + session.remote_function( + input_types=bigframes.series.Series, + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + )(is_sum_positive_series) + + is_sum_positive_series_mf = session.remote_function( + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + )(is_sum_positive_series) + + scalars_df, scalars_pandas_df = scalars_dfs + int64_cols = ["int64_col", "int64_too"] + + bf_int64_df = scalars_df[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + pd_int64_df = scalars_pandas_df[int64_cols] + pd_int64_df_filtered = pd_int64_df.dropna() + + # This is for callable `other` arg in dataframe.where method. + def func_for_other(x): + return -x + + # Test callable condition in dataframe.where method. + bf_result = bf_int64_df_filtered.where( + is_sum_positive_series_mf, func_for_other + ).to_pandas() + pd_result = pd_int64_df_filtered.where(is_sum_positive_series, func_for_other) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + # Test callable condition in dataframe.mask method. + bf_result = bf_int64_df_filtered.mask( + is_sum_positive_series_mf, func_for_other + ).to_pandas() + pd_result = pd_int64_df_filtered.mask(is_sum_positive_series, func_for_other) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the remote function. + cleanup_function_assets( + is_sum_positive_series_mf, session.bqclient, ignore_failures=False + ) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_series_where_mask(session, dataset_id, scalars_dfs): + try: + + def _ten_times(x): + return x * 10 + + ten_times_mf = session.remote_function( + input_types=float, + output_type=float, + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + )(_ten_times) + + scalars, scalars_pandas = scalars_dfs + + bf_int64 = scalars["float64_col"] + bf_int64_filtered = bf_int64.dropna() + pd_int64 = scalars_pandas["float64_col"] + pd_int64_filtered = pd_int64.dropna() + + # Test series.where method: the cond is not a callable and the other is + # a callable (remote function). + bf_result = bf_int64_filtered.where( + cond=bf_int64_filtered < 0, other=ten_times_mf + ).to_pandas() + pd_result = pd_int64_filtered.where( + cond=pd_int64_filtered < 0, other=_ten_times + ) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + # Test series.mask method: the cond is not a callable and the other is + # a callable (remote function). + bf_result = bf_int64_filtered.mask( + cond=bf_int64_filtered < 0, other=ten_times_mf + ).to_pandas() + pd_result = pd_int64_filtered.mask(cond=pd_int64_filtered < 0, other=_ten_times) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the remote function. + cleanup_function_assets(ten_times_mf, session.bqclient, ignore_failures=False) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_series_apply_args(session, dataset_id, scalars_dfs): + try: + + @session.remote_function( + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + ) + def foo(x: int, y: bool, z: float) -> str: + if y: + return f"{x}: y is True." + if z > 0.0: + return f"{x}: y is False and z is positive." + return f"{x}: y is False and z is non-positive." + + scalars_df, scalars_pandas_df = scalars_dfs + + args1 = (True, 10.0) + bf_result = scalars_df["int64_too"].apply(foo, args=args1).to_pandas() + pd_result = scalars_pandas_df["int64_too"].apply(foo, args=args1) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + args2 = (False, -10.0) + foo_ref = session.read_gbq_function(foo.bigframes_bigquery_function) + + bf_result = scalars_df["int64_too"].apply(foo_ref, args=args2).to_pandas() + pd_result = scalars_pandas_df["int64_too"].apply(foo, args=args2) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the remote function. + cleanup_function_assets(foo, session.bqclient, ignore_failures=False) diff --git a/tests/system/large/ml/conftest.py b/tests/system/large/ml/conftest.py new file mode 100644 index 0000000000..7735f3eff5 --- /dev/null +++ b/tests/system/large/ml/conftest.py @@ -0,0 +1,87 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import hashlib +import logging + +from google.cloud import bigquery +import google.cloud.exceptions +import pytest + +import bigframes +from bigframes.ml import core, linear_model + +PERMANENT_DATASET = "bigframes_testing" + + +@pytest.fixture(scope="session") +def dataset_id_permanent(bigquery_client: bigquery.Client, project_id: str) -> str: + """Create a dataset if it doesn't exist.""" + dataset_id = f"{project_id}.{PERMANENT_DATASET}" + dataset = bigquery.Dataset(dataset_id) + bigquery_client.create_dataset(dataset, exists_ok=True) + return dataset_id + + +@pytest.fixture(scope="session") +def penguins_bqml_linear_model(session, penguins_linear_model_name) -> core.BqmlModel: + model = session.bqclient.get_model(penguins_linear_model_name) + return core.BqmlModel(session, model) + + +@pytest.fixture(scope="function") +def penguins_linear_model_w_global_explain( + penguins_bqml_linear_model: core.BqmlModel, +) -> linear_model.LinearRegression: + bf_model = linear_model.LinearRegression(enable_global_explain=True) + bf_model._bqml_model = penguins_bqml_linear_model + return bf_model + + +@pytest.fixture(scope="session") +def penguins_table_id(test_data_tables) -> str: + return test_data_tables["penguins"] + + +@pytest.fixture(scope="session") +def penguins_linear_model_name( + session: bigframes.Session, dataset_id_permanent, penguins_table_id +) -> str: + """Provides a pretrained model as a test fixture that is cached across test runs. + This lets us run system tests without having to wait for a model.fit(...)""" + sql = f""" +CREATE OR REPLACE MODEL `$model_name` +OPTIONS ( + model_type='linear_reg', + input_label_cols=['body_mass_g'], + data_split_method='NO_SPLIT' +) AS +SELECT + * +FROM + `{penguins_table_id}` +WHERE + body_mass_g IS NOT NULL""" + # We use the SQL hash as the name to ensure the model is regenerated if this fixture is edited + model_name = f"{dataset_id_permanent}.penguins_linear_reg_{hashlib.md5(sql.encode()).hexdigest()}" + sql = sql.replace("$model_name", model_name) + + try: + session.bqclient.get_model(model_name) + except google.cloud.exceptions.NotFound: + logging.info( + "penguins_linear_model fixture was not found in the permanent dataset, regenerating it..." + ) + session.bqclient.query(sql).result() + finally: + return model_name diff --git a/tests/system/large/ml/test_cluster.py b/tests/system/large/ml/test_cluster.py index 39368f490b..9736199b17 100644 --- a/tests/system/large/ml/test_cluster.py +++ b/tests/system/large/ml/test_cluster.py @@ -15,7 +15,7 @@ import pandas as pd from bigframes.ml import cluster -from tests.system import utils +from bigframes.testing import utils def test_cluster_configure_fit_score_predict( diff --git a/tests/system/large/ml/test_compose.py b/tests/system/large/ml/test_compose.py index cbc702018a..9279324b3c 100644 --- a/tests/system/large/ml/test_compose.py +++ b/tests/system/large/ml/test_compose.py @@ -13,7 +13,7 @@ # limitations under the License. from bigframes.ml import compose, preprocessing -from tests.system import utils +from bigframes.testing import utils def test_columntransformer_standalone_fit_and_transform( diff --git a/tests/system/large/ml/test_core.py b/tests/system/large/ml/test_core.py index c1e1cc19d9..6f0551b1ef 100644 --- a/tests/system/large/ml/test_core.py +++ b/tests/system/large/ml/test_core.py @@ -13,7 +13,7 @@ # limitations under the License. from bigframes.ml import globals -from tests.system import utils +from bigframes.testing import utils def test_bqml_e2e(session, dataset_id, penguins_df_default_index, new_penguins_df): diff --git a/tests/system/large/ml/test_decomposition.py b/tests/system/large/ml/test_decomposition.py index 49aa985189..c36e873816 100644 --- a/tests/system/large/ml/test_decomposition.py +++ b/tests/system/large/ml/test_decomposition.py @@ -13,9 +13,10 @@ # limitations under the License. import pandas as pd +import pandas.testing from bigframes.ml import decomposition -from tests.system import utils +from bigframes.testing import utils def test_decomposition_configure_fit_score_predict( @@ -163,3 +164,58 @@ def test_decomposition_configure_fit_load_none_component( in reloaded_model._bqml_model.model_name ) assert reloaded_model.n_components == 7 + + +def test_decomposition_mf_configure_fit_load( + session, ratings_df_default_index, dataset_id +): + model = decomposition.MatrixFactorization( + num_factors=6, + feedback_type="explicit", + user_col="user_id", + item_col="item_id", + rating_col="rating", + l2_reg=9.83, + ) + + model.fit(ratings_df_default_index) + + reloaded_model = model.to_gbq( + f"{dataset_id}.temp_configured_mf_model", replace=True + ) + + new_ratings = session.read_pandas( + pd.DataFrame( + { + "user_id": ["11", "12", "13"], + "item_id": [1, 2, 3], + "rating": [1.0, 2.0, 3.0], + } + ) + ) + + # Make sure the input to score is not ignored. + scores_training_data = reloaded_model.score().to_pandas() + scores_new_ratings = reloaded_model.score(new_ratings).to_pandas() + pandas.testing.assert_index_equal( + scores_training_data.columns, scores_new_ratings.columns + ) + assert ( + scores_training_data["mean_squared_error"].iloc[0] + != scores_new_ratings["mean_squared_error"].iloc[0] + ) + + result = reloaded_model.predict(new_ratings).to_pandas() + + assert reloaded_model._bqml_model is not None + assert ( + f"{dataset_id}.temp_configured_mf_model" + in reloaded_model._bqml_model.model_name + ) + assert result is not None + assert reloaded_model.feedback_type == "explicit" + assert reloaded_model.num_factors == 6 + assert reloaded_model.user_col == "user_id" + assert reloaded_model.item_col == "item_id" + assert reloaded_model.rating_col == "rating" + assert reloaded_model.l2_reg == 9.83 diff --git a/tests/system/large/ml/test_ensemble.py b/tests/system/large/ml/test_ensemble.py index 706cbfdfaf..c2e9036eed 100644 --- a/tests/system/large/ml/test_ensemble.py +++ b/tests/system/large/ml/test_ensemble.py @@ -15,7 +15,7 @@ import pytest import bigframes.ml.ensemble -from tests.system import utils +from bigframes.testing import utils @pytest.mark.flaky(retries=2) diff --git a/tests/system/large/ml/test_forecasting.py b/tests/system/large/ml/test_forecasting.py index 7c070fd200..72a0ee469b 100644 --- a/tests/system/large/ml/test_forecasting.py +++ b/tests/system/large/ml/test_forecasting.py @@ -15,7 +15,7 @@ import pytest from bigframes.ml import forecasting -from tests.system import utils +from bigframes.testing import utils ARIMA_EVALUATE_OUTPUT_COL = [ "non_seasonal_p", @@ -154,6 +154,7 @@ def test_arima_plus_model_fit_params( holiday_region="US", clean_spikes_and_dips=False, adjust_step_changes=False, + forecast_limit_lower_bound=0.0, time_series_length_fraction=0.5, min_time_series_length=10, trend_smoothing_window_size=5, @@ -183,6 +184,8 @@ def test_arima_plus_model_fit_params( assert reloaded_model.holiday_region == "US" assert reloaded_model.clean_spikes_and_dips is False assert reloaded_model.adjust_step_changes is False + # TODO(b/391399223): API must return forecastLimitLowerBound for the following assertion + # assert reloaded_model.forecast_limit_lower_bound == 0.0 assert reloaded_model.time_series_length_fraction == 0.5 assert reloaded_model.min_time_series_length == 10 assert reloaded_model.trend_smoothing_window_size == 5 diff --git a/tests/system/large/ml/test_linear_model.py b/tests/system/large/ml/test_linear_model.py index 96215c5e47..d7bb122772 100644 --- a/tests/system/large/ml/test_linear_model.py +++ b/tests/system/large/ml/test_linear_model.py @@ -13,10 +13,11 @@ # limitations under the License. import pandas as pd +import pytest from bigframes.ml import model_selection import bigframes.ml.linear_model -from tests.system import utils +from bigframes.testing import utils def test_linear_regression_configure_fit_score(penguins_df_default_index, dataset_id): @@ -61,12 +62,20 @@ def test_linear_regression_configure_fit_score(penguins_df_default_index, datase assert reloaded_model.tol == 0.01 +@pytest.mark.parametrize( + "df_fixture", + [ + "penguins_df_default_index", + "penguins_df_null_index", + ], +) def test_linear_regression_configure_fit_with_eval_score( - penguins_df_default_index, dataset_id + df_fixture, dataset_id, request ): + df = request.getfixturevalue(df_fixture) model = bigframes.ml.linear_model.LinearRegression() - df = penguins_df_default_index.dropna() + df = df.dropna() X = df[ [ "species", @@ -109,7 +118,7 @@ def test_linear_regression_configure_fit_with_eval_score( assert reloaded_model.tol == 0.01 # make sure the bqml model was internally created with custom split - bq_model = penguins_df_default_index._session.bqclient.get_model(bq_model_name) + bq_model = df._session.bqclient.get_model(bq_model_name) last_fitting = bq_model.training_runs[-1]["trainingOptions"] assert last_fitting["dataSplitMethod"] == "CUSTOM" assert "dataSplitColumn" in last_fitting @@ -222,8 +231,8 @@ def test_unordered_mode_linear_regression_configure_fit_score_predict( start_execution_count = end_execution_count result = model.score(X_train, y_train).to_pandas() end_execution_count = df._block._expr.session._metrics.execution_count - # The score function and to_pandas each initiate one query. - assert end_execution_count - start_execution_count == 2 + # The score function and to_pandas reuse same result. + assert end_execution_count - start_execution_count == 1 utils.check_pandas_df_schema_and_index( result, columns=utils.ML_REGRESSION_METRICS, index=1 @@ -452,3 +461,39 @@ def test_model_centroids_with_custom_index(penguins_df_default_index): # If this line executes without errors, the model has correctly ignored the custom index columns model.predict(X_train.reset_index(drop=True)) + + +def test_linear_reg_model_global_explain( + penguins_linear_model_w_global_explain, new_penguins_df +): + training_data = new_penguins_df.dropna(subset=["body_mass_g"]) + X = training_data.drop(columns=["body_mass_g"]) + y = training_data[["body_mass_g"]] + penguins_linear_model_w_global_explain.fit(X, y) + global_ex = penguins_linear_model_w_global_explain.global_explain() + assert global_ex.shape == (6, 1) + expected_columns = pd.Index(["attribution"]) + pd.testing.assert_index_equal(global_ex.columns, expected_columns) + result = global_ex.to_pandas().drop(["attribution"], axis=1).sort_index() + expected_feature = ( + pd.DataFrame( + { + "feature": [ + "island", + "species", + "sex", + "flipper_length_mm", + "culmen_depth_mm", + "culmen_length_mm", + ] + }, + ) + .set_index("feature") + .sort_index() + ) + pd.testing.assert_frame_equal( + result, + expected_feature, + check_exact=False, + check_index_type=False, + ) diff --git a/tests/system/large/ml/test_llm.py b/tests/system/large/ml/test_llm.py new file mode 100644 index 0000000000..1daaebb8cb --- /dev/null +++ b/tests/system/large/ml/test_llm.py @@ -0,0 +1,233 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pyarrow as pa +import pytest + +from bigframes.ml import llm +import bigframes.pandas as bpd +from bigframes.testing import utils + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +@pytest.mark.flaky( + retries=2 +) # usually create model shouldn't be flaky, but this one due to the limited quota of gemini-2.0-flash-exp. +def test_create_load_gemini_text_generator_model( + dataset_id, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + assert gemini_text_generator_model is not None + assert gemini_text_generator_model._bqml_model is not None + + # save, load to ensure configuration was kept + reloaded_model = gemini_text_generator_model.to_gbq( + f"{dataset_id}.temp_text_model", replace=True + ) + assert f"{dataset_id}.temp_text_model" == reloaded_model._bqml_model.model_name + assert reloaded_model.connection_name == bq_connection + assert reloaded_model.model_name == model_name + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +# @pytest.mark.flaky(retries=2) +def test_gemini_text_generator_predict_default_params_success( + llm_text_df, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + df = gemini_text_generator_model.predict(llm_text_df).to_pandas() + utils.check_pandas_df_schema_and_index( + df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False + ) + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +@pytest.mark.flaky(retries=2) +def test_gemini_text_generator_predict_with_params_success( + llm_text_df, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + df = gemini_text_generator_model.predict( + llm_text_df, temperature=0.5, max_output_tokens=100, top_k=20, top_p=0.5 + ).to_pandas() + utils.check_pandas_df_schema_and_index( + df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False + ) + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +@pytest.mark.flaky(retries=2) +def test_gemini_text_generator_multi_cols_predict_success( + llm_text_df: bpd.DataFrame, model_name, session, bq_connection +): + df = llm_text_df.assign(additional_col=1) + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + pd_df = gemini_text_generator_model.predict(df).to_pandas() + utils.check_pandas_df_schema_and_index( + pd_df, + columns=utils.ML_GENERATE_TEXT_OUTPUT + ["additional_col"], + index=3, + col_exact=False, + ) + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +@pytest.mark.flaky(retries=2) +def test_gemini_text_generator_predict_output_schema_success( + llm_text_df: bpd.DataFrame, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + output_schema = { + "bool_output": "bool", + "int_output": "int64", + "float_output": "float64", + "str_output": "string", + "array_output": "array", + "struct_output": "struct", + } + df = gemini_text_generator_model.predict(llm_text_df, output_schema=output_schema) + assert df["bool_output"].dtype == pd.BooleanDtype() + assert df["int_output"].dtype == pd.Int64Dtype() + assert df["float_output"].dtype == pd.Float64Dtype() + assert df["str_output"].dtype == pd.StringDtype(storage="pyarrow") + assert df["array_output"].dtype == pd.ArrowDtype(pa.list_(pa.int64())) + assert df["struct_output"].dtype == pd.ArrowDtype( + pa.struct([("number", pa.int64())]) + ) + + pd_df = df.to_pandas() + utils.check_pandas_df_schema_and_index( + pd_df, + columns=list(output_schema.keys()) + ["prompt", "full_response", "status"], + index=3, + col_exact=False, + ) + + +@pytest.mark.flaky(retries=2) +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ), +) +def test_llm_gemini_score(llm_fine_tune_df_default_index, model_name): + model = llm.GeminiTextGenerator(model_name=model_name) + + # Check score to ensure the model was fitted + score_result = model.score( + X=llm_fine_tune_df_default_index[["prompt"]], + y=llm_fine_tune_df_default_index[["label"]], + ).to_pandas() + utils.check_pandas_df_schema_and_index( + score_result, + columns=[ + "bleu4_score", + "rouge-l_precision", + "rouge-l_recall", + "rouge-l_f1_score", + "evaluation_status", + ], + index=1, + ) + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ), +) +def test_llm_gemini_pro_score_params(llm_fine_tune_df_default_index, model_name): + model = llm.GeminiTextGenerator(model_name=model_name) + + # Check score to ensure the model was fitted + score_result = model.score( + X=llm_fine_tune_df_default_index["prompt"], + y=llm_fine_tune_df_default_index["label"], + task_type="classification", + ).to_pandas() + utils.check_pandas_df_schema_and_index( + score_result, + columns=[ + "precision", + "recall", + "f1_score", + "label", + "evaluation_status", + ], + ) diff --git a/tests/system/large/ml/test_model_selection.py b/tests/system/large/ml/test_model_selection.py index c1856a1537..26174b7ee9 100644 --- a/tests/system/large/ml/test_model_selection.py +++ b/tests/system/large/ml/test_model_selection.py @@ -15,7 +15,7 @@ import pytest from bigframes.ml import linear_model, model_selection -from tests.system import utils +from bigframes.testing import utils @pytest.mark.parametrize( diff --git a/tests/system/large/ml/test_multimodal_llm.py b/tests/system/large/ml/test_multimodal_llm.py new file mode 100644 index 0000000000..03fdddf665 --- /dev/null +++ b/tests/system/large/ml/test_multimodal_llm.py @@ -0,0 +1,45 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.ml import llm +import bigframes.pandas as bpd +from bigframes.testing import utils + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ), +) +@pytest.mark.flaky(retries=2) +def test_gemini_text_generator_multimodal_input( + images_mm_df: bpd.DataFrame, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + pd_df = gemini_text_generator_model.predict( + images_mm_df, prompt=["Describe", images_mm_df["blob_col"]] + ).to_pandas() + utils.check_pandas_df_schema_and_index( + pd_df, + columns=utils.ML_GENERATE_TEXT_OUTPUT + ["blob_col"], + index=2, + col_exact=False, + ) diff --git a/tests/system/large/ml/test_pipeline.py b/tests/system/large/ml/test_pipeline.py index 84a6b11ff2..6c51a11a11 100644 --- a/tests/system/large/ml/test_pipeline.py +++ b/tests/system/large/ml/test_pipeline.py @@ -25,7 +25,7 @@ pipeline, preprocessing, ) -from tests.system import utils +from bigframes.testing import utils def test_pipeline_linear_regression_fit_score_predict( diff --git a/tests/system/large/operations/conftest.py b/tests/system/large/operations/conftest.py index 4f6e2d1704..6f64c7552f 100644 --- a/tests/system/large/operations/conftest.py +++ b/tests/system/large/operations/conftest.py @@ -22,7 +22,7 @@ def gemini_flash_model(session, bq_connection) -> llm.GeminiTextGenerator: return llm.GeminiTextGenerator( session=session, connection_name=bq_connection, - model_name="gemini-1.5-flash-001", + model_name="gemini-2.0-flash-001", ) diff --git a/tests/system/large/operations/test_ai.py b/tests/system/large/operations/test_ai.py new file mode 100644 index 0000000000..86b30d9c65 --- /dev/null +++ b/tests/system/large/operations/test_ai.py @@ -0,0 +1,918 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from contextlib import nullcontext +from unittest.mock import patch + +import pandas as pd +import pandas.testing +import pytest + +import bigframes +from bigframes import dataframe, exceptions, series + +AI_OP_EXP_OPTION = "experiments.ai_operators" +BLOB_EXP_OPTION = "experiments.blob" +THRESHOLD_OPTION = "compute.ai_ops_confirmation_threshold" + + +def test_filter(session, gemini_flash_model): + df = dataframe.DataFrame( + data={ + "country": ["USA", "Germany"], + "city": ["Seattle", "Berlin"], + "year": [2023, 2024], + }, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = df.ai.filter( + "{city} is the capital of {country} in {year}", gemini_flash_model + ).to_pandas() + + expected_df = pd.DataFrame( + {"country": ["Germany"], "city": ["Berlin"], "year": [2024]}, index=[1] + ) + pandas.testing.assert_frame_equal( + actual_df, expected_df, check_dtype=False, check_index_type=False + ) + + +def test_filter_multi_model(session, gemini_flash_model): + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + BLOB_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + df["prey"] = series.Series( + ["building", "cross road", "rock", "squirrel", "rabbit"], session=session + ) + result = df.ai.filter( + "The object in {image} feeds on {prey}", + gemini_flash_model, + ).to_pandas() + + assert len(result) <= len(df) + + +@pytest.mark.parametrize( + ("reply"), + [ + pytest.param("y"), + pytest.param( + "n", marks=pytest.mark.xfail(raises=exceptions.OperationAbortedError) + ), + ], +) +def test_filter_with_confirmation(session, gemini_flash_model, reply, monkeypatch): + df = dataframe.DataFrame( + data={ + "country": ["USA", "Germany"], + "city": ["Seattle", "Berlin"], + "year": [2023, 2024], + }, + session=session, + ) + monkeypatch.setattr("builtins.input", lambda: reply) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df.ai.filter("{city} is the capital of {country} in {year}", gemini_flash_model) + + +def test_filter_single_column_reference(session, gemini_flash_model): + df = dataframe.DataFrame( + data={"country": ["USA", "Germany"], "city": ["Seattle", "Berlin"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = df.ai.filter( + "{country} is in Europe", gemini_flash_model + ).to_pandas() + + expected_df = pd.DataFrame({"country": ["Germany"], "city": ["Berlin"]}, index=[1]) + pandas.testing.assert_frame_equal( + actual_df, expected_df, check_dtype=False, check_index_type=False + ) + + +@pytest.mark.parametrize( + "instruction", + [ + pytest.param( + "No column reference", + id="zero_column", + marks=pytest.mark.xfail(raises=ValueError), + ), + pytest.param( + "{city} is in the {non_existing_column}", + id="non_existing_column", + marks=pytest.mark.xfail(raises=ValueError), + ), + pytest.param( + "{id}", + id="invalid_type", + marks=pytest.mark.xfail(raises=TypeError), + ), + ], +) +def test_filter_invalid_instruction_raise_error(instruction, gemini_flash_model): + df = dataframe.DataFrame({"id": [1, 2], "city": ["Seattle", "Berlin"]}) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.ai.filter(instruction, gemini_flash_model) + + +def test_filter_invalid_model_raise_error(): + df = dataframe.DataFrame( + {"country": ["USA", "Germany"], "city": ["Seattle", "Berlin"]} + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + df.ai.filter("{city} is the capital of {country}", None) + + +@pytest.mark.parametrize( + ("output_schema", "output_col"), + [ + pytest.param(None, "ml_generate_text_llm_result", id="default_schema"), + pytest.param({"food": "string"}, "food", id="non_default_schema"), + ], +) +def test_map(session, gemini_flash_model, output_schema, output_col): + df = dataframe.DataFrame( + data={ + "ingredient_1": ["Burger Bun", "Soy Bean"], + "ingredient_2": ["Beef Patty", "Bittern"], + "gluten-free": [True, True], + }, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = df.ai.map( + "What is the {gluten-free} food made from {ingredient_1} and {ingredient_2}? One word only.", + gemini_flash_model, + output_schema=output_schema, + ).to_pandas() + # Result sanitation + actual_df[output_col] = actual_df[output_col].str.strip().str.lower() + + expected_df = pd.DataFrame( + { + "ingredient_1": ["Burger Bun", "Soy Bean"], + "ingredient_2": ["Beef Patty", "Bittern"], + "gluten-free": [True, True], + output_col: ["burger", "tofu"], + } + ) + pandas.testing.assert_frame_equal( + actual_df, + expected_df, + check_dtype=False, + check_index_type=False, + check_column_type=False, + ) + + +def test_map_multimodel(session, gemini_flash_model): + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + BLOB_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + df["scenario"] = series.Series( + ["building", "cross road", "tree", "squirrel", "rabbit"], session=session + ) + result = df.ai.map( + "What is the object in {image} combined with {scenario}? One word only.", + gemini_flash_model, + output_schema={"object": "string"}, + ).to_pandas() + + assert len(result) == len(df) + + +@pytest.mark.parametrize( + ("reply"), + [ + pytest.param("y"), + pytest.param( + "n", marks=pytest.mark.xfail(raises=exceptions.OperationAbortedError) + ), + ], +) +def test_map_with_confirmation(session, gemini_flash_model, reply, monkeypatch): + df = dataframe.DataFrame( + data={ + "ingredient_1": ["Burger Bun", "Soy Bean"], + "ingredient_2": ["Beef Patty", "Bittern"], + "gluten-free": [True, True], + }, + session=session, + ) + monkeypatch.setattr("builtins.input", lambda: reply) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df.ai.map( + "What is the {gluten-free} food made from {ingredient_1} and {ingredient_2}? One word only.", + gemini_flash_model, + ) + + +@pytest.mark.parametrize( + "instruction", + [ + pytest.param( + "No column reference", + id="zero_column", + marks=pytest.mark.xfail(raises=ValueError), + ), + pytest.param( + "What is the food made from {ingredient_1} and {non_existing_column}?}", + id="non_existing_column", + marks=pytest.mark.xfail(raises=ValueError), + ), + pytest.param( + "{id}", + id="invalid_type", + marks=pytest.mark.xfail(raises=TypeError), + ), + ], +) +def test_map_invalid_instruction_raise_error(instruction, gemini_flash_model): + df = dataframe.DataFrame( + data={ + "id": [1, 2], + "ingredient_1": ["Burger Bun", "Soy Bean"], + "ingredient_2": ["Beef Patty", "Bittern"], + } + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.ai.map(instruction, gemini_flash_model, output_schema={"food": "string"}) + + +def test_map_invalid_model_raise_error(): + df = dataframe.DataFrame( + data={ + "ingredient_1": ["Burger Bun", "Soy Bean"], + "ingredient_2": ["Beef Patty", "Bittern"], + }, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + df.ai.map( + "What is the food made from {ingredient_1} and {ingredient_2}? One word only.", + None, + ) + + +def test_classify(gemini_flash_model, session): + df = dataframe.DataFrame(data={"creature": ["dog", "rose"]}, session=session) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_result = df.ai.classify( + "{creature}", + gemini_flash_model, + labels=["animal", "plant"], + output_column="result", + ).to_pandas() + + expected_result = pd.DataFrame( + { + "creature": ["dog", "rose"], + "result": ["animal", "plant"], + } + ) + pandas.testing.assert_frame_equal( + actual_result, expected_result, check_index_type=False, check_dtype=False + ) + + +@pytest.mark.parametrize( + "instruction", + [ + pytest.param("{city} is in {country}", id="no_dataframe_reference"), + pytest.param("{left.city} is in {country}", id="has_left_dataframe_reference"), + pytest.param( + "{city} is in {right.country}", + id="has_right_dataframe_reference", + ), + pytest.param( + "{left.city} is in {right.country}", id="has_both_dataframe_references" + ), + ], +) +def test_join(instruction, session, gemini_flash_model): + cities = dataframe.DataFrame( + data={ + "city": ["Seattle", "Berlin"], + }, + session=session, + ) + countries = dataframe.DataFrame( + data={"country": ["USA", "UK", "Germany"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = cities.ai.join( + countries, + instruction, + gemini_flash_model, + ).to_pandas() + + expected_df = pd.DataFrame( + { + "city": ["Seattle", "Berlin"], + "country": ["USA", "Germany"], + } + ) + pandas.testing.assert_frame_equal( + actual_df, + expected_df, + check_dtype=False, + check_index_type=False, + check_column_type=False, + ) + + +@pytest.mark.parametrize( + ("reply"), + [ + pytest.param("y"), + pytest.param( + "n", marks=pytest.mark.xfail(raises=exceptions.OperationAbortedError) + ), + ], +) +def test_join_with_confirmation(session, gemini_flash_model, reply, monkeypatch): + cities = dataframe.DataFrame( + data={ + "city": ["Seattle", "Berlin"], + }, + session=session, + ) + countries = dataframe.DataFrame( + data={"country": ["USA", "UK", "Germany"]}, + session=session, + ) + monkeypatch.setattr("builtins.input", lambda: reply) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + cities.ai.join( + countries, + "{city} is in {country}", + gemini_flash_model, + ) + + +def test_self_join(session, gemini_flash_model): + animals = dataframe.DataFrame( + data={ + "animal": ["ant", "elephant"], + }, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = animals.ai.join( + animals, + "{left.animal} is heavier than {right.animal}", + gemini_flash_model, + ).to_pandas() + + expected_df = pd.DataFrame( + { + "animal_left": ["elephant"], + "animal_right": ["ant"], + } + ) + pandas.testing.assert_frame_equal( + actual_df, + expected_df, + check_dtype=False, + check_index_type=False, + check_column_type=False, + ) + + +@pytest.mark.parametrize( + ("instruction", "error_pattern"), + [ + ("No column reference", "No column references"), + pytest.param( + "{city} is in {continent}", r"Column .+ not found", id="non_existing_column" + ), + pytest.param( + "{city} is in {country}", + r"Ambiguous column reference: .+", + id="ambiguous_column", + ), + pytest.param( + "{right.city} is in {country}", r"Column .+ not found", id="wrong_prefix" + ), + pytest.param( + "{city} is in {right.continent}", + r"Column .+ not found", + id="prefix_on_non_existing_column", + ), + ], +) +def test_join_invalid_instruction_raise_error( + instruction, error_pattern, gemini_flash_model +): + df1 = dataframe.DataFrame( + {"city": ["Seattle", "Berlin"], "country": ["USA", "Germany"]} + ) + df2 = dataframe.DataFrame( + { + "country": ["USA", "UK", "Germany"], + "region": ["North America", "Europe", "Europe"], + } + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError, match=error_pattern): + df1.ai.join(df2, instruction, gemini_flash_model) + + +def test_join_invalid_model_raise_error(): + cities = dataframe.DataFrame({"city": ["Seattle", "Berlin"]}) + countries = dataframe.DataFrame({"country": ["USA", "UK", "Germany"]}) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + cities.ai.join(countries, "{city} is in {country}", None) + + +@pytest.mark.parametrize( + "score_column", + [ + pytest.param(None, id="no_score_column"), + pytest.param("distance", id="has_score_column"), + ], +) +def test_search(session, text_embedding_generator, score_column): + df = dataframe.DataFrame( + data={"creatures": ["salmon", "sea urchin", "baboons", "frog", "chimpanzee"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_result = df.ai.search( + "creatures", + "monkey", + top_k=2, + model=text_embedding_generator, + score_column=score_column, + ).to_pandas() + + expected_result = pd.Series( + ["baboons", "chimpanzee"], index=[2, 4], name="creatures" + ) + pandas.testing.assert_series_equal( + actual_result["creatures"], + expected_result, + check_dtype=False, + check_index_type=False, + ) + + if score_column is None: + assert len(actual_result.columns) == 1 + else: + assert score_column in actual_result.columns + + +@pytest.mark.parametrize( + ("reply"), + [ + pytest.param("y"), + pytest.param( + "n", marks=pytest.mark.xfail(raises=exceptions.OperationAbortedError) + ), + ], +) +def test_search_with_confirmation( + session, text_embedding_generator, reply, monkeypatch +): + df = dataframe.DataFrame( + data={"creatures": ["salmon", "sea urchin", "baboons", "frog", "chimpanzee"]}, + session=session, + ) + monkeypatch.setattr("builtins.input", lambda: reply) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df.ai.search( + "creatures", + "monkey", + top_k=2, + model=text_embedding_generator, + ) + + +def test_search_invalid_column_raises_error(session, text_embedding_generator): + df = dataframe.DataFrame( + data={"creatures": ["salmon", "sea urchin", "baboons", "frog", "chimpanzee"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.ai.search("whatever", "monkey", top_k=2, model=text_embedding_generator) + + +def test_search_invalid_model_raises_error(session): + df = dataframe.DataFrame( + data={"creatures": ["salmon", "sea urchin", "baboons", "frog", "chimpanzee"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + df.ai.search("creatures", "monkey", top_k=2, model=None) + + +def test_search_invalid_top_k_raises_error(session, text_embedding_generator): + df = dataframe.DataFrame( + data={"creatures": ["salmon", "sea urchin", "baboons", "frog", "chimpanzee"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.ai.search("creatures", "monkey", top_k=0, model=text_embedding_generator) + + +@pytest.mark.parametrize( + "score_column", + [ + pytest.param(None, id="no_score_column"), + pytest.param("distance", id="has_score_column"), + ], +) +def test_sim_join(session, text_embedding_generator, score_column): + df1 = dataframe.DataFrame( + data={"creatures": ["salmon", "cat"]}, + session=session, + ) + df2 = dataframe.DataFrame( + data={"creatures": ["dog", "tuna"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_result = df1.ai.sim_join( + df2, + left_on="creatures", + right_on="creatures", + model=text_embedding_generator, + top_k=1, + score_column=score_column, + ).to_pandas() + + expected_result = pd.DataFrame( + {"creatures": ["salmon", "cat"], "creatures_1": ["tuna", "dog"]} + ) + pandas.testing.assert_frame_equal( + actual_result[["creatures", "creatures_1"]], + expected_result, + check_dtype=False, + check_index_type=False, + ) + + if score_column is None: + assert len(actual_result.columns) == 2 + else: + assert score_column in actual_result.columns + + +@pytest.mark.parametrize( + ("reply"), + [ + pytest.param("y"), + pytest.param( + "n", marks=pytest.mark.xfail(raises=exceptions.OperationAbortedError) + ), + ], +) +def test_sim_join_with_confirmation( + session, text_embedding_generator, reply, monkeypatch +): + df1 = dataframe.DataFrame( + data={"creatures": ["salmon", "cat"]}, + session=session, + ) + df2 = dataframe.DataFrame( + data={"creatures": ["dog", "tuna"]}, + session=session, + ) + monkeypatch.setattr("builtins.input", lambda: reply) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df1.ai.sim_join( + df2, + left_on="creatures", + right_on="creatures", + model=text_embedding_generator, + top_k=1, + ) + + +@pytest.mark.parametrize( + ("left_on", "right_on"), + [ + pytest.param("whatever", "creatures", id="incorrect_left_column"), + pytest.param("creatures", "whatever", id="incorrect_right_column"), + ], +) +def test_sim_join_invalid_column_raises_error( + session, text_embedding_generator, left_on, right_on +): + df1 = dataframe.DataFrame( + data={"creatures": ["salmon", "cat"]}, + session=session, + ) + df2 = dataframe.DataFrame( + data={"creatures": ["dog", "tuna"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df1.ai.sim_join( + df2, left_on=left_on, right_on=right_on, model=text_embedding_generator + ) + + +def test_sim_join_invalid_model_raises_error(session): + df1 = dataframe.DataFrame( + data={"creatures": ["salmon", "cat"]}, + session=session, + ) + df2 = dataframe.DataFrame( + data={"creatures": ["dog", "tuna"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + df1.ai.sim_join(df2, left_on="creatures", right_on="creatures", model=None) + + +def test_sim_join_invalid_top_k_raises_error(session, text_embedding_generator): + df1 = dataframe.DataFrame( + data={"creatures": ["salmon", "cat"]}, + session=session, + ) + df2 = dataframe.DataFrame( + data={"creatures": ["dog", "tuna"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df1.ai.sim_join( + df2, + left_on="creatures", + right_on="creatures", + top_k=0, + model=text_embedding_generator, + ) + + +def test_sim_join_data_too_large_raises_error(session, text_embedding_generator): + df1 = dataframe.DataFrame( + data={"creatures": ["salmon", "cat"]}, + session=session, + ) + df2 = dataframe.DataFrame( + data={"creatures": ["dog", "tuna"]}, + session=session, + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df1.ai.sim_join( + df2, + left_on="creatures", + right_on="creatures", + model=text_embedding_generator, + max_rows=1, + ) + + +@patch("builtins.input", return_value="") +def test_confirm_operation__below_threshold_do_not_confirm(mock_input): + df = dataframe.DataFrame({}) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 3, + ): + df.ai._confirm_operation(1) + + mock_input.assert_not_called() + + +@patch("builtins.input", return_value="") +def test_confirm_operation__threshold_is_none_do_not_confirm(mock_input): + df = dataframe.DataFrame({}) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + None, + ): + df.ai._confirm_operation(100) + + mock_input.assert_not_called() + + +@patch("builtins.input", return_value="") +def test_confirm_operation__threshold_autofail_do_not_confirm(mock_input): + df = dataframe.DataFrame({}) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 1, + "compute.ai_ops_threshold_autofail", + True, + ), pytest.raises(exceptions.OperationAbortedError): + df.ai._confirm_operation(100) + + mock_input.assert_not_called() + + +@pytest.mark.parametrize( + ("reply", "expectation"), + [ + ("y", nullcontext()), + ("yes", nullcontext()), + ("", nullcontext()), + ("n", pytest.raises(exceptions.OperationAbortedError)), + ("something", pytest.raises(exceptions.OperationAbortedError)), + ], +) +def test_confirm_operation__above_threshold_confirm(reply, expectation, monkeypatch): + monkeypatch.setattr("builtins.input", lambda: reply) + df = dataframe.DataFrame({}) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 3, + ), expectation as e: + assert df.ai._confirm_operation(4) == e diff --git a/tests/system/large/operations/test_semantics.py b/tests/system/large/operations/test_semantics.py index 20219ef46e..7ae78a5c53 100644 --- a/tests/system/large/operations/test_semantics.py +++ b/tests/system/large/operations/test_semantics.py @@ -20,9 +20,15 @@ import pytest import bigframes -from bigframes import dataframe, dtypes, exceptions +from bigframes import dataframe, dtypes, exceptions, series -EXPERIMENT_OPTION = "experiments.semantic_operators" +pytest.skip( + "Semantics namespace is deprecated. ", + allow_module_level=True, +) + +SEM_OP_EXP_OPTION = "experiments.semantic_operators" +BLOB_EXP_OPTION = "experiments.blob" THRESHOLD_OPTION = "compute.semantic_ops_confirmation_threshold" @@ -31,7 +37,7 @@ def test_semantics_experiment_off_raise_error(): {"country": ["USA", "Germany"], "city": ["Seattle", "Berlin"]} ) - with bigframes.option_context(EXPERIMENT_OPTION, False), pytest.raises( + with bigframes.option_context(SEM_OP_EXP_OPTION, False), pytest.raises( NotImplementedError ): df.semantics @@ -68,7 +74,7 @@ def test_agg(session, gemini_flash_model, max_agg_rows, cluster_column): instruction = "Find the shared first name of actors in {Movies}. One word answer." with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 50, @@ -80,7 +86,7 @@ def test_agg(session, gemini_flash_model, max_agg_rows, cluster_column): cluster_column=cluster_column, ).to_pandas() - expected_s = pd.Series(["Leonardo \n"], dtype=dtypes.STRING_DTYPE) + expected_s = pd.Series(["Leonardo\n"], dtype=dtypes.STRING_DTYPE) expected_s.name = "Movies" pandas.testing.assert_series_equal(actual_s, expected_s, check_index_type=False) @@ -114,7 +120,7 @@ def test_agg_with_confirmation(session, gemini_flash_model, reply, monkeypatch): monkeypatch.setattr("builtins.input", lambda: reply) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 0, @@ -131,15 +137,16 @@ def test_agg_w_int_column(session, gemini_flash_model): "Movies": [ "Killers of the Flower Moon", "The Great Gatsby", + "The Wolf of Wall Street", ], - "Years": [2023, 2013], + "Years": [2023, 2013, 2013], }, session=session, ) - instruction = "Find the {Years} Leonardo DiCaprio acted in the most movies. Answer with the year only." + instruction = "Find the {Years} Leonardo DiCaprio acted in the most movies. Your answer should be the four-digit year, returned as a string." with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -149,7 +156,7 @@ def test_agg_w_int_column(session, gemini_flash_model): model=gemini_flash_model, ).to_pandas() - expected_s = pd.Series(["2013 \n"], dtype=dtypes.STRING_DTYPE) + expected_s = pd.Series(["2013\n"], dtype=dtypes.STRING_DTYPE) expected_s.name = "Years" pandas.testing.assert_series_equal(actual_s, expected_s, check_index_type=False) @@ -187,7 +194,7 @@ def test_agg_invalid_instruction_raise_error(instruction, gemini_flash_model): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -222,7 +229,7 @@ def test_agg_invalid_cluster_column_raise_error(gemini_flash_model, cluster_colu instruction = "Find the shared first name of actors in {Movies}. One word answer." with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -257,7 +264,7 @@ def test_cluster_by(session, text_embedding_generator, n_clusters): output_column = "cluster id" with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -305,7 +312,7 @@ def test_cluster_by_with_confirmation( monkeypatch.setattr("builtins.input", lambda: reply) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 0, @@ -326,7 +333,7 @@ def test_cluster_by_invalid_column(session, text_embedding_generator): output_column = "cluster id" with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -347,7 +354,7 @@ def test_cluster_by_invalid_model(session, gemini_flash_model): output_column = "cluster id" with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -371,7 +378,7 @@ def test_filter(session, gemini_flash_model): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -388,6 +395,29 @@ def test_filter(session, gemini_flash_model): ) +def test_filter_multi_model(session, gemini_flash_model): + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + BLOB_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + df["prey"] = series.Series( + ["building", "cross road", "rock", "squirrel", "rabbit"], session=session + ) + result = df.semantics.filter( + "The object in {image} feeds on {prey}", + gemini_flash_model, + ).to_pandas() + + assert len(result) <= len(df) + + @pytest.mark.parametrize( ("reply"), [ @@ -409,7 +439,7 @@ def test_filter_with_confirmation(session, gemini_flash_model, reply, monkeypatc monkeypatch.setattr("builtins.input", lambda: reply) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 0, @@ -426,7 +456,7 @@ def test_filter_single_column_reference(session, gemini_flash_model): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -465,7 +495,7 @@ def test_filter_invalid_instruction_raise_error(instruction, gemini_flash_model) df = dataframe.DataFrame({"id": [1, 2], "city": ["Seattle", "Berlin"]}) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -479,7 +509,7 @@ def test_filter_invalid_model_raise_error(): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -498,7 +528,7 @@ def test_map(session, gemini_flash_model): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -528,6 +558,30 @@ def test_map(session, gemini_flash_model): ) +def test_map_multimodel(session, gemini_flash_model): + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + BLOB_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + df["scenario"] = series.Series( + ["building", "cross road", "tree", "squirrel", "rabbit"], session=session + ) + result = df.semantics.map( + "What is the object in {image} combined with {scenario}? One word only.", + "object", + gemini_flash_model, + ).to_pandas() + + assert len(result) == len(df) + + @pytest.mark.parametrize( ("reply"), [ @@ -549,7 +603,7 @@ def test_map_with_confirmation(session, gemini_flash_model, reply, monkeypatch): monkeypatch.setattr("builtins.input", lambda: reply) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 0, @@ -591,7 +645,7 @@ def test_map_invalid_instruction_raise_error(instruction, gemini_flash_model): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -608,7 +662,7 @@ def test_map_invalid_model_raise_error(): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -647,7 +701,7 @@ def test_join(instruction, session, gemini_flash_model): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -696,7 +750,7 @@ def test_join_with_confirmation(session, gemini_flash_model, reply, monkeypatch) monkeypatch.setattr("builtins.input", lambda: reply) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 0, @@ -711,13 +765,13 @@ def test_join_with_confirmation(session, gemini_flash_model, reply, monkeypatch) def test_self_join(session, gemini_flash_model): animals = dataframe.DataFrame( data={ - "animal": ["spider", "capybara"], + "animal": ["ant", "elephant"], }, session=session, ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -730,8 +784,8 @@ def test_self_join(session, gemini_flash_model): expected_df = pd.DataFrame( { - "animal_left": ["capybara"], - "animal_right": ["spider"], + "animal_left": ["elephant"], + "animal_right": ["ant"], } ) pandas.testing.assert_frame_equal( @@ -779,7 +833,7 @@ def test_join_invalid_instruction_raise_error( ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -792,7 +846,7 @@ def test_join_invalid_model_raise_error(): countries = dataframe.DataFrame({"country": ["USA", "UK", "Germany"]}) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -814,7 +868,7 @@ def test_search(session, text_embedding_generator, score_column): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -862,7 +916,7 @@ def test_search_with_confirmation( monkeypatch.setattr("builtins.input", lambda: reply) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 0, @@ -882,7 +936,7 @@ def test_search_invalid_column_raises_error(session, text_embedding_generator): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -899,7 +953,7 @@ def test_search_invalid_model_raises_error(session): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -914,7 +968,7 @@ def test_search_invalid_top_k_raises_error(session, text_embedding_generator): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -942,7 +996,7 @@ def test_sim_join(session, text_embedding_generator, score_column): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -995,7 +1049,7 @@ def test_sim_join_with_confirmation( monkeypatch.setattr("builtins.input", lambda: reply) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 0, @@ -1029,7 +1083,7 @@ def test_sim_join_invalid_column_raises_error( ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -1050,7 +1104,7 @@ def test_sim_join_invalid_model_raises_error(session): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -1071,7 +1125,7 @@ def test_sim_join_invalid_top_k_raises_error(session, text_embedding_generator): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -1096,7 +1150,7 @@ def test_sim_join_data_too_large_raises_error(session, text_embedding_generator) ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -1145,7 +1199,7 @@ def test_top_k_invalid_instruction_raise_error(instruction, gemini_flash_model): ) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -1157,7 +1211,7 @@ def test_top_k_invalid_k_raise_error(gemini_flash_model): df = dataframe.DataFrame({"Animals": ["Dog", "Cat", "Bird", "Horse"]}) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 10, @@ -1174,7 +1228,7 @@ def test_confirm_operation__below_threshold_do_not_confirm(mock_input): df = dataframe.DataFrame({}) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 3, @@ -1189,7 +1243,7 @@ def test_confirm_operation__threshold_is_none_do_not_confirm(mock_input): df = dataframe.DataFrame({}) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, None, @@ -1204,7 +1258,7 @@ def test_confirm_operation__threshold_autofail_do_not_confirm(mock_input): df = dataframe.DataFrame({}) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 1, @@ -1231,7 +1285,7 @@ def test_confirm_operation__above_threshold_confirm(reply, expectation, monkeypa df = dataframe.DataFrame({}) with bigframes.option_context( - EXPERIMENT_OPTION, + SEM_OP_EXP_OPTION, True, THRESHOLD_OPTION, 3, diff --git a/tests/system/large/streaming/test_bigtable.py b/tests/system/large/streaming/test_bigtable.py new file mode 100644 index 0000000000..38e01f44bc --- /dev/null +++ b/tests/system/large/streaming/test_bigtable.py @@ -0,0 +1,106 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime, timedelta +import time +from typing import Generator +import uuid + +import pytest + +import bigframes + +pytest.importorskip("google.cloud.bigtable") + +from google.cloud import bigtable # noqa +from google.cloud.bigtable import column_family, instance, table # noqa + + +@pytest.fixture(scope="session") +def bigtable_instance(session_load: bigframes.Session) -> instance.Instance: + client = bigtable.Client(project=session_load._project, admin=True) + + instance_name = "streaming-testing-instance" + bt_instance = instance.Instance( + instance_name, + client, + ) + + if not bt_instance.exists(): + cluster_id = "streaming-testing-instance-c1" + cluster = bt_instance.cluster( + cluster_id, + location_id="us-west1-a", + serve_nodes=1, + ) + operation = bt_instance.create( + clusters=[cluster], + ) + operation.result(timeout=480) + return bt_instance + + +@pytest.fixture(scope="function") +def bigtable_table( + bigtable_instance: instance.Instance, +) -> Generator[table.Table, None, None]: + table_id = "bigframes_test_" + uuid.uuid4().hex + bt_table = table.Table( + table_id, + bigtable_instance, + ) + max_versions_rule = column_family.MaxVersionsGCRule(1) + column_family_id = "body_mass_g" + column_families = {column_family_id: max_versions_rule} + bt_table.create(column_families=column_families) + yield bt_table + bt_table.delete() + + +@pytest.mark.flaky(retries=3, delay=10) +def test_streaming_df_to_bigtable( + session_load: bigframes.Session, bigtable_table: table.Table +): + # launch a continuous query + job_id_prefix = "test_streaming_" + sdf = session_load.read_gbq_table_streaming("birds.penguins_bigtable_streaming") + + sdf = sdf[["species", "island", "body_mass_g"]] + sdf = sdf[sdf["body_mass_g"] < 4000] + sdf = sdf.rename(columns={"island": "rowkey"}) + + try: + query_job = sdf.to_bigtable( + instance="streaming-testing-instance", + table=bigtable_table.table_id, + service_account_email="streaming-testing-admin@bigframes-load-testing.iam.gserviceaccount.com", + app_profile=None, + truncate=True, + overwrite=True, + auto_create_column_families=True, + bigtable_options={}, + job_id=None, + job_id_prefix=job_id_prefix, + start_timestamp=datetime.now() - timedelta(days=1), + ) + + # wait 200 seconds in order to ensure the query doesn't stop + # (i.e. it is continuous) + time.sleep(200) + assert query_job.running() + assert query_job.error_result is None + assert str(query_job.job_id).startswith(job_id_prefix) + assert len(list(bigtable_table.read_rows())) > 0 + finally: + query_job.cancel() diff --git a/tests/system/large/streaming/test_pubsub.py b/tests/system/large/streaming/test_pubsub.py new file mode 100644 index 0000000000..9ff965fd77 --- /dev/null +++ b/tests/system/large/streaming/test_pubsub.py @@ -0,0 +1,116 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from concurrent import futures +from datetime import datetime, timedelta +from typing import Generator +import uuid + +import pytest + +import bigframes + +pytest.importorskip("google.cloud.pubsub") +from google.cloud import pubsub # type: ignore # noqa + + +def resource_name_full(project_id: str, resource_type: str, resource_id: str): + """Used for bigtable or pubsub resources.""" + return f"projects/{project_id}/{resource_type}/{resource_id}" + + +@pytest.fixture(scope="function") +def pubsub_topic_id(session_load: bigframes.Session) -> Generator[str, None, None]: + publisher = pubsub.PublisherClient() + topic_id = "bigframes_test_topic_" + uuid.uuid4().hex + + topic_name = resource_name_full(session_load._project, "topics", topic_id) + + publisher.create_topic(name=topic_name) + yield topic_id + publisher.delete_topic(topic=topic_name) + + +@pytest.fixture(scope="function") +def pubsub_topic_subscription_ids( + session_load: bigframes.Session, pubsub_topic_id: str +) -> Generator[tuple[str, str], None, None]: + subscriber = pubsub.SubscriberClient() + subscription_id = "bigframes_test_subscription_" + uuid.uuid4().hex + + subscription_name = resource_name_full( + session_load._project, "subscriptions", subscription_id + ) + topic_name = resource_name_full(session_load._project, "topics", pubsub_topic_id) + + subscriber.create_subscription(name=subscription_name, topic=topic_name) + yield (pubsub_topic_id, subscription_id) + subscriber.delete_subscription(subscription=subscription_name) + + +@pytest.mark.flaky(retries=3, delay=10) +def test_streaming_df_to_pubsub( + session_load: bigframes.Session, pubsub_topic_subscription_ids: tuple[str, str] +): + topic_id, subscription_id = pubsub_topic_subscription_ids + + subscriber = pubsub.SubscriberClient() + + subscription_name = "projects/{project_id}/subscriptions/{sub}".format( + project_id=session_load._project, + sub=subscription_id, + ) + + # launch a continuous query + job_id_prefix = "test_streaming_pubsub_" + sdf = session_load.read_gbq_table_streaming("birds.penguins_bigtable_streaming") + + sdf = sdf[sdf["body_mass_g"] < 4000] + sdf = sdf[["island"]] + + try: + + def counter(func): + def wrapper(*args, **kwargs): + wrapper.count += 1 # type: ignore + return func(*args, **kwargs) + + wrapper.count = 0 # type: ignore + return wrapper + + @counter + def callback(message): + message.ack() + + future = subscriber.subscribe(subscription_name, callback) + + query_job = sdf.to_pubsub( + topic=topic_id, + service_account_email="streaming-testing@bigframes-load-testing.iam.gserviceaccount.com", + job_id=None, + job_id_prefix=job_id_prefix, + start_timestamp=datetime.now() - timedelta(days=1), + ) + try: + # wait 200 seconds in order to ensure the query doesn't stop + # (i.e. it is continuous) + future.result(timeout=200) + except futures.TimeoutError: + future.cancel() + assert query_job.running() + assert query_job.error_result is None + assert str(query_job.job_id).startswith(job_id_prefix) + assert callback.count > 0 # type: ignore + finally: + query_job.cancel() diff --git a/tests/system/large/test_dataframe.py b/tests/system/large/test_dataframe.py index 20d383463a..dc7671d18a 100644 --- a/tests/system/large/test_dataframe.py +++ b/tests/system/large/test_dataframe.py @@ -9,7 +9,7 @@ # See: https://github.com/python/cpython/issues/112282 reason="setrecursionlimit has no effect on the Python C stack since Python 3.12.", ) -def test_corr_w_numeric_only(scalars_df_numeric_150_columns_maybe_ordered): +def test_corr_150_columns(scalars_df_numeric_150_columns_maybe_ordered): scalars_df, scalars_pandas_df = scalars_df_numeric_150_columns_maybe_ordered bf_result = scalars_df.corr(numeric_only=True).to_pandas() pd_result = scalars_pandas_df.corr(numeric_only=True) @@ -28,7 +28,7 @@ def test_corr_w_numeric_only(scalars_df_numeric_150_columns_maybe_ordered): # See: https://github.com/python/cpython/issues/112282 reason="setrecursionlimit has no effect on the Python C stack since Python 3.12.", ) -def test_cov_w_numeric_only(scalars_df_numeric_150_columns_maybe_ordered): +def test_cov_150_columns(scalars_df_numeric_150_columns_maybe_ordered): scalars_df, scalars_pandas_df = scalars_df_numeric_150_columns_maybe_ordered bf_result = scalars_df.cov(numeric_only=True).to_pandas() pd_result = scalars_pandas_df.cov(numeric_only=True) @@ -40,3 +40,27 @@ def test_cov_w_numeric_only(scalars_df_numeric_150_columns_maybe_ordered): check_index_type=False, check_column_type=False, ) + + +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + (False,), + ], +) +def test_drop_duplicates_unordered( + scalars_df_unordered, scalars_pandas_df_default_index, keep +): + uniq_scalar_rows = scalars_df_unordered.drop_duplicates( + subset="bool_col", keep=keep + ) + uniq_pd_rows = scalars_pandas_df_default_index.drop_duplicates( + subset="bool_col", keep=keep + ) + + assert len(uniq_scalar_rows) == len(uniq_pd_rows) + assert len(uniq_scalar_rows.groupby("bool_col")) == len( + uniq_pd_rows.groupby("bool_col") + ) diff --git a/tests/system/large/test_dataframe_io.py b/tests/system/large/test_dataframe_io.py new file mode 100644 index 0000000000..c60940109d --- /dev/null +++ b/tests/system/large/test_dataframe_io.py @@ -0,0 +1,66 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import google.api_core.exceptions +import pytest + +import bigframes + +WIKIPEDIA_TABLE = "bigquery-public-data.samples.wikipedia" +LARGE_TABLE_OPTION = "compute.allow_large_results" + + +def test_to_pandas_batches_raise_when_large_result_not_allowed(session): + with bigframes.option_context(LARGE_TABLE_OPTION, False), pytest.raises( + google.api_core.exceptions.Forbidden + ): + df = session.read_gbq(WIKIPEDIA_TABLE) + next(df.to_pandas_batches(page_size=500, max_results=1500)) + + +def test_large_df_peek_no_job(session): + execution_count_before = session._metrics.execution_count + + # only works with null index, as sequential index requires row_number over full table scan. + df = session.read_gbq( + WIKIPEDIA_TABLE, index_col=bigframes.enums.DefaultIndexKind.NULL + ) + result = df.peek(50) + execution_count_after = session._metrics.execution_count + + assert len(result) == 50 + assert execution_count_after == execution_count_before + + +def test_to_pandas_batches_override_global_option( + session, +): + with bigframes.option_context(LARGE_TABLE_OPTION, False): + df = session.read_gbq(WIKIPEDIA_TABLE) + batches = df.sort_values("id").to_pandas_batches( + page_size=500, max_results=1500, allow_large_results=True + ) + assert batches.total_rows > 0 + assert batches.total_bytes_processed > 0 + pages = list(batches) + assert all((len(page) <= 500) for page in pages) + assert sum(len(page) for page in pages) == 1500 + + +def test_to_pandas_raise_when_large_result_not_allowed(session): + with bigframes.option_context(LARGE_TABLE_OPTION, False), pytest.raises( + google.api_core.exceptions.Forbidden + ): + df = session.read_gbq(WIKIPEDIA_TABLE) + next(df.to_pandas()) diff --git a/tests/system/large/test_location.py b/tests/system/large/test_location.py index 3521e4cd20..3ebe2bb040 100644 --- a/tests/system/large/test_location.py +++ b/tests/system/large/test_location.py @@ -14,7 +14,8 @@ import typing -from google.cloud import bigquery +import pandas +import pandas.testing import pytest import bigframes @@ -38,8 +39,17 @@ def _assert_bq_execution_location( if expected_location is None: expected_location = session._location - assert typing.cast(bigquery.QueryJob, df.query_job).location == expected_location + query_job = df.query_job + assert query_job is not None + assert query_job.location == expected_location + destination = query_job.destination + assert destination is not None + destination_dataset = session.bqclient.get_dataset( + f"{destination.project}.{destination.dataset_id}" + ) + assert destination_dataset.location == expected_location + # Ensure operation involving BQ client suceeds result = ( df[["name", "number"]] .groupby("name") @@ -48,8 +58,27 @@ def _assert_bq_execution_location( .head() ) - assert ( - typing.cast(bigquery.QueryJob, result.query_job).location == expected_location + # Use allow_large_results = True to force a job to be created. + result_pd = result.to_pandas(allow_large_results=True) + + query_job = df.query_job + assert query_job is not None + assert query_job.location == expected_location + destination = query_job.destination + assert destination is not None + destination_dataset = session.bqclient.get_dataset( + f"{destination.project}.{destination.dataset_id}" + ) + assert destination_dataset.location == expected_location + + expected_result = pandas.DataFrame( + {"number": [444, 222]}, index=pandas.Index(["aaa", "bbb"], name="name") + ) + pandas.testing.assert_frame_equal( + expected_result, + result_pd, + check_dtype=False, + check_index_type=False, ) @@ -124,6 +153,8 @@ def test_bq_rep_endpoints(bigquery_location): ) ) + # Verify that location and endpoint is correctly set for the BigQuery API + # client assert session.bqclient.location == bigquery_location assert ( session.bqclient._connection.API_BASE_URL @@ -132,29 +163,52 @@ def test_bq_rep_endpoints(bigquery_location): ) ) + # Verify that endpoint is correctly set for the BigQuery Storage API client + # TODO(shobs): Figure out if we can verify that location is set in the + # BigQuery Storage API client. + assert ( + session.bqstoragereadclient.api_endpoint + == f"bigquerystorage.{bigquery_location}.rep.googleapis.com" + ) + # assert that bigframes session honors the location _assert_bq_execution_location(session) +def test_clients_provider_no_location(): + with pytest.raises(ValueError, match="Must set location to use regional endpoints"): + bigframes.session.clients.ClientsProvider(use_regional_endpoints=True) + + @pytest.mark.parametrize( "bigquery_location", # Sort the set to avoid nondeterminism. - sorted(bigframes.constants.LEP_ENABLED_BIGQUERY_LOCATIONS), + sorted(bigframes.constants.REP_NOT_ENABLED_BIGQUERY_LOCATIONS), ) -def test_bq_lep_endpoints(bigquery_location): - # We are not testing BigFrames Session for LEP endpoints because it involves - # query execution using the endpoint, which requires the project to be - # allowlisted for LEP access. We could hardcode one project which is - # allowlisted but then not every open source developer will have access to - # that. Let's rely on just creating the clients for LEP. - clients_provider = bigframes.session.clients.ClientsProvider( - location=bigquery_location, use_regional_endpoints=True - ) +def test_clients_provider_use_regional_endpoints_non_rep_locations(bigquery_location): + with pytest.raises( + ValueError, + match=f"not .*available in the location {bigquery_location}", + ): + bigframes.session.clients.ClientsProvider( + location=bigquery_location, use_regional_endpoints=True + ) - assert clients_provider.bqclient.location == bigquery_location - assert ( - clients_provider.bqclient._connection.API_BASE_URL - == "https://{location}-bigquery.googleapis.com".format( - location=bigquery_location + +@pytest.mark.parametrize( + "bigquery_location", + # Sort the set to avoid nondeterminism. + sorted(bigframes.constants.REP_NOT_ENABLED_BIGQUERY_LOCATIONS), +) +def test_session_init_fails_to_use_regional_endpoints_non_rep_endpoints( + bigquery_location, +): + with pytest.raises( + ValueError, + match=f"not .*available in the location {bigquery_location}", + ): + bigframes.Session( + context=bigframes.BigQueryOptions( + location=bigquery_location, use_regional_endpoints=True + ) ) - ) diff --git a/tests/system/large/test_session.py b/tests/system/large/test_session.py index 7f13462cbe..48c2b9e1b3 100644 --- a/tests/system/large/test_session.py +++ b/tests/system/large/test_session.py @@ -13,8 +13,12 @@ # limitations under the License. import datetime +from unittest import mock +import google.cloud.bigquery as bigquery import google.cloud.exceptions +import numpy as np +import pandas as pd import pytest import bigframes @@ -22,41 +26,39 @@ import bigframes.session._io.bigquery +@pytest.fixture +def large_pd_df(): + nrows = 1000000 + + np_int1 = np.random.randint(0, 1000, size=nrows, dtype=np.int32) + np_int2 = np.random.randint(10000, 20000, size=nrows, dtype=np.int64) + np_bool = np.random.choice([True, False], size=nrows) + np_float1 = np.random.rand(nrows).astype(np.float32) + np_float2 = np.random.normal(loc=50.0, scale=10.0, size=nrows).astype(np.float64) + + return pd.DataFrame( + { + "int_col_1": np_int1, + "int_col_2": np_int2, + "bool_col": np_bool, + "float_col_1": np_float1, + "float_col_2": np_float2, + } + ) + + @pytest.mark.parametrize( - ("query_or_table", "index_col"), + ("write_engine"), [ - pytest.param( - "bigquery-public-data.patents_view.ipcr_201708", - (), - id="1g_table_w_default_index", - ), - pytest.param( - "bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2011", - (), - id="30g_table_w_default_index", - ), - # TODO(chelsealin): Disable the long run tests until we have propertily - # ordering support to avoid materializating any data. - # # Adding default index to large tables would take much longer time, - # # e.g. ~5 mins for a 100G table, ~20 mins for a 1T table. - # pytest.param( - # "bigquery-public-data.stackoverflow.post_history", - # ["id"], - # id="100g_table_w_unique_column_index", - # ), - # pytest.param( - # "bigquery-public-data.wise_all_sky_data_release.all_wise", - # ["cntr"], - # id="1t_table_w_unique_column_index", - # ), + ("bigquery_load"), + ("bigquery_streaming"), + ("bigquery_write"), ], ) -def test_read_gbq_for_large_tables( - session: bigframes.Session, query_or_table, index_col -): - """Verify read_gbq() is able to read large tables.""" - df = session.read_gbq(query_or_table, index_col=index_col) - assert len(df.columns) != 0 +def test_read_pandas_large_df(session, large_pd_df, write_engine: str): + df = session.read_pandas(large_pd_df, write_engine=write_engine) + assert len(df.peek(5)) == 5 + assert len(large_pd_df) == 1000000 def test_close(session: bigframes.Session): @@ -70,10 +72,14 @@ def test_close(session: bigframes.Session): + bigframes.constants.DEFAULT_EXPIRATION ) full_id_1 = bigframes.session._io.bigquery.create_temp_table( - session.bqclient, session._temp_storage_manager._random_table(), expiration + session.bqclient, + session._anon_dataset_manager.allocate_temp_table(), + expiration, ) full_id_2 = bigframes.session._io.bigquery.create_temp_table( - session.bqclient, session._temp_storage_manager._random_table(), expiration + session.bqclient, + session._anon_dataset_manager.allocate_temp_table(), + expiration, ) # check that the tables were actually created @@ -106,10 +112,14 @@ def test_clean_up_by_session_id(): + bigframes.constants.DEFAULT_EXPIRATION ) bigframes.session._io.bigquery.create_temp_table( - session.bqclient, session._temp_storage_manager._random_table(), expiration + session.bqclient, + session._anon_dataset_manager.allocate_temp_table(), + expiration, ) bigframes.session._io.bigquery.create_temp_table( - session.bqclient, session._temp_storage_manager._random_table(), expiration + session.bqclient, + session._anon_dataset_manager.allocate_temp_table(), + expiration, ) # check that some table exists with the expected session_id @@ -142,21 +152,19 @@ def test_clean_up_by_session_id(): pytest.param(bigframes.connect, id="connect-method"), ], ) +@pytest.mark.flaky(retries=3) def test_clean_up_via_context_manager(session_creator): # we will create two tables and confirm that they are deleted # when the session is closed with session_creator() as session: bqclient = session.bqclient - expiration = ( - datetime.datetime.now(datetime.timezone.utc) - + bigframes.constants.DEFAULT_EXPIRATION + full_id_1 = session._anon_dataset_manager.create_temp_table( + [bigquery.SchemaField("a", "INT64")], cluster_cols=[] ) - full_id_1 = bigframes.session._io.bigquery.create_temp_table( - session.bqclient, session._temp_storage_manager._random_table(), expiration - ) - full_id_2 = bigframes.session._io.bigquery.create_temp_table( - session.bqclient, session._temp_storage_manager._random_table(), expiration + assert session._session_resource_manager is not None + full_id_2 = session._session_resource_manager.create_temp_table( + [bigquery.SchemaField("b", "STRING")], cluster_cols=["b"] ) # check that the tables were actually created @@ -168,3 +176,35 @@ def test_clean_up_via_context_manager(session_creator): bqclient.delete_table(full_id_1) with pytest.raises(google.cloud.exceptions.NotFound): bqclient.delete_table(full_id_2) + + +def test_cleanup_old_udfs(session: bigframes.Session): + routine_ref = session._anon_dataset_manager.dataset.routine("test_routine_cleanup") + + # Create a dummy function to be deleted. + create_function_sql = f""" +CREATE OR REPLACE FUNCTION `{routine_ref.project}.{routine_ref.dataset_id}.{routine_ref.routine_id}`(x INT64) +RETURNS INT64 LANGUAGE python +OPTIONS (entry_point='dummy_func', runtime_version='python-3.11') +AS r''' +def dummy_func(x): + return x + 1 +''' + """ + session.bqclient.query(create_function_sql).result() + + assert session.bqclient.get_routine(routine_ref) is not None + + mock_routine = mock.MagicMock(spec=bigquery.Routine) + mock_routine.created = datetime.datetime.now( + datetime.timezone.utc + ) - datetime.timedelta(days=100) + mock_routine.reference = routine_ref + mock_routine._properties = {"routineType": "SCALAR_FUNCTION"} + routines = [mock_routine] + + with mock.patch.object(session.bqclient, "list_routines", return_value=routines): + session._anon_dataset_manager._cleanup_old_udfs() + + with pytest.raises(google.cloud.exceptions.NotFound): + session.bqclient.get_routine(routine_ref) diff --git a/tests/system/large/test_streaming.py b/tests/system/large/test_streaming.py deleted file mode 100644 index e4992f8573..0000000000 --- a/tests/system/large/test_streaming.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import time - -import pytest - -import bigframes -import bigframes.streaming - - -@pytest.mark.flaky(retries=3, delay=10) -def test_streaming_df_to_bigtable(session_load: bigframes.Session): - # launch a continuous query - job_id_prefix = "test_streaming_" - sdf = session_load.read_gbq_table_streaming("birds.penguins_bigtable_streaming") - - sdf = sdf[["species", "island", "body_mass_g"]] - sdf = sdf[sdf["body_mass_g"] < 4000] - sdf = sdf.rename(columns={"island": "rowkey"}) - - query_job = sdf.to_bigtable( - instance="streaming-testing-instance", - table="table-testing", - service_account_email="streaming-testing@bigframes-load-testing.iam.gserviceaccount.com", - app_profile=None, - truncate=True, - overwrite=True, - auto_create_column_families=True, - bigtable_options={}, - job_id=None, - job_id_prefix=job_id_prefix, - ) - - try: - # wait 100 seconds in order to ensure the query doesn't stop - # (i.e. it is continuous) - time.sleep(100) - assert query_job.running() - assert query_job.error_result is None - assert str(query_job.job_id).startswith(job_id_prefix) - finally: - query_job.cancel() - - -@pytest.mark.flaky(retries=3, delay=10) -def test_streaming_df_to_pubsub(session_load: bigframes.Session): - # launch a continuous query - job_id_prefix = "test_streaming_pubsub_" - sdf = session_load.read_gbq_table_streaming("birds.penguins_bigtable_streaming") - - sdf = sdf[sdf["body_mass_g"] < 4000] - sdf = sdf[["island"]] - - query_job = sdf.to_pubsub( - topic="penguins", - service_account_email="streaming-testing@bigframes-load-testing.iam.gserviceaccount.com", - job_id=None, - job_id_prefix=job_id_prefix, - ) - - try: - # wait 100 seconds in order to ensure the query doesn't stop - # (i.e. it is continuous) - time.sleep(100) - assert query_job.running() - assert query_job.error_result is None - assert str(query_job.job_id).startswith(job_id_prefix) - finally: - query_job.cancel() diff --git a/tests/system/load/test_large_tables.py b/tests/system/load/test_large_tables.py index 472be3d2ad..ee49c2703e 100644 --- a/tests/system/load/test_large_tables.py +++ b/tests/system/load/test_large_tables.py @@ -75,17 +75,19 @@ def test_index_repr_large_table(): def test_to_pandas_batches_large_table(): - df = bpd.read_gbq("load_testing.scalars_1tb") + df = bpd.read_gbq("load_testing.scalars_100gb") _, expected_column_count = df.shape # download only a few batches, since 1tb would be too much - iterable = df.to_pandas_batches(page_size=500, max_results=1500) + iterable = df.to_pandas_batches( + page_size=500, max_results=1500, allow_large_results=True + ) # use page size since client library doesn't support # streaming only part of the dataframe via bqstorage for pdf in iterable: batch_row_count, batch_column_count = pdf.shape assert batch_column_count == expected_column_count - assert batch_row_count > 0 + assert 0 < batch_row_count <= 500 @pytest.mark.skip(reason="See if it caused kokoro build aborted.") diff --git a/tests/system/load/test_llm.py b/tests/system/load/test_llm.py index 45dd1667a6..9630952e67 100644 --- a/tests/system/load/test_llm.py +++ b/tests/system/load/test_llm.py @@ -16,7 +16,7 @@ import pytest from bigframes.ml import llm -from tests.system import utils +from bigframes.testing import utils @pytest.fixture(scope="session") @@ -25,7 +25,7 @@ def llm_remote_text_pandas_df(): return pd.DataFrame( { "prompt": [ - "Please do sentiment analysis on the following text and only output a number from 0 to 5where 0 means sadness, 1 means joy, 2 means love, 3 means anger, 4 means fear, and 5 means surprise. Text: i feel beautifully emotional knowing that these women of whom i knew just a handful were holding me and my baba on our journey", + "Please do sentiment analysis on the following text and only output a number from 0 to 5 where 0 means sadness, 1 means joy, 2 means love, 3 means anger, 4 means fear, and 5 means surprise. Text: i feel beautifully emotional knowing that these women of whom i knew just a handful were holding me and my baba on our journey", "Please do sentiment analysis on the following text and only output a number from 0 to 5 where 0 means sadness, 1 means joy, 2 means love, 3 means anger, 4 means fear, and 5 means surprise. Text: i was feeling a little vain when i did this one", "Please do sentiment analysis on the following text and only output a number from 0 to 5 where 0 means sadness, 1 means joy, 2 means love, 3 means anger, 4 means fear, and 5 means surprise. Text: a father of children killed in an accident", ], @@ -41,9 +41,8 @@ def llm_remote_text_df(session, llm_remote_text_pandas_df): @pytest.mark.parametrize( "model_name", ( - "gemini-pro", - "gemini-1.5-pro-002", - "gemini-1.5-flash-002", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", ), ) def test_llm_gemini_configure_fit( @@ -80,7 +79,7 @@ def test_llm_gemini_configure_fit( @pytest.mark.flaky(retries=2) def test_llm_gemini_w_ground_with_google_search(llm_remote_text_df): - model = llm.GeminiTextGenerator(model_name="gemini-pro", max_iterations=1) + model = llm.GeminiTextGenerator(model_name="gemini-2.0-flash-001", max_iterations=1) df = model.predict( llm_remote_text_df["prompt"], ground_with_google_search=True, @@ -101,7 +100,7 @@ def test_llm_gemini_w_ground_with_google_search(llm_remote_text_df): # (b/366290533): Claude models are of extremely low capacity. The tests should reside in small tests. Moving these here just to protect BQML's shared capacity(as load test only runs once per day.) and make sure we still have minimum coverage. @pytest.mark.parametrize( "model_name", - ("claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), + ("claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), ) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_create_load( @@ -126,7 +125,7 @@ def test_claude3_text_generator_create_load( @pytest.mark.parametrize( "model_name", - ("claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), + ("claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), ) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_predict_default_params_success( @@ -145,7 +144,7 @@ def test_claude3_text_generator_predict_default_params_success( @pytest.mark.parametrize( "model_name", - ("claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), + ("claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), ) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_predict_with_params_success( @@ -166,7 +165,7 @@ def test_claude3_text_generator_predict_with_params_success( @pytest.mark.parametrize( "model_name", - ("claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), + ("claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), ) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_predict_multi_col_success( diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py new file mode 100644 index 0000000000..e5af45ec2b --- /dev/null +++ b/tests/system/small/bigquery/test_ai.py @@ -0,0 +1,381 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import mock + +from packaging import version +import pandas as pd +import pyarrow as pa +import pytest +import sqlglot + +from bigframes import dataframe, dtypes, series +import bigframes.bigquery as bbq +import bigframes.pandas as bpd +from bigframes.testing import utils as test_utils + + +def test_ai_function_pandas_input(session): + s1 = pd.Series(["apple", "bear"]) + s2 = bpd.Series(["fruit", "tree"], session=session) + prompt = (s1, " is a ", s2) + + result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_function_string_input(session): + with mock.patch( + "bigframes.core.global_session.get_global_session" + ) as mock_get_session: + mock_get_session.return_value = session + prompt = "Is apple a fruit?" + + result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_function_compile_model_params(session): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this version." + ) + + s1 = bpd.Series(["apple", "bear"], session=session) + s2 = bpd.Series(["fruit", "tree"], session=session) + prompt = (s1, " is a ", s2) + model_params = {"generation_config": {"thinking_config": {"thinking_budget": 0}}} + + result = bbq.ai.generate_bool( + prompt, endpoint="gemini-2.5-flash", model_params=model_params + ) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate(session): + country = bpd.Series(["Japan", "Canada"], session=session) + prompt = ("What's the capital city of ", country, "? one word only") + + result = bbq.ai.generate(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.string()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_with_output_schema(session): + country = bpd.Series(["Japan", "Canada"], session=session) + prompt = ("Describe ", country) + + result = bbq.ai.generate( + prompt, + endpoint="gemini-2.5-flash", + output_schema={"population": "INT64", "is_in_north_america": "bool"}, + ) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("is_in_north_america", pa.bool_()), + pa.field("population", pa.int64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_with_invalid_output_schema_raise_error(session): + country = bpd.Series(["Japan", "Canada"], session=session) + prompt = ("Describe ", country) + + with pytest.raises(ValueError): + bbq.ai.generate( + prompt, + endpoint="gemini-2.5-flash", + output_schema={"population": "INT64", "is_in_north_america": "JSON"}, + ) + + +def test_ai_generate_bool(session): + s1 = bpd.Series(["apple", "bear"], session=session) + s2 = bpd.Series(["fruit", "tree"], session=session) + prompt = (s1, " is a ", s2) + + result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_bool_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + + result = bbq.ai.generate_bool((df["image"], " contains an animal")) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_int(session): + s = bpd.Series(["Cat"], session=session) + prompt = ("How many legs does a ", s, " have?") + + result = bbq.ai.generate_int(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.int64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_int_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + + result = bbq.ai.generate_int( + ("How many animals are there in the picture ", df["image"]) + ) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.int64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_double(session): + s = bpd.Series(["Cat"], session=session) + prompt = ("How many legs does a ", s, " have?") + + result = bbq.ai.generate_double(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.float64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_double_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + + result = bbq.ai.generate_double( + ("How many animals are there in the picture ", df["image"]) + ) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.float64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_if(session): + s1 = bpd.Series(["apple", "bear"], session=session) + s2 = bpd.Series(["fruit", "tree"], session=session) + prompt = (s1, " is a ", s2) + + result = bbq.ai.if_(prompt) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.BOOL_DTYPE + + +def test_ai_if_multi_model(session, bq_connection): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", + name="image", + connection=bq_connection, + ) + + result = bbq.ai.if_((df["image"], " contains an animal")) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.BOOL_DTYPE + + +def test_ai_classify(session): + s = bpd.Series(["cat", "orchid"], session=session) + + result = bbq.ai.classify(s, ["animal", "plant"]) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.STRING_DTYPE + + +def test_ai_classify_multi_model(session, bq_connection): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", + name="image", + connection=bq_connection, + ) + + result = bbq.ai.classify(df["image"], ["photo", "cartoon"]) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.STRING_DTYPE + + +def test_ai_score(session): + s = bpd.Series(["Tiger", "Rabbit"], session=session) + prompt = ("Rank the relative weights of ", s, " on the scale from 1 to 3") + + result = bbq.ai.score(prompt) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.FLOAT_DTYPE + + +def test_ai_score_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + prompt = ("Rank the liveliness of ", df["image"], "on the scale from 1 to 3") + + result = bbq.ai.score(prompt) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.FLOAT_DTYPE + + +def test_forecast_default_params(time_series_df_default_index: dataframe.DataFrame): + df = time_series_df_default_index[time_series_df_default_index["id"] == "1"] + + result = bbq.ai.forecast(df, timestamp_col="parsed_date", data_col="total_visits") + + expected_columns = [ + "forecast_timestamp", + "forecast_value", + "confidence_level", + "prediction_interval_lower_bound", + "prediction_interval_upper_bound", + "ai_forecast_status", + ] + test_utils.check_pandas_df_schema_and_index( + result, + columns=expected_columns, + index=10, + ) + + +def test_forecast_w_params(time_series_df_default_index: dataframe.DataFrame): + result = bbq.ai.forecast( + time_series_df_default_index, + timestamp_col="parsed_date", + data_col="total_visits", + id_cols=["id"], + horizon=20, + confidence_level=0.98, + context_window=64, + ) + + expected_columns = [ + "id", + "forecast_timestamp", + "forecast_value", + "confidence_level", + "prediction_interval_lower_bound", + "prediction_interval_upper_bound", + "ai_forecast_status", + ] + test_utils.check_pandas_df_schema_and_index( + result, + columns=expected_columns, + index=20 * 2, # 20 for each id + ) + + +def _contains_no_nulls(s: series.Series) -> bool: + return len(s) == s.count() diff --git a/tests/system/small/bigquery/test_array.py b/tests/system/small/bigquery/test_array.py index d6823a3a54..2ceb90e22c 100644 --- a/tests/system/small/bigquery/test_array.py +++ b/tests/system/small/bigquery/test_array.py @@ -17,17 +17,61 @@ import pytest import bigframes.bigquery as bbq +import bigframes.dtypes import bigframes.pandas as bpd -def test_array_length(): - series = bpd.Series([["A", "AA", "AAA"], ["BB", "B"], np.nan, [], ["C"]]) - # TODO(b/336880368): Allow for NULL values to be input for ARRAY columns. - # Once we actually store NULL values, this will be NULL where the input is NULL. - expected = bpd.Series([3, 2, 0, 0, 1]) +@pytest.mark.parametrize( + ["input_data", "expected"], + [ + pytest.param( + [["A", "AA", "AAA"], ["BB", "B"], np.nan, [], ["C"]], + [ + 3, + 2, + # TODO(b/336880368): Allow for NULL values to be input for ARRAY + # columns. Once we actually store NULL values, this will be + # NULL where the input is NULL. + 0, + 0, + 1, + ], + id="small-string", + ), + pytest.param( + [[1, 2, 3], [4, 5], [], [], [6]], [3, 2, 0, 0, 1], id="small-int64" + ), + pytest.param( + [ + # Regression test for b/414374215 where the Series constructor + # returns empty lists when the lists are too big to embed in + # SQL. + list(np.random.randint(-1_000_000, 1_000_000, size=1000)), + list(np.random.randint(-1_000_000, 1_000_000, size=967)), + list(np.random.randint(-1_000_000, 1_000_000, size=423)), + list(np.random.randint(-1_000_000, 1_000_000, size=5000)), + list(np.random.randint(-1_000_000, 1_000_000, size=1003)), + list(np.random.randint(-1_000_000, 1_000_000, size=9999)), + ], + [ + 1000, + 967, + 423, + 5000, + 1003, + 9999, + ], + id="larger-int64", + ), + ], +) +def test_array_length(input_data, expected): + series = bpd.Series(input_data) + expected = pd.Series(expected, dtype=bigframes.dtypes.INT_DTYPE) pd.testing.assert_series_equal( bbq.array_length(series).to_pandas(), - expected.to_pandas(), + expected, + check_index_type=False, ) diff --git a/tests/system/small/bigquery/test_datetime.py b/tests/system/small/bigquery/test_datetime.py index b839031263..dc68e7b892 100644 --- a/tests/system/small/bigquery/test_datetime.py +++ b/tests/system/small/bigquery/test_datetime.py @@ -15,10 +15,20 @@ import typing import pandas as pd +import pyarrow as pa import pytest from bigframes import bigquery +_TIMESTAMP_DTYPE = pd.ArrowDtype(pa.timestamp("us", tz="UTC")) + + +@pytest.fixture +def int_series(session): + pd_series = pd.Series([1, 2, 3, 4, 5]) + + return session.read_pandas(pd_series), pd_series + def test_unix_seconds(scalars_dfs): bigframes_df, pandas_df = scalars_dfs @@ -33,6 +43,19 @@ def test_unix_seconds(scalars_dfs): pd.testing.assert_series_equal(actual_res, expected_res) +def test_unix_seconds_after_type_casting(int_series): + bf_series, pd_series = int_series + + actual_res = bigquery.unix_seconds(bf_series.astype(_TIMESTAMP_DTYPE)).to_pandas() + + expected_res = ( + pd_series.astype(_TIMESTAMP_DTYPE) + .apply(lambda ts: _to_unix_epoch(ts, "s")) + .astype("Int64") + ) + pd.testing.assert_series_equal(actual_res, expected_res, check_index_type=False) + + def test_unix_seconds_incorrect_input_type_raise_error(scalars_dfs): df, _ = scalars_dfs @@ -53,6 +76,19 @@ def test_unix_millis(scalars_dfs): pd.testing.assert_series_equal(actual_res, expected_res) +def test_unix_millis_after_type_casting(int_series): + bf_series, pd_series = int_series + + actual_res = bigquery.unix_millis(bf_series.astype(_TIMESTAMP_DTYPE)).to_pandas() + + expected_res = ( + pd_series.astype(_TIMESTAMP_DTYPE) + .apply(lambda ts: _to_unix_epoch(ts, "ms")) + .astype("Int64") + ) + pd.testing.assert_series_equal(actual_res, expected_res, check_index_type=False) + + def test_unix_millis_incorrect_input_type_raise_error(scalars_dfs): df, _ = scalars_dfs @@ -73,6 +109,19 @@ def test_unix_micros(scalars_dfs): pd.testing.assert_series_equal(actual_res, expected_res) +def test_unix_micros_after_type_casting(int_series): + bf_series, pd_series = int_series + + actual_res = bigquery.unix_micros(bf_series.astype(_TIMESTAMP_DTYPE)).to_pandas() + + expected_res = ( + pd_series.astype(_TIMESTAMP_DTYPE) + .apply(lambda ts: _to_unix_epoch(ts, "us")) + .astype("Int64") + ) + pd.testing.assert_series_equal(actual_res, expected_res, check_index_type=False) + + def test_unix_micros_incorrect_input_type_raise_error(scalars_dfs): df, _ = scalars_dfs diff --git a/tests/system/small/bigquery/test_geo.py b/tests/system/small/bigquery/test_geo.py index 7d38cd7d91..28db58c711 100644 --- a/tests/system/small/bigquery/test_geo.py +++ b/tests/system/small/bigquery/test_geo.py @@ -12,16 +12,29 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import geopandas # type: ignore import pandas as pd -from shapely.geometry import LineString, Point, Polygon # type: ignore +import pandas.testing +import pytest +from shapely.geometry import ( # type: ignore + GeometryCollection, + LineString, + MultiLineString, + MultiPoint, + MultiPolygon, + Point, + Polygon, +) +from bigframes.bigquery import st_length import bigframes.bigquery as bbq import bigframes.geopandas -import bigframes.series +import bigframes.session -def test_geo_st_area(): +def test_geo_st_area(session: bigframes.session.Session): data = [ Polygon([(0.000, 0.0), (0.001, 0.001), (0.000, 0.001)]), Polygon([(0.0010, 0.004), (0.009, 0.005), (0.0010, 0.005)]), @@ -31,7 +44,7 @@ def test_geo_st_area(): ] geopd_s = geopandas.GeoSeries(data=data, crs="EPSG:4326") - geobf_s = bigframes.geopandas.GeoSeries(data=data) + geobf_s = bigframes.geopandas.GeoSeries(data=data, session=session) # For `geopd_s`, the data was further projected with `geopandas.GeoSeries.to_crs` # to `to_crs(26393)` to get the area in square meter. See: https://geopandas.org/en/stable/docs/user_guide/projections.html @@ -49,5 +62,430 @@ def test_geo_st_area(): check_dtype=False, check_index_type=False, check_exact=False, - rtol=1, + rtol=0.1, + ) + + +# Expected length for 1 degree of longitude at the equator is approx 111195.079734 meters +DEG_LNG_EQUATOR_METERS = 111195.07973400292 + + +def test_st_length_various_geometries(session): + input_geometries = [ + Point(0, 0), + LineString([(0, 0), (1, 0)]), + Polygon([(0, 0), (1, 0), (0, 1), (0, 0)]), + MultiPoint([Point(0, 0), Point(1, 1)]), + MultiLineString([LineString([(0, 0), (1, 0)]), LineString([(0, 0), (0, 1)])]), + MultiPolygon( + [ + Polygon([(0, 0), (1, 0), (0, 1), (0, 0)]), + Polygon([(2, 2), (3, 2), (2, 3), (2, 2)]), + ] + ), + GeometryCollection([Point(0, 0), LineString([(0, 0), (1, 0)])]), + GeometryCollection([]), + None, # Represents NULL geography input + GeometryCollection([Point(1, 1), Point(2, 2)]), + ] + geoseries = bigframes.geopandas.GeoSeries(input_geometries, session=session) + + expected_lengths = pd.Series( + [ + 0.0, # Point + DEG_LNG_EQUATOR_METERS, # LineString + 0.0, # Polygon + 0.0, # MultiPoint + 2 * DEG_LNG_EQUATOR_METERS, # MultiLineString + 0.0, # MultiPolygon + DEG_LNG_EQUATOR_METERS, # GeometryCollection (Point + LineString) + 0.0, # Empty GeometryCollection + pd.NA, # None input for ST_LENGTH(NULL) is NULL + 0.0, # GeometryCollection (Point + Point) + ], + index=pd.Index(range(10), dtype="Int64"), + dtype="Float64", + ) + + # Test default use_spheroid + result_default = st_length(geoseries).to_pandas() + pd.testing.assert_series_equal( + result_default, + expected_lengths, + rtol=1e-3, + atol=1e-3, # For comparisons involving 0.0 + ) # type: ignore + + # Test explicit use_spheroid=False + result_explicit_false = st_length(geoseries, use_spheroid=False).to_pandas() + pd.testing.assert_series_equal( + result_explicit_false, + expected_lengths, + rtol=1e-3, + atol=1e-3, # For comparisons involving 0.0 + ) # type: ignore + + +def test_geo_st_difference_with_geometry_objects(session: bigframes.session.Session): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + Point(0, 1), + ] + + data2 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + LineString([(2, 0), (0, 2)]), + ] + + geobf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + geobf_s2 = bigframes.geopandas.GeoSeries(data=data2, session=session) + geobf_s_result = bbq.st_difference(geobf_s1, geobf_s2).to_pandas() + + expected = pd.Series( + [ + GeometryCollection([]), + GeometryCollection([]), + Point(0, 1), + ], + index=[0, 1, 2], + dtype=geopandas.array.GeometryDtype(), + ) + pandas.testing.assert_series_equal( + geobf_s_result, + expected, + check_index_type=False, + check_exact=False, + rtol=0.1, + ) + + +def test_geo_st_difference_with_single_geometry_object( + session: bigframes.session.Session, +): + pytest.importorskip( + "shapely", + minversion="2.0.0", + reason="shapely objects must be hashable to include in our expression trees", + ) + + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]), + Polygon([(0, 1), (10, 1), (10, 9), (0, 9), (0, 1)]), + Point(0, 1), + ] + + geobf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + geobf_s_result = bbq.st_difference( + geobf_s1, + Polygon([(0, 0), (10, 0), (10, 5), (0, 5), (0, 0)]), + ).to_pandas() + + expected = pd.Series( + [ + Polygon([(10, 5), (10, 10), (0, 10), (0, 5), (10, 5)]), + Polygon([(10, 5), (10, 9), (0, 9), (0, 5), (10, 5)]), + GeometryCollection([]), + ], + index=[0, 1, 2], + dtype=geopandas.array.GeometryDtype(), + ) + pandas.testing.assert_series_equal( + geobf_s_result, + expected, + check_index_type=False, + check_exact=False, + rtol=0.1, + ) + + +def test_geo_st_difference_with_similar_geometry_objects( + session: bigframes.session.Session, +): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ] + + geobf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + geobf_s_result = bbq.st_difference(geobf_s1, geobf_s1).to_pandas() + + expected = pd.Series( + [GeometryCollection([]), GeometryCollection([]), GeometryCollection([])], + index=[0, 1, 2], + dtype=geopandas.array.GeometryDtype(), + ) + pandas.testing.assert_series_equal( + geobf_s_result, + expected, + check_index_type=False, + check_exact=False, + rtol=0.1, + ) + + +def test_geo_st_distance_with_geometry_objects(session: bigframes.session.Session): + data1 = [ + # 0.00001 is approximately 1 meter. + Polygon([(0, 0), (0.00001, 0), (0.00001, 0.00001), (0, 0.00001), (0, 0)]), + Polygon( + [ + (0.00002, 0), + (0.00003, 0), + (0.00003, 0.00001), + (0.00002, 0.00001), + (0.00002, 0), + ] + ), + Point(0, 0.00002), + ] + + data2 = [ + Polygon( + [ + (0.00002, 0), + (0.00003, 0), + (0.00003, 0.00001), + (0.00002, 0.00001), + (0.00002, 0), + ] + ), + Point(0, 0.00002), + Polygon([(0, 0), (0.00001, 0), (0.00001, 0.00001), (0, 0.00001), (0, 0)]), + Point( + 1, 1 + ), # No matching row in data1, so this will be NULL after the call to distance. + ] + + geobf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + geobf_s2 = bigframes.geopandas.GeoSeries(data=data2, session=session) + geobf_s_result = bbq.st_distance(geobf_s1, geobf_s2).to_pandas() + + expected = pd.Series( + [ + 1.112, + 2.486, + 1.112, + None, + ], + index=[0, 1, 2, 3], + dtype="Float64", + ) + pandas.testing.assert_series_equal( + geobf_s_result, + expected, + check_index_type=False, + check_exact=False, + rtol=0.1, + ) + + +def test_geo_st_distance_with_single_geometry_object( + session: bigframes.session.Session, +): + pytest.importorskip( + "shapely", + minversion="2.0.0", + reason="shapely objects must be hashable to include in our expression trees", + ) + + data1 = [ + # 0.00001 is approximately 1 meter. + Polygon([(0, 0), (0.00001, 0), (0.00001, 0.00001), (0, 0.00001), (0, 0)]), + Polygon( + [ + (0.00001, 0), + (0.00002, 0), + (0.00002, 0.00001), + (0.00001, 0.00001), + (0.00001, 0), + ] + ), + Point(0, 0.00002), + ] + + geobf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + geobf_s_result = bbq.st_distance( + geobf_s1, + Point(0, 0), + ).to_pandas() + + expected = pd.Series( + [ + 0, + 1.112, + 2.224, + ], + dtype="Float64", + ) + pandas.testing.assert_series_equal( + geobf_s_result, + expected, + check_index_type=False, + check_exact=False, + rtol=0.1, + ) + + +def test_geo_st_intersection_with_geometry_objects(session: bigframes.session.Session): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + Point(0, 1), + ] + + data2 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + LineString([(2, 0), (0, 2)]), + ] + + geobf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + geobf_s2 = bigframes.geopandas.GeoSeries(data=data2, session=session) + geobf_s_result = bbq.st_intersection(geobf_s1, geobf_s2).to_pandas() + + expected = pd.Series( + [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + GeometryCollection([]), + ], + index=[0, 1, 2], + dtype=geopandas.array.GeometryDtype(), + ) + pandas.testing.assert_series_equal( + geobf_s_result, + expected, + check_index_type=False, + check_exact=False, + rtol=0.1, + ) + + +def test_geo_st_intersection_with_single_geometry_object( + session: bigframes.session.Session, +): + pytest.importorskip( + "shapely", + minversion="2.0.0", + reason="shapely objects must be hashable to include in our expression trees", + ) + + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]), + Polygon([(0, 1), (10, 1), (10, 9), (0, 9), (0, 1)]), + Point(0, 1), + ] + + geobf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + geobf_s_result = bbq.st_intersection( + geobf_s1, + Polygon([(0, 0), (10, 0), (10, 5), (0, 5), (0, 0)]), + ).to_pandas() + + expected = pd.Series( + [ + Polygon([(0, 0), (10, 0), (10, 5), (0, 5), (0, 0)]), + Polygon([(0, 1), (10, 1), (10, 5), (0, 5), (0, 1)]), + Point(0, 1), + ], + index=[0, 1, 2], + dtype=geopandas.array.GeometryDtype(), + ) + pandas.testing.assert_series_equal( + geobf_s_result, + expected, + check_index_type=False, + check_exact=False, + rtol=0.1, + ) + + +def test_geo_st_intersection_with_similar_geometry_objects( + session: bigframes.session.Session, +): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ] + + geobf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + geobf_s_result = bbq.st_intersection(geobf_s1, geobf_s1).to_pandas() + + expected = pd.Series( + [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ], + index=[0, 1, 2], + dtype=geopandas.array.GeometryDtype(), + ) + pandas.testing.assert_series_equal( + geobf_s_result, + expected, + check_index_type=False, + check_exact=False, + rtol=0.1, + ) + + +def test_geo_st_isclosed(session: bigframes.session.Session): + bf_gs = bigframes.geopandas.GeoSeries( + [ + Point(0, 0), # Point + LineString([(0, 0), (1, 1)]), # Open LineString + LineString([(0, 0), (1, 1), (0, 1), (0, 0)]), # Closed LineString + Polygon([(0, 0), (1, 1), (0, 1)]), # Open polygon + GeometryCollection(), # Empty GeometryCollection + bigframes.geopandas.GeoSeries.from_wkt( + ["GEOMETRYCOLLECTION EMPTY"], session=session + ).iloc[ + 0 + ], # Also empty + None, # Should be filtered out by dropna + ], + index=[0, 1, 2, 3, 4, 5, 6], + session=session, + ) + bf_result = bbq.st_isclosed(bf_gs).to_pandas() + + # Expected results based on ST_ISCLOSED documentation: + expected_data = [ + True, # Point: True + False, # Open LineString: False + True, # Closed LineString: True + False, # Polygon: False (only True if it's a full polygon) + False, # Empty GeometryCollection: False (An empty GEOGRAPHY isn't closed) + False, # GEOMETRYCOLLECTION EMPTY: False + None, + ] + expected_series = pd.Series(data=expected_data, dtype="boolean") + + pd.testing.assert_series_equal( + bf_result, + expected_series, + # We default to Int64 (nullable) dtype, but pandas defaults to int64 index. + check_index_type=False, + ) + + +def test_st_buffer(session): + geoseries = bigframes.geopandas.GeoSeries( + [Point(0, 0), LineString([(1, 1), (2, 2)])], session=session + ) + result = bbq.st_buffer(geoseries, 1000).to_pandas() + assert result.iloc[0].geom_type == "Polygon" + assert result.iloc[1].geom_type == "Polygon" + + +def test_st_simplify(session): + geoseries = bigframes.geopandas.GeoSeries( + [LineString([(0, 0), (1, 1), (2, 0)])], session=session ) + result = bbq.st_simplify(geoseries, 100000).to_pandas() + assert len(result.index) == 1 + assert result.isna().sum() == 0 diff --git a/tests/system/small/bigquery/test_json.py b/tests/system/small/bigquery/test_json.py index 492c0cf9b6..d2ebb73972 100644 --- a/tests/system/small/bigquery/test_json.py +++ b/tests/system/small/bigquery/test_json.py @@ -12,80 +12,68 @@ # See the License for the specific language governing permissions and # limitations under the License. -import db_dtypes # type: ignore import geopandas as gpd # type: ignore import pandas as pd import pyarrow as pa import pytest import bigframes.bigquery as bbq -import bigframes.dtypes +import bigframes.dtypes as dtypes import bigframes.pandas as bpd @pytest.mark.parametrize( ("json_path", "expected_json"), [ - pytest.param("$.a", [{"a": 10}], id="simple"), - pytest.param("$.a.b.c", [{"a": {"b": {"c": 10, "d": []}}}], id="nested"), + pytest.param("$.a", ['{"a": 10}'], id="simple"), + pytest.param("$.a.b.c", ['{"a": {"b": {"c": 10, "d": []}}}'], id="nested"), ], ) def test_json_set_at_json_path(json_path, expected_json): - original_json = [{"a": {"b": {"c": "tester", "d": []}}}] - s = bpd.Series(original_json, dtype=db_dtypes.JSONDtype()) + original_json = ['{"a": {"b": {"c": "tester", "d": []}}}'] + s = bpd.Series(original_json, dtype=dtypes.JSON_DTYPE) + actual = bbq.json_set(s, json_path_value_pairs=[(json_path, 10)]) + expected = bpd.Series(expected_json, dtype=dtypes.JSON_DTYPE) - expected = bpd.Series(expected_json, dtype=db_dtypes.JSONDtype()) - pd.testing.assert_series_equal( - actual.to_pandas(), - expected.to_pandas(), - ) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) @pytest.mark.parametrize( ("json_value", "expected_json"), [ - pytest.param(10, [{"a": {"b": 10}}, {"a": {"b": 10}}], id="int"), - pytest.param(0.333, [{"a": {"b": 0.333}}, {"a": {"b": 0.333}}], id="float"), - pytest.param("eng", [{"a": {"b": "eng"}}, {"a": {"b": "eng"}}], id="string"), - pytest.param([1, 2], [{"a": {"b": 1}}, {"a": {"b": 2}}], id="series"), + pytest.param(10, ['{"a": {"b": 10}}', '{"a": {"b": 10}}'], id="int"), + pytest.param(0.333, ['{"a": {"b": 0.333}}', '{"a": {"b": 0.333}}'], id="float"), + pytest.param( + "eng", ['{"a": {"b": "eng"}}', '{"a": {"b": "eng"}}'], id="string" + ), + pytest.param([1, 2], ['{"a": {"b": 1}}', '{"a": {"b": 2}}'], id="series"), ], ) def test_json_set_at_json_value_type(json_value, expected_json): - original_json = [{"a": {"b": "dev"}}, {"a": {"b": [1, 2]}}] - s = bpd.Series(original_json, dtype=db_dtypes.JSONDtype()) + original_json = ['{"a": {"b": "dev"}}', '{"a": {"b": [1, 2]}}'] + s = bpd.Series(original_json, dtype=dtypes.JSON_DTYPE) actual = bbq.json_set(s, json_path_value_pairs=[("$.a.b", json_value)]) + expected = bpd.Series(expected_json, dtype=dtypes.JSON_DTYPE) - expected = bpd.Series(expected_json, dtype=db_dtypes.JSONDtype()) - pd.testing.assert_series_equal( - actual.to_pandas(), - expected.to_pandas(), - ) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) def test_json_set_w_more_pairs(): - original_json = [{"a": 2}, {"b": 5}, {"c": 1}] - s = bpd.Series(original_json, dtype=db_dtypes.JSONDtype()) + original_json = ['{"a": 2}', '{"b": 5}', '{"c": 1}'] + s = bpd.Series(original_json, dtype=dtypes.JSON_DTYPE) actual = bbq.json_set( s, json_path_value_pairs=[("$.a", 1), ("$.b", 2), ("$.a", [3, 4, 5])] ) - expected_json = [{"a": 3, "b": 2}, {"a": 4, "b": 2}, {"a": 5, "b": 2, "c": 1}] - expected = bpd.Series(expected_json, dtype=db_dtypes.JSONDtype()) - pd.testing.assert_series_equal( - actual.to_pandas(), - expected.to_pandas(), - ) - + expected_json = ['{"a": 3,"b":2}', '{"a":4,"b": 2}', '{"a": 5,"b":2,"c":1}'] + expected = bpd.Series(expected_json, dtype=dtypes.JSON_DTYPE) -def test_json_set_w_invalid_json_path_value_pairs(): - s = bpd.Series([{"a": 10}], dtype=db_dtypes.JSONDtype()) - with pytest.raises(ValueError): - bbq.json_set(s, json_path_value_pairs=[("$.a", 1, 100)]) # type: ignore + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) def test_json_set_w_invalid_value_type(): - s = bpd.Series([{"a": 10}], dtype=db_dtypes.JSONDtype()) + s = bpd.Series(['{"a": 10}'], dtype=dtypes.JSON_DTYPE) with pytest.raises(TypeError): bbq.json_set( s, @@ -101,21 +89,21 @@ def test_json_set_w_invalid_value_type(): def test_json_set_w_invalid_series_type(): + s = bpd.Series([1, 2]) with pytest.raises(TypeError): - bbq.json_set(bpd.Series([1, 2]), json_path_value_pairs=[("$.a", 1)]) + bbq.json_set(s, json_path_value_pairs=[("$.a", 1)]) def test_json_extract_from_json(): s = bpd.Series( - [{"a": {"b": [1, 2]}}, {"a": {"c": 1}}, {"a": {"b": 0}}], - dtype=db_dtypes.JSONDtype(), - ) - actual = bbq.json_extract(s, "$.a.b").to_pandas() - expected = bpd.Series([[1, 2], None, 0], dtype=db_dtypes.JSONDtype()).to_pandas() - pd.testing.assert_series_equal( - actual, - expected, + ['{"a": {"b": [1, 2]}}', '{"a": {"c": 1}}', '{"a": {"b": 0}}'], + dtype=dtypes.JSON_DTYPE, ) + with pytest.warns(UserWarning, match="The `json_extract` is deprecated"): + actual = bbq.json_extract(s, "$.a.b") + expected = bpd.Series(["[1, 2]", None, "0"], dtype=dtypes.JSON_DTYPE) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) def test_json_extract_from_string(): @@ -125,23 +113,23 @@ def test_json_extract_from_string(): ) actual = bbq.json_extract(s, "$.a.b") expected = bpd.Series(["[1,2]", None, "0"], dtype=pd.StringDtype(storage="pyarrow")) - pd.testing.assert_series_equal( - actual.to_pandas(), - expected.to_pandas(), - ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) def test_json_extract_w_invalid_series_type(): + s = bpd.Series([1, 2]) with pytest.raises(TypeError): - bbq.json_extract(bpd.Series([1, 2]), "$.a") + bbq.json_extract(s, "$.a") def test_json_extract_array_from_json(): s = bpd.Series( - [{"a": ["ab", "2", "3 xy"]}, {"a": []}, {"a": ["4", "5"]}, {}], - dtype=db_dtypes.JSONDtype(), + ['{"a": ["ab", "2", "3 xy"]}', '{"a": []}', '{"a": ["4", "5"]}', "{}"], + dtype=dtypes.JSON_DTYPE, ) - actual = bbq.json_extract_array(s, "$.a") + with pytest.warns(UserWarning, match="The `json_extract_array` is deprecated"): + actual = bbq.json_extract_array(s, "$.a") # This code provides a workaround for issue https://github.com/apache/arrow/issues/45262, # which currently prevents constructing a series using the pa.list_(db_types.JSONArrrowType()) @@ -159,10 +147,7 @@ def test_json_extract_array_from_json(): expected.index.name = None expected.name = None - pd.testing.assert_series_equal( - actual.to_pandas(), - expected.to_pandas(), - ) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) def test_json_extract_array_from_json_strings(): @@ -175,10 +160,8 @@ def test_json_extract_array_from_json_strings(): [['"ab"', '"2"', '"3 xy"'], [], ['"4"', '"5"'], None], dtype=pd.ArrowDtype(pa.list_(pa.string())), ) - pd.testing.assert_series_equal( - actual.to_pandas(), - expected.to_pandas(), - ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) def test_json_extract_array_from_json_array_strings(): @@ -191,10 +174,8 @@ def test_json_extract_array_from_json_array_strings(): [["1", "2", "3"], [], ["4", "5"]], dtype=pd.ArrowDtype(pa.list_(pa.string())), ) - pd.testing.assert_series_equal( - actual.to_pandas(), - expected.to_pandas(), - ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) def test_json_extract_array_w_invalid_series_type(): @@ -205,39 +186,301 @@ def test_json_extract_array_w_invalid_series_type(): def test_json_extract_string_array_from_json_strings(): s = bpd.Series(['{"a": ["ab", "2", "3 xy"]}', '{"a": []}', '{"a": ["4","5"]}']) - actual = bbq.json_extract_string_array(s, "$.a") + with pytest.warns( + UserWarning, match="The `json_extract_string_array` is deprecated" + ): + actual = bbq.json_extract_string_array(s, "$.a") expected = bpd.Series([["ab", "2", "3 xy"], [], ["4", "5"]]) - pd.testing.assert_series_equal( - actual.to_pandas(), - expected.to_pandas(), - ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) def test_json_extract_string_array_from_array_strings(): s = bpd.Series(["[1, 2, 3]", "[]", "[4,5]"]) actual = bbq.json_extract_string_array(s) expected = bpd.Series([["1", "2", "3"], [], ["4", "5"]]) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_extract_string_array_as_float_array_from_array_strings(): + s = bpd.Series(["[1, 2.5, 3]", "[]", "[4,5]"]) + actual = bbq.json_extract_string_array(s, value_dtype=dtypes.FLOAT_DTYPE) + expected = bpd.Series([[1, 2.5, 3], [], [4, 5]]) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_extract_string_array_w_invalid_series_type(): + s = bpd.Series([1, 2]) + with pytest.raises(TypeError): + bbq.json_extract_string_array(s) + + +def test_json_value_array_from_json_strings(): + s = bpd.Series(['{"a": ["ab", "2", "3 xy"]}', '{"a": []}', '{"a": ["4","5"]}']) + actual = bbq.json_value_array(s, "$.a") + expected_data = [["ab", "2", "3 xy"], [], ["4", "5"]] + # Expected dtype after JSON_VALUE_ARRAY is ARRAY + expected = bpd.Series(expected_data, dtype=pd.ArrowDtype(pa.list_(pa.string()))) pd.testing.assert_series_equal( actual.to_pandas(), expected.to_pandas(), ) -def test_json_extract_string_array_as_float_array_from_array_strings(): - s = bpd.Series(["[1, 2.5, 3]", "[]", "[4,5]"]) - actual = bbq.json_extract_string_array(s, value_dtype=bigframes.dtypes.FLOAT_DTYPE) - expected = bpd.Series([[1, 2.5, 3], [], [4, 5]]) +def test_json_value_array_from_array_strings(): + s = bpd.Series(["[1, 2, 3]", "[]", "[4,5]"]) + actual = bbq.json_value_array(s) + expected_data = [["1", "2", "3"], [], ["4", "5"]] + expected = bpd.Series(expected_data, dtype=pd.ArrowDtype(pa.list_(pa.string()))) pd.testing.assert_series_equal( actual.to_pandas(), expected.to_pandas(), ) -def test_json_extract_string_array_w_invalid_series_type(): +def test_json_value_array_w_invalid_series_type(): + s = bpd.Series([1, 2], dtype=dtypes.INT_DTYPE) # Not a JSON-like string + with pytest.raises(TypeError): + bbq.json_value_array(s) + + +def test_json_value_array_from_json_native(): + json_data = [ + '{"key": ["hello", "world"]}', + '{"key": ["123", "45.6"]}', + '{"key": []}', + "{}", # case with missing key + ] + s = bpd.Series(json_data, dtype=dtypes.JSON_DTYPE) + actual = bbq.json_value_array(s, json_path="$.key") + + expected_data_pandas = [["hello", "world"], ["123", "45.6"], [], None] + expected = bpd.Series( + expected_data_pandas, dtype=pd.ArrowDtype(pa.list_(pa.string())) + ).fillna(pd.NA) + result_pd = actual.to_pandas().fillna(pd.NA) + pd.testing.assert_series_equal(result_pd, expected.to_pandas()) + + +def test_json_query_from_json(): + s = bpd.Series( + ['{"a": {"b": [1, 2]}}', '{"a": {"c": 1}}', '{"a": {"b": 0}}'], + dtype=dtypes.JSON_DTYPE, + ) + actual = bbq.json_query(s, "$.a.b") + expected = bpd.Series(["[1, 2]", None, "0"], dtype=dtypes.JSON_DTYPE) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_query_from_string(): + s = bpd.Series( + ['{"a": {"b": [1, 2]}}', '{"a": {"c": 1}}', '{"a": {"b": 0}}'], + dtype=pd.StringDtype(storage="pyarrow"), + ) + actual = bbq.json_query(s, "$.a.b") + expected = bpd.Series(["[1,2]", None, "0"], dtype=pd.StringDtype(storage="pyarrow")) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_query_w_invalid_series_type(): + s = bpd.Series([1, 2]) + with pytest.raises(TypeError): + bbq.json_query(s, "$.a") + + +def test_json_query_array_from_json(): + s = bpd.Series( + ['{"a": ["ab", "2", "3 xy"]}', '{"a": []}', '{"a": ["4", "5"]}', "{}"], + dtype=dtypes.JSON_DTYPE, + ) + actual = bbq.json_query_array(s, "$.a") + + # This code provides a workaround for issue https://github.com/apache/arrow/issues/45262, + # which currently prevents constructing a series using the pa.list_(db_types.JSONArrrowType()) + sql = """ + SELECT 0 AS id, [JSON '"ab"', JSON '"2"', JSON '"3 xy"'] AS data, + UNION ALL + SELECT 1, [], + UNION ALL + SELECT 2, [JSON '"4"', JSON '"5"'], + UNION ALL + SELECT 3, null, + """ + df = bpd.read_gbq(sql).set_index("id").sort_index() + expected = df["data"] + expected.index.name = None + expected.name = None + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_query_array_from_json_strings(): + s = bpd.Series( + ['{"a": ["ab", "2", "3 xy"]}', '{"a": []}', '{"a": ["4","5"]}', "{}"], + dtype=pd.StringDtype(storage="pyarrow"), + ) + actual = bbq.json_query_array(s, "$.a") + expected = bpd.Series( + [['"ab"', '"2"', '"3 xy"'], [], ['"4"', '"5"'], None], + dtype=pd.ArrowDtype(pa.list_(pa.string())), + ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_query_array_from_json_array_strings(): + s = bpd.Series( + ["[1, 2, 3]", "[]", "[4,5]"], + dtype=pd.StringDtype(storage="pyarrow"), + ) + actual = bbq.json_query_array(s) + expected = bpd.Series( + [["1", "2", "3"], [], ["4", "5"]], + dtype=pd.ArrowDtype(pa.list_(pa.string())), + ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_query_array_w_invalid_series_type(): + s = bpd.Series([1, 2]) with pytest.raises(TypeError): - bbq.json_extract_string_array(bpd.Series([1, 2])) + bbq.json_query_array(s) + + +def test_json_value_from_json(): + s = bpd.Series( + ['{"a": {"b": [1, 2]}}', '{"a": {"c": 1}}', '{"a": {"b": 0}}'], + dtype=dtypes.JSON_DTYPE, + ) + actual = bbq.json_value(s, "$.a.b") + expected = bpd.Series([None, None, "0"], dtype=dtypes.STRING_DTYPE) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_value_from_string(): + s = bpd.Series( + ['{"a": {"b": [1, 2]}}', '{"a": {"c": 1}}', '{"a": {"b": 0}}'], + dtype=pd.StringDtype(storage="pyarrow"), + ) + actual = bbq.json_value(s, "$.a.b") + expected = bpd.Series([None, None, "0"], dtype=dtypes.STRING_DTYPE) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_value_w_invalid_series_type(): + s = bpd.Series([1, 2]) + with pytest.raises(TypeError): + bbq.json_value(s, "$.a") def test_parse_json_w_invalid_series_type(): + s = bpd.Series([1, 2]) + with pytest.raises(TypeError): + bbq.parse_json(s) + + +def test_to_json_from_int(): + s = bpd.Series([1, 2, None, 3]) + actual = bbq.to_json(s) + expected = bpd.Series(["1.0", "2.0", "null", "3.0"], dtype=dtypes.JSON_DTYPE) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_to_json_from_struct(): + s = bpd.Series( + [ + {"version": 1, "project": "pandas"}, + {"version": 2, "project": "numpy"}, + ] + ) + assert dtypes.is_struct_like(s.dtype) + + actual = bbq.to_json(s) + expected = bpd.Series( + ['{"project":"pandas","version":1}', '{"project":"numpy","version":2}'], + dtype=dtypes.JSON_DTYPE, + ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_to_json_string_from_int(): + s = bpd.Series([1, 2, None, 3]) + actual = bbq.to_json_string(s) + expected = bpd.Series(["1", "2", "null", "3"], dtype=dtypes.STRING_DTYPE) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_to_json_string_from_struct(): + s = bpd.Series( + [ + {"version": 1, "project": "pandas"}, + {"version": 2, "project": "numpy"}, + ] + ) + assert dtypes.is_struct_like(s.dtype) + + actual = bbq.to_json_string(s) + expected = bpd.Series( + ['{"project":"pandas","version":1}', '{"project":"numpy","version":2}'], + dtype=dtypes.STRING_DTYPE, + ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_keys(): + json_data = [ + '{"name": "Alice", "age": 30}', + '{"city": "New York", "country": "USA", "active": true}', + "{}", + '{"items": [1, 2, 3]}', + ] + s = bpd.Series(json_data, dtype=dtypes.JSON_DTYPE) + actual = bbq.json_keys(s) + + expected_data_pandas = [ + ["age", "name"], + [ + "active", + "city", + "country", + ], + [], + ["items"], + ] + expected = bpd.Series( + expected_data_pandas, dtype=pd.ArrowDtype(pa.list_(pa.string())) + ) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_keys_with_max_depth(): + json_data = [ + '{"user": {"name": "Bob", "details": {"id": 123, "status": "approved"}}}', + '{"user": {"name": "Charlie"}}', + ] + s = bpd.Series(json_data, dtype=dtypes.JSON_DTYPE) + actual = bbq.json_keys(s, max_depth=2) + + expected_data_pandas = [ + ["user", "user.details", "user.name"], + ["user", "user.name"], + ] + expected = bpd.Series( + expected_data_pandas, dtype=pd.ArrowDtype(pa.list_(pa.string())) + ) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_keys_from_string_error(): + s = bpd.Series(['{"a": 1, "b": 2}', '{"c": 3}']) with pytest.raises(TypeError): - bbq.parse_json(bpd.Series([1, 2])) + bbq.json_keys(s) diff --git a/tests/system/small/bigquery/test_sql.py b/tests/system/small/bigquery/test_sql.py index 283624100a..c519b427fa 100644 --- a/tests/system/small/bigquery/test_sql.py +++ b/tests/system/small/bigquery/test_sql.py @@ -12,11 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -import bigframes.bigquery +import pandas as pd +import pytest +import bigframes.bigquery as bbq +import bigframes.dtypes as dtypes +import bigframes.pandas as bpd -def test_sql_scalar_on_scalars_null_index(scalars_df_null_index): - series = bigframes.bigquery.sql_scalar( + +def test_sql_scalar_for_all_scalar_types(scalars_df_null_index): + series = bbq.sql_scalar( """ CAST({0} AS INT64) + BYTE_LENGTH({1}) @@ -48,3 +53,109 @@ def test_sql_scalar_on_scalars_null_index(scalars_df_null_index): ) result = series.to_pandas() assert len(result) == len(scalars_df_null_index) + + +def test_sql_scalar_for_bool_series(scalars_df_index): + series: bpd.Series = scalars_df_index["bool_col"] + result = bbq.sql_scalar("CAST({0} AS INT64)", [series]) + expected = series.astype(dtypes.INT_DTYPE) + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) + + +@pytest.mark.parametrize( + ("column_name"), + [ + pytest.param("bool_col"), + pytest.param("bytes_col"), + pytest.param("date_col"), + pytest.param("datetime_col"), + pytest.param("geography_col"), + pytest.param("int64_col"), + pytest.param("numeric_col"), + pytest.param("float64_col"), + pytest.param("string_col"), + pytest.param("time_col"), + pytest.param("timestamp_col"), + ], +) +def test_sql_scalar_outputs_all_scalar_types(scalars_df_index, column_name): + series: bpd.Series = scalars_df_index[column_name] + result = bbq.sql_scalar("{0}", [series]) + expected = series + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) + + +def test_sql_scalar_for_array_series(repeated_df): + result = bbq.sql_scalar( + """ + ARRAY_LENGTH({0}) + ARRAY_LENGTH({1}) + ARRAY_LENGTH({2}) + + ARRAY_LENGTH({3}) + ARRAY_LENGTH({4}) + ARRAY_LENGTH({5}) + + ARRAY_LENGTH({6}) + """, + [ + repeated_df["int_list_col"], + repeated_df["bool_list_col"], + repeated_df["float_list_col"], + repeated_df["date_list_col"], + repeated_df["date_time_list_col"], + repeated_df["numeric_list_col"], + repeated_df["string_list_col"], + ], + ) + + expected = ( + repeated_df["int_list_col"].list.len() + + repeated_df["bool_list_col"].list.len() + + repeated_df["float_list_col"].list.len() + + repeated_df["date_list_col"].list.len() + + repeated_df["date_time_list_col"].list.len() + + repeated_df["numeric_list_col"].list.len() + + repeated_df["string_list_col"].list.len() + ) + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) + + +def test_sql_scalar_outputs_array_series(repeated_df): + result = bbq.sql_scalar("{0}", [repeated_df["int_list_col"]]) + expected = repeated_df["int_list_col"] + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) + + +def test_sql_scalar_for_struct_series(nested_structs_df): + result = bbq.sql_scalar( + "CHAR_LENGTH({0}.name) + {0}.age", + [nested_structs_df["person"]], + ) + expected = nested_structs_df["person"].struct.field( + "name" + ).str.len() + nested_structs_df["person"].struct.field("age") + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) + + +def test_sql_scalar_outputs_struct_series(nested_structs_df): + result = bbq.sql_scalar("{0}", [nested_structs_df["person"]]) + expected = nested_structs_df["person"] + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) + + +def test_sql_scalar_for_json_series(json_df): + result = bbq.sql_scalar( + """JSON_VALUE({0}, '$.int_value')""", + [ + json_df["json_col"], + ], + ) + expected = bbq.json_value(json_df["json_col"], "$.int_value") + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) + + +def test_sql_scalar_outputs_json_series(json_df): + result = bbq.sql_scalar("{0}", [json_df["json_col"]]) + expected = json_df["json_col"] + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) diff --git a/tests/system/small/bigquery/test_vector_search.py b/tests/system/small/bigquery/test_vector_search.py index 6297d729ea..ff320731e2 100644 --- a/tests/system/small/bigquery/test_vector_search.py +++ b/tests/system/small/bigquery/test_vector_search.py @@ -23,7 +23,7 @@ import bigframes.bigquery as bbq import bigframes.pandas as bpd -from tests.system.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal # Need at least 5,000 rows to create a vector index. VECTOR_DF = pd.DataFrame( @@ -123,12 +123,17 @@ def test_vector_search_basic_params_with_df(): "embedding": [[1.0, 2.0], [3.0, 5.2]], } ) - vector_search_result = bbq.vector_search( - base_table="bigframes-dev.bigframes_tests_sys.base_table", - column_to_search="my_embedding", - query=search_query, - top_k=2, - ).to_pandas() # type:ignore + vector_search_result = ( + bbq.vector_search( + base_table="bigframes-dev.bigframes_tests_sys.base_table", + column_to_search="my_embedding", + query=search_query, + top_k=2, + ) + .sort_values("distance") + .sort_index() + .to_pandas() + ) # type:ignore expected = pd.DataFrame( { "query_id": ["cat", "dog", "dog", "cat"], @@ -149,7 +154,7 @@ def test_vector_search_basic_params_with_df(): }, index=pd.Index([1, 0, 0, 1], dtype="Int64"), ) - assert_pandas_df_equal( + assert_frame_equal( expected.sort_values("id"), vector_search_result.sort_values("id"), check_dtype=False, @@ -157,80 +162,60 @@ def test_vector_search_basic_params_with_df(): ) -def test_vector_search_different_params_with_query(): - search_query = bpd.Series([[1.0, 2.0], [3.0, 5.2]]) - vector_search_result = bbq.vector_search( - base_table="bigframes-dev.bigframes_tests_sys.base_table", - column_to_search="my_embedding", - query=search_query, - distance_type="cosine", - top_k=2, - ).to_pandas() # type:ignore - expected = pd.DataFrame( +def test_vector_search_different_params_with_query(session): + base_df = bpd.DataFrame( { - "0": [ - np.array([1.0, 2.0]), - np.array([1.0, 2.0]), - np.array([3.0, 5.2]), - np.array([3.0, 5.2]), - ], - "id": [2, 1, 1, 2], + "id": [1, 2, 3, 4], "my_embedding": [ - np.array([2.0, 4.0]), - np.array([1.0, 2.0]), - np.array([1.0, 2.0]), - np.array([2.0, 4.0]), + np.array([0.0, 1.0]), + np.array([1.0, 0.0]), + np.array([0.0, -1.0]), + np.array([-1.0, 0.0]), ], - "distance": [0.0, 0.0, 0.001777, 0.001777], }, - index=pd.Index([0, 0, 1, 1], dtype="Int64"), - ) - pd.testing.assert_frame_equal( - vector_search_result, expected, check_dtype=False, rtol=0.1 - ) - - -def test_vector_search_df_with_query_column_to_search(): - search_query = bpd.DataFrame( - { - "query_id": ["dog", "cat"], - "embedding": [[1.0, 2.0], [3.0, 5.2]], - "another_embedding": [[1.0, 2.5], [3.3, 5.2]], - } - ) - vector_search_result = bbq.vector_search( - base_table="bigframes-dev.bigframes_tests_sys.base_table", - column_to_search="my_embedding", - query=search_query, - query_column_to_search="another_embedding", - top_k=2, - ).to_pandas() # type:ignore - expected = pd.DataFrame( - { - "query_id": ["dog", "dog", "cat", "cat"], - "embedding": [ - np.array([1.0, 2.0]), - np.array([1.0, 2.0]), - np.array([3.0, 5.2]), - np.array([3.0, 5.2]), - ], - "another_embedding": [ - np.array([1.0, 2.5]), - np.array([1.0, 2.5]), - np.array([3.3, 5.2]), - np.array([3.3, 5.2]), - ], - "id": [1, 4, 2, 5], - "my_embedding": [ - np.array([1.0, 2.0]), - np.array([1.0, 3.2]), - np.array([2.0, 4.0]), - np.array([5.0, 5.4]), - ], - "distance": [0.5, 0.7, 1.769181, 1.711724], - }, - index=pd.Index([0, 0, 1, 1], dtype="Int64"), - ) - pd.testing.assert_frame_equal( - vector_search_result, expected, check_dtype=False, rtol=0.1 + session=session, ) + base_table = base_df.to_gbq() + try: + search_query = bpd.Series([[0.75, 0.25], [-0.25, -0.75]], session=session) + vector_search_result = ( + bbq.vector_search( + base_table=base_table, + column_to_search="my_embedding", + query=search_query, + distance_type="cosine", + top_k=2, + ) + .sort_values("distance") + .sort_index() + .to_pandas() + ) # type:ignore + expected = pd.DataFrame( + { + "0": [ + [0.75, 0.25], + [0.75, 0.25], + [-0.25, -0.75], + [-0.25, -0.75], + ], + "id": [2, 1, 3, 4], + "my_embedding": [ + [1.0, 0.0], + [0.0, 1.0], + [0.0, -1.0], + [-1.0, 0.0], + ], + "distance": [ + 0.051317, + 0.683772, + 0.051317, + 0.683772, + ], + }, + index=pd.Index([0, 0, 1, 1], dtype="Int64"), + ) + pd.testing.assert_frame_equal( + vector_search_result, expected, check_dtype=False, rtol=0.1 + ) + finally: + session.bqclient.delete_table(base_table, not_found_ok=True) diff --git a/tests/system/small/blob/test_io.py b/tests/system/small/blob/test_io.py index 8ecb36ecc9..5ada4fabb0 100644 --- a/tests/system/small/blob/test_io.py +++ b/tests/system/small/blob/test_io.py @@ -12,27 +12,25 @@ # See the License for the specific language governing permissions and # limitations under the License. +from unittest import mock + +import IPython.display import pandas as pd import bigframes import bigframes.pandas as bpd -def test_blob_create_from_uri_str(bq_connection: str, session: bigframes.Session): - bigframes.options.experiments.blob = True - - uris = [ - "gs://bigframes_blob_test/images/img0.jpg", - "gs://bigframes_blob_test/images/img1.jpg", - ] - - uri_series = bpd.Series(uris, session=session) +def test_blob_create_from_uri_str( + bq_connection: str, session: bigframes.Session, images_uris +): + uri_series = bpd.Series(images_uris, session=session) blob_series = uri_series.str.to_blob(connection=bq_connection) pd_blob_df = blob_series.struct.explode().to_pandas() expected_pd_df = pd.DataFrame( { - "uri": uris, + "uri": images_uris, "version": [None, None], "authorizer": [bq_connection.casefold(), bq_connection.casefold()], "details": [None, None], @@ -44,19 +42,50 @@ def test_blob_create_from_uri_str(bq_connection: str, session: bigframes.Session ) -def test_blob_create_from_glob_path(bq_connection: str, session: bigframes.Session): - bigframes.options.experiments.blob = True - +def test_blob_create_from_glob_path( + bq_connection: str, session: bigframes.Session, images_gcs_path, images_uris +): blob_df = session.from_glob_path( - "gs://bigframes_blob_test/images/*", connection=bq_connection, name="blob_col" + images_gcs_path, connection=bq_connection, name="blob_col" + ) + pd_blob_df = ( + blob_df["blob_col"] + .struct.explode() + .to_pandas() + .sort_values("uri") + .reset_index(drop=True) + ) + + expected_df = pd.DataFrame( + { + "uri": images_uris, + "version": [None, None], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "details": [None, None], + } + ) + + pd.testing.assert_frame_equal( + pd_blob_df, expected_df, check_dtype=False, check_index_type=False + ) + + +def test_blob_create_read_gbq_object_table( + bq_connection: str, session: bigframes.Session, images_gcs_path, images_uris +): + obj_table = session._create_object_table(images_gcs_path, bq_connection) + + blob_df = session.read_gbq_object_table(obj_table, name="blob_col") + pd_blob_df = ( + blob_df["blob_col"] + .struct.explode() + .to_pandas() + .sort_values("uri") + .reset_index(drop=True) ) - pd_blob_df = blob_df["blob_col"].struct.explode().to_pandas() expected_df = pd.DataFrame( { - "uri": [ - "gs://bigframes_blob_test/images/img0.jpg", - "gs://bigframes_blob_test/images/img1.jpg", - ], + "uri": images_uris, "version": [None, None], "authorizer": [bq_connection.casefold(), bq_connection.casefold()], "details": [None, None], @@ -66,3 +95,33 @@ def test_blob_create_from_glob_path(bq_connection: str, session: bigframes.Sessi pd.testing.assert_frame_equal( pd_blob_df, expected_df, check_dtype=False, check_index_type=False ) + + +def test_display_images(monkeypatch, images_mm_df: bpd.DataFrame): + mock_display = mock.Mock() + monkeypatch.setattr(IPython.display, "display", mock_display) + + images_mm_df["blob_col"].blob.display() + + for call in mock_display.call_args_list: + args, _ = call + arg = args[0] + assert isinstance(arg, IPython.display.Image) + + +def test_display_nulls( + monkeypatch, + bq_connection: str, + session: bigframes.Session, +): + uri_series = bpd.Series([None, None, None], dtype="string", session=session) + blob_series = uri_series.str.to_blob(connection=bq_connection) + mock_display = mock.Mock() + monkeypatch.setattr(IPython.display, "display", mock_display) + + blob_series.blob.display() + + for call in mock_display.call_args_list: + args, _ = call + arg = args[0] + assert arg == "" diff --git a/tests/system/small/blob/test_properties.py b/tests/system/small/blob/test_properties.py new file mode 100644 index 0000000000..47d4d2aa04 --- /dev/null +++ b/tests/system/small/blob/test_properties.py @@ -0,0 +1,116 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd + +import bigframes.dtypes as dtypes +import bigframes.pandas as bpd + + +def test_blob_uri(images_uris: list[str], images_mm_df: bpd.DataFrame): + actual = images_mm_df["blob_col"].blob.uri().to_pandas() + expected = pd.Series(images_uris, name="uri") + + pd.testing.assert_series_equal( + actual, expected, check_dtype=False, check_index_type=False + ) + + +def test_blob_authorizer(images_mm_df: bpd.DataFrame, bq_connection: str): + actual = images_mm_df["blob_col"].blob.authorizer().to_pandas() + expected = pd.Series( + [bq_connection.casefold(), bq_connection.casefold()], name="authorizer" + ) + + pd.testing.assert_series_equal( + actual, expected, check_dtype=False, check_index_type=False + ) + + +def test_blob_version(images_mm_df: bpd.DataFrame): + actual = images_mm_df["blob_col"].blob.version().to_pandas() + expected = pd.Series(["1753907851152593", "1753907851111538"], name="version") + + pd.testing.assert_series_equal( + actual, expected, check_dtype=False, check_index_type=False + ) + + +def test_blob_metadata(images_mm_df: bpd.DataFrame): + actual = images_mm_df["blob_col"].blob.metadata().to_pandas() + expected = pd.Series( + [ + ( + '{"content_type":"image/jpeg",' + '"md5_hash":"e130ad042261a1883cd2cc06831cf748",' + '"size":338390,' + '"updated":1753907851000000}' + ), + ( + '{"content_type":"image/jpeg",' + '"md5_hash":"e2ae3191ff2b809fd0935f01a537c650",' + '"size":43333,' + '"updated":1753907851000000}' + ), + ], + name="metadata", + dtype=dtypes.JSON_DTYPE, + ) + expected.index = expected.index.astype(dtypes.INT_DTYPE) + pd.testing.assert_series_equal(actual, expected) + + +def test_blob_content_type(images_mm_df: bpd.DataFrame): + actual = images_mm_df["blob_col"].blob.content_type().to_pandas() + expected = pd.Series(["image/jpeg", "image/jpeg"], name="content_type") + + pd.testing.assert_series_equal( + actual, expected, check_dtype=False, check_index_type=False + ) + + +def test_blob_md5_hash(images_mm_df: bpd.DataFrame): + actual = images_mm_df["blob_col"].blob.md5_hash().to_pandas() + expected = pd.Series( + ["e130ad042261a1883cd2cc06831cf748", "e2ae3191ff2b809fd0935f01a537c650"], + name="md5_hash", + ) + + pd.testing.assert_series_equal( + actual, expected, check_dtype=False, check_index_type=False + ) + + +def test_blob_size(images_mm_df: bpd.DataFrame): + actual = images_mm_df["blob_col"].blob.size().to_pandas() + expected = pd.Series([338390, 43333], name="size") + + pd.testing.assert_series_equal( + actual, expected, check_dtype=False, check_index_type=False + ) + + +def test_blob_updated(images_mm_df: bpd.DataFrame): + actual = images_mm_df["blob_col"].blob.updated().to_pandas() + expected = pd.Series( + [ + pd.Timestamp("2025-07-30 20:37:31", tz="UTC"), + pd.Timestamp("2025-07-30 20:37:31", tz="UTC"), + ], + name="updated", + ) + + pd.testing.assert_series_equal( + actual, expected, check_dtype=False, check_index_type=False + ) diff --git a/tests/system/small/blob/test_urls.py b/tests/system/small/blob/test_urls.py new file mode 100644 index 0000000000..02a76587f5 --- /dev/null +++ b/tests/system/small/blob/test_urls.py @@ -0,0 +1,27 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import bigframes.pandas as bpd + + +def test_blob_read_url(images_mm_df: bpd.DataFrame): + urls = images_mm_df["blob_col"].blob.read_url() + + assert urls.str.startswith("https://storage.googleapis.com/").all() + + +def test_blob_write_url(images_mm_df: bpd.DataFrame): + urls = images_mm_df["blob_col"].blob.write_url() + + assert urls.str.startswith("https://storage.googleapis.com/").all() diff --git a/tests/system/small/core/indexes/__init__.py b/tests/system/small/core/indexes/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/tests/system/small/core/indexes/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/system/small/core/indexes/test_base.py b/tests/system/small/core/indexes/test_base.py new file mode 100644 index 0000000000..05ea40cfb9 --- /dev/null +++ b/tests/system/small/core/indexes/test_base.py @@ -0,0 +1,35 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from packaging import version +import pandas as pd +import pandas.testing +import pytest + + +@pytest.mark.parametrize("level", [None, 0, 1, "level0", "level1"]) +def test_unique(session, level): + if version.Version(pd.__version__) < version.Version("2.0.0"): + pytest.skip("StringDtype for multi-index not supported until Pandas 2.0") + arrays = [ + pd.Series(["A", "A", "B", "B", "A"], dtype=pd.StringDtype(storage="pyarrow")), + pd.Series([1, 2, 1, 2, 1], dtype=pd.Int64Dtype()), + ] + pd_idx = pd.MultiIndex.from_arrays(arrays, names=["level0", "level1"]) + bf_idx = session.read_pandas(pd_idx) + + actual_result = bf_idx.unique(level).to_pandas() + + expected_result = pd_idx.unique(level) + pandas.testing.assert_index_equal(actual_result, expected_result) diff --git a/tests/system/small/core/indexes/test_datetimes.py b/tests/system/small/core/indexes/test_datetimes.py new file mode 100644 index 0000000000..40ce310b31 --- /dev/null +++ b/tests/system/small/core/indexes/test_datetimes.py @@ -0,0 +1,46 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import pandas +import pandas.testing +import pytest + + +@pytest.fixture(scope="module") +def datetime_indexes(session): + pd_index = pandas.date_range("2024-12-25", periods=10, freq="d") + bf_index = session.read_pandas(pd_index) + + return bf_index, pd_index + + +@pytest.mark.parametrize( + "access", + [ + pytest.param(lambda x: x.year, id="year"), + pytest.param(lambda x: x.month, id="month"), + pytest.param(lambda x: x.day, id="day"), + pytest.param(lambda x: x.dayofweek, id="dayofweek"), + pytest.param(lambda x: x.day_of_week, id="day_of_week"), + pytest.param(lambda x: x.weekday, id="weekday"), + ], +) +def test_datetime_index_properties(datetime_indexes, access): + bf_index, pd_index = datetime_indexes + + actual_result = access(bf_index).to_pandas() + + expected_result = access(pd_index).astype(pandas.Int64Dtype()) + pandas.testing.assert_index_equal(actual_result, expected_result) diff --git a/tests/system/small/core/test_convert.py b/tests/system/small/core/test_convert.py index 3f74d17091..7ce0dd47ba 100644 --- a/tests/system/small/core/test_convert.py +++ b/tests/system/small/core/test_convert.py @@ -56,4 +56,7 @@ def test_to_bf_dataframe(input, session): def test_to_bf_dataframe_with_bf_dataframe(session): bf = dataframe.DataFrame({"test": [1, 2, 3]}, session=session) - assert convert.to_bf_dataframe(bf, None, session) is bf + testing.assert_frame_equal( + convert.to_bf_dataframe(bf, None, session).to_pandas(), + bf.to_pandas(), + ) diff --git a/tests/system/small/core/test_reshape.py b/tests/system/small/core/test_reshape.py new file mode 100644 index 0000000000..0850bf50bb --- /dev/null +++ b/tests/system/small/core/test_reshape.py @@ -0,0 +1,120 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pandas.testing +import pytest + +from bigframes import session +from bigframes.core.reshape import merge + + +@pytest.mark.parametrize( + ("left_on", "right_on", "left_index", "right_index"), + [ + ("col_a", None, False, True), + (None, "col_d", True, False), + (None, None, True, True), + ], +) +@pytest.mark.parametrize("how", ["inner", "left", "right", "outer"]) +def test_join_with_index( + session: session.Session, left_on, right_on, left_index, right_index, how +): + df1 = pd.DataFrame({"col_a": [1, 2, 3], "col_b": [2, 3, 4]}, index=[1, 2, 3]) + bf1 = session.read_pandas(df1) + df2 = pd.DataFrame({"col_c": [1, 2, 3], "col_d": [2, 3, 4]}, index=[2, 3, 4]) + bf2 = session.read_pandas(df2) + + bf_result = merge.merge( + bf1, + bf2, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, + how=how, + ).to_pandas() + pd_result = pd.merge( + df1, + df2, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, + how=how, + ) + + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +@pytest.mark.parametrize( + ("on", "left_on", "right_on", "left_index", "right_index"), + [ + (None, "col_a", None, True, False), + (None, None, "col_c", None, True), + ("col_a", None, None, True, True), + ], +) +def test_join_with_index_invalid_index_arg_raise_error( + session: session.Session, on, left_on, right_on, left_index, right_index +): + df1 = pd.DataFrame({"col_a": [1, 2, 3], "col_b": [2, 3, 4]}, index=[1, 2, 3]) + bf1 = session.read_pandas(df1) + df2 = pd.DataFrame({"col_c": [1, 2, 3], "col_d": [2, 3, 4]}, index=[2, 3, 4]) + bf2 = session.read_pandas(df2) + + with pytest.raises(ValueError): + merge.merge( + bf1, + bf2, + on=on, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, + ).to_pandas() + + +@pytest.mark.parametrize( + ("left_on", "right_on", "left_index", "right_index"), + [ + (["col_a", "col_b"], None, False, True), + (None, ["col_c", "col_d"], True, False), + (None, None, True, True), + ], +) +@pytest.mark.parametrize("how", ["inner", "left", "right", "outer"]) +def test_join_with_multiindex_raises_error( + session: session.Session, left_on, right_on, left_index, right_index, how +): + multi_idx1 = pd.MultiIndex.from_tuples([(1, 2), (2, 3), (3, 5)]) + df1 = pd.DataFrame({"col_a": [1, 2, 3], "col_b": [2, 3, 4]}, index=multi_idx1) + bf1 = session.read_pandas(df1) + multi_idx2 = pd.MultiIndex.from_tuples([(1, 2), (2, 3), (3, 2)]) + df2 = pd.DataFrame({"col_c": [1, 2, 3], "col_d": [2, 3, 4]}, index=multi_idx2) + bf2 = session.read_pandas(df2) + + with pytest.raises(ValueError): + merge.merge( + bf1, + bf2, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, + how=how, + ) diff --git a/tests/system/small/engines/__init__.py b/tests/system/small/engines/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/tests/system/small/engines/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/system/small/engines/conftest.py b/tests/system/small/engines/conftest.py new file mode 100644 index 0000000000..a775731cde --- /dev/null +++ b/tests/system/small/engines/conftest.py @@ -0,0 +1,102 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pathlib +from typing import Generator + +from google.cloud import bigquery +import pandas as pd +import pytest + +import bigframes +from bigframes.core import ArrayValue, events, local_data +from bigframes.session import ( + direct_gbq_execution, + local_scan_executor, + polars_executor, + semi_executor, +) + +CURRENT_DIR = pathlib.Path(__file__).parent +DATA_DIR = CURRENT_DIR.parent.parent.parent / "data" + + +@pytest.fixture(scope="module") +def fake_session() -> Generator[bigframes.Session, None, None]: + import bigframes.core.global_session + + # its a "polars session", but we are bypassing session-provided execution + # we just want a minimal placeholder session without expensive setup + from bigframes.testing import polars_session + + session = polars_session.TestSession() + with bigframes.core.global_session._GlobalSessionContext(session): + yield session + + +@pytest.fixture(scope="session", params=["pyarrow", "polars", "bq", "bq-sqlglot"]) +def engine(request, bigquery_client: bigquery.Client) -> semi_executor.SemiExecutor: + if request.param == "pyarrow": + return local_scan_executor.LocalScanExecutor() + if request.param == "polars": + return polars_executor.PolarsExecutor() + publisher = events.Publisher() + if request.param == "bq": + return direct_gbq_execution.DirectGbqExecutor( + bigquery_client, publisher=publisher + ) + if request.param == "bq-sqlglot": + return direct_gbq_execution.DirectGbqExecutor( + bigquery_client, compiler="sqlglot", publisher=publisher + ) + raise ValueError(f"Unrecognized param: {request.param}") + + +@pytest.fixture(scope="module") +def managed_data_source( + scalars_pandas_df_index: pd.DataFrame, +) -> local_data.ManagedArrowTable: + return local_data.ManagedArrowTable.from_pandas(scalars_pandas_df_index) + + +@pytest.fixture(scope="module") +def scalars_array_value( + managed_data_source: local_data.ManagedArrowTable, fake_session: bigframes.Session +): + return ArrayValue.from_managed(managed_data_source, fake_session) + + +@pytest.fixture(scope="module") +def zero_row_source() -> local_data.ManagedArrowTable: + return local_data.ManagedArrowTable.from_pandas(pd.DataFrame({"a": [], "b": []})) + + +@pytest.fixture(scope="module") +def nested_data_source( + nested_pandas_df: pd.DataFrame, +) -> local_data.ManagedArrowTable: + return local_data.ManagedArrowTable.from_pandas(nested_pandas_df) + + +@pytest.fixture(scope="module") +def repeated_data_source( + repeated_pandas_df: pd.DataFrame, +) -> local_data.ManagedArrowTable: + return local_data.ManagedArrowTable.from_pandas(repeated_pandas_df) + + +@pytest.fixture(scope="module") +def arrays_array_value( + repeated_data_source: local_data.ManagedArrowTable, fake_session: bigframes.Session +): + return ArrayValue.from_managed(repeated_data_source, fake_session) diff --git a/tests/system/small/engines/test_aggregation.py b/tests/system/small/engines/test_aggregation.py new file mode 100644 index 0000000000..4ed826d2ae --- /dev/null +++ b/tests/system/small/engines/test_aggregation.py @@ -0,0 +1,177 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.cloud import bigquery +import pytest + +from bigframes.core import ( + agg_expressions, + array_value, + events, + expression, + identifiers, + nodes, +) +import bigframes.operations.aggregations as agg_ops +from bigframes.session import direct_gbq_execution, polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +def apply_agg_to_all_valid( + array: array_value.ArrayValue, op: agg_ops.UnaryAggregateOp, excluded_cols=[] +) -> array_value.ArrayValue: + """ + Apply the aggregation to every column in the array that has a compatible datatype. + """ + exprs_by_name = [] + for arg in array.column_ids: + if arg in excluded_cols: + continue + try: + _ = op.output_type(array.get_column_type(arg)) + expr = agg_expressions.UnaryAggregation(op, expression.deref(arg)) + name = f"{arg}-{op.name}" + exprs_by_name.append((expr, name)) + except TypeError: + continue + assert len(exprs_by_name) > 0 + new_arr = array.aggregate(exprs_by_name) + return new_arr + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_aggregate_post_filter_size( + scalars_array_value: array_value.ArrayValue, + engine, +): + w_offsets, offsets_id = ( + scalars_array_value.select_columns(("bool_col", "string_col")) + .filter(expression.deref("bool_col")) + .promote_offsets() + ) + plan = ( + w_offsets.select_columns((offsets_id, "bool_col", "string_col")) + .row_count() + .node + ) + + assert_equivalence_execution(plan, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_aggregate_size( + scalars_array_value: array_value.ArrayValue, + engine, +): + node = nodes.AggregateNode( + scalars_array_value.node, + aggregations=( + ( + agg_expressions.NullaryAggregation(agg_ops.SizeOp()), + identifiers.ColumnId("size_op"), + ), + ( + agg_expressions.UnaryAggregation( + agg_ops.SizeUnaryOp(), expression.deref("string_col") + ), + identifiers.ColumnId("unary_size_op"), + ), + ), + ) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize( + "op", + [agg_ops.min_op, agg_ops.max_op, agg_ops.mean_op, agg_ops.sum_op, agg_ops.count_op], +) +def test_engines_unary_aggregates( + scalars_array_value: array_value.ArrayValue, + engine, + op, +): + node = apply_agg_to_all_valid(scalars_array_value, op).node + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize( + "op", + [agg_ops.std_op, agg_ops.var_op, agg_ops.PopVarOp()], +) +def test_engines_unary_variance_aggregates( + scalars_array_value: array_value.ArrayValue, + engine, + op, +): + node = apply_agg_to_all_valid(scalars_array_value, op).node + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +def test_sql_engines_median_op_aggregates( + scalars_array_value: array_value.ArrayValue, + bigquery_client: bigquery.Client, +): + node = apply_agg_to_all_valid( + scalars_array_value, + agg_ops.MedianOp(), + ).node + publisher = events.Publisher() + left_engine = direct_gbq_execution.DirectGbqExecutor( + bigquery_client, publisher=publisher + ) + right_engine = direct_gbq_execution.DirectGbqExecutor( + bigquery_client, compiler="sqlglot", publisher=publisher + ) + assert_equivalence_execution(node, left_engine, right_engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize( + "grouping_cols", + [ + ["bool_col"], + ["string_col", "int64_col"], + ["date_col"], + ["datetime_col"], + ["timestamp_col"], + ["bytes_col"], + ], +) +def test_engines_grouped_aggregate( + scalars_array_value: array_value.ArrayValue, engine, grouping_cols +): + node = nodes.AggregateNode( + scalars_array_value.node, + aggregations=( + ( + agg_expressions.NullaryAggregation(agg_ops.SizeOp()), + identifiers.ColumnId("size_op"), + ), + ( + agg_expressions.UnaryAggregation( + agg_ops.SizeUnaryOp(), expression.deref("string_col") + ), + identifiers.ColumnId("unary_size_op"), + ), + ), + by_column_ids=tuple(expression.deref(id) for id in grouping_cols), + ) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_array_ops.py b/tests/system/small/engines/test_array_ops.py new file mode 100644 index 0000000000..3b80cb8854 --- /dev/null +++ b/tests/system/small/engines/test_array_ops.py @@ -0,0 +1,60 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value, expression +import bigframes.operations as ops +import bigframes.operations.aggregations as agg_ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_to_array_op(scalars_array_value: array_value.ArrayValue, engine): + # Bigquery won't allow you to materialize arrays with null, so use non-nullable + int64_non_null = ops.coalesce_op.as_expr("int64_col", expression.const(0)) + bool_col_non_null = ops.coalesce_op.as_expr("bool_col", expression.const(False)) + float_col_non_null = ops.coalesce_op.as_expr("float64_col", expression.const(0.0)) + string_col_non_null = ops.coalesce_op.as_expr("string_col", expression.const("")) + + arr, _ = scalars_array_value.compute_values( + [ + ops.ToArrayOp().as_expr(int64_non_null), + ops.ToArrayOp().as_expr( + int64_non_null, bool_col_non_null, float_col_non_null + ), + ops.ToArrayOp().as_expr(string_col_non_null, string_col_non_null), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_array_reduce_op(arrays_array_value: array_value.ArrayValue, engine): + arr, _ = arrays_array_value.compute_values( + [ + ops.ArrayReduceOp(agg_ops.SumOp()).as_expr("float_list_col"), + ops.ArrayReduceOp(agg_ops.StdOp()).as_expr("float_list_col"), + ops.ArrayReduceOp(agg_ops.MaxOp()).as_expr("date_list_col"), + ops.ArrayReduceOp(agg_ops.CountOp()).as_expr("string_list_col"), + ops.ArrayReduceOp(agg_ops.AnyOp()).as_expr("bool_list_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_bool_ops.py b/tests/system/small/engines/test_bool_ops.py new file mode 100644 index 0000000000..a77d52b356 --- /dev/null +++ b/tests/system/small/engines/test_bool_ops.py @@ -0,0 +1,64 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import itertools + +import pytest + +from bigframes.core import array_value +import bigframes.operations as ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +def apply_op_pairwise( + array: array_value.ArrayValue, op: ops.BinaryOp, excluded_cols=[] +) -> array_value.ArrayValue: + exprs = [] + for l_arg, r_arg in itertools.permutations(array.column_ids, 2): + if (l_arg in excluded_cols) or (r_arg in excluded_cols): + continue + try: + _ = op.output_type( + array.get_column_type(l_arg), array.get_column_type(r_arg) + ) + exprs.append(op.as_expr(l_arg, r_arg)) + except TypeError: + continue + assert len(exprs) > 0 + new_arr, _ = array.compute_values(exprs) + return new_arr + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize( + "op", + [ + ops.and_op, + ops.or_op, + ops.xor_op, + ], +) +def test_engines_project_boolean_op( + scalars_array_value: array_value.ArrayValue, engine, op +): + # exclude string cols as does not contain dates + # bool col actually doesn't work properly for bq engine + arr = apply_op_pairwise(scalars_array_value, op, excluded_cols=["string_col"]) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_comparison_ops.py b/tests/system/small/engines/test_comparison_ops.py new file mode 100644 index 0000000000..0fcc48b10a --- /dev/null +++ b/tests/system/small/engines/test_comparison_ops.py @@ -0,0 +1,70 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import itertools + +import pytest + +from bigframes.core import array_value +import bigframes.operations as ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + +# numeric domain + + +def apply_op_pairwise( + array: array_value.ArrayValue, op: ops.BinaryOp, excluded_cols=[] +) -> array_value.ArrayValue: + exprs = [] + for l_arg, r_arg in itertools.permutations(array.column_ids, 2): + if (l_arg in excluded_cols) or (r_arg in excluded_cols): + continue + try: + _ = op.output_type( + array.get_column_type(l_arg), array.get_column_type(r_arg) + ) + exprs.append(op.as_expr(l_arg, r_arg)) + except TypeError: + continue + assert len(exprs) > 0 + new_arr, _ = array.compute_values(exprs) + return new_arr + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize( + "op", + [ + ops.eq_op, + ops.eq_null_match_op, + ops.ne_op, + ops.gt_op, + ops.lt_op, + ops.le_op, + ops.ge_op, + ], +) +def test_engines_project_comparison_op( + scalars_array_value: array_value.ArrayValue, engine, op +): + # exclude string cols as does not contain dates + # bool col actually doesn't work properly for bq engine + arr = apply_op_pairwise(scalars_array_value, op, excluded_cols=["string_col"]) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_concat.py b/tests/system/small/engines/test_concat.py new file mode 100644 index 0000000000..5786cfc419 --- /dev/null +++ b/tests/system/small/engines/test_concat.py @@ -0,0 +1,51 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value, ordering +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_concat_self( + scalars_array_value: array_value.ArrayValue, + engine, +): + result = scalars_array_value.concat([scalars_array_value, scalars_array_value]) + + assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_concat_filtered_sorted( + scalars_array_value: array_value.ArrayValue, + engine, +): + input_1 = scalars_array_value.select_columns(["float64_col", "int64_col"]).order_by( + [ordering.ascending_over("int64_col")] + ) + input_2 = scalars_array_value.filter_by_id("bool_col").select_columns( + ["float64_col", "int64_too"] + ) + + result = input_1.concat([input_2, input_1, input_2]) + + assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_filtering.py b/tests/system/small/engines/test_filtering.py new file mode 100644 index 0000000000..817bb4c3f7 --- /dev/null +++ b/tests/system/small/engines/test_filtering.py @@ -0,0 +1,67 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +from bigframes.core import array_value, expression, nodes +import bigframes.operations as ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_filter_bool_col( + scalars_array_value: array_value.ArrayValue, + engine, +): + node = nodes.FilterNode( + scalars_array_value.node, predicate=expression.deref("bool_col") + ) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_filter_expr_cond( + scalars_array_value: array_value.ArrayValue, + engine, +): + predicate = ops.gt_op.as_expr( + expression.deref("float64_col"), expression.deref("int64_col") + ) + node = nodes.FilterNode(scalars_array_value.node, predicate=predicate) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_filter_true( + scalars_array_value: array_value.ArrayValue, + engine, +): + predicate = expression.const(True) + node = nodes.FilterNode(scalars_array_value.node, predicate=predicate) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_filter_false( + scalars_array_value: array_value.ArrayValue, + engine, +): + predicate = expression.const(False) + node = nodes.FilterNode(scalars_array_value.node, predicate=predicate) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py new file mode 100644 index 0000000000..c0469ed97a --- /dev/null +++ b/tests/system/small/engines/test_generic_ops.py @@ -0,0 +1,469 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re + +import pytest + +from bigframes.core import array_value, expression +import bigframes.dtypes +import bigframes.operations as ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +polars = pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +def apply_op( + array: array_value.ArrayValue, op: ops.AsTypeOp, excluded_cols=[] +) -> array_value.ArrayValue: + exprs = [] + labels = [] + for arg in array.column_ids: + if arg in excluded_cols: + continue + try: + _ = op.output_type(array.get_column_type(arg)) + expr = op.as_expr(arg) + exprs.append(expr) + type_string = re.sub(r"[^a-zA-Z\d]", "_", str(op.to_type)) + labels.append(f"{arg}_as_{type_string}") + except TypeError: + continue + assert len(exprs) > 0 + new_arr, ids = array.compute_values(exprs) + new_arr = new_arr.rename_columns( + {new_col: label for new_col, label in zip(ids, labels)} + ) + return new_arr + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_int(scalars_array_value: array_value.ArrayValue, engine): + polars_version = tuple([int(part) for part in polars.__version__.split(".")]) + if polars_version >= (1, 34, 0): + # TODO(https://github.com/pola-rs/polars/issues/24841): Remove this when + # polars fixes Decimal to Int cast. + scalars_array_value = scalars_array_value.drop_columns(["numeric_col"]) + + arr = apply_op( + scalars_array_value, + ops.AsTypeOp(to_type=bigframes.dtypes.INT_DTYPE), + excluded_cols=["string_col"], + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_string_int(scalars_array_value: array_value.ArrayValue, engine): + vals = ["1", "100", "-3"] + arr, _ = scalars_array_value.compute_values( + [ + ops.AsTypeOp(to_type=bigframes.dtypes.INT_DTYPE).as_expr( + expression.const(val) + ) + for val in vals + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_float(scalars_array_value: array_value.ArrayValue, engine): + arr = apply_op( + scalars_array_value, + ops.AsTypeOp(to_type=bigframes.dtypes.FLOAT_DTYPE), + excluded_cols=["string_col"], + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_string_float( + scalars_array_value: array_value.ArrayValue, engine +): + vals = ["1", "1.1", ".1", "1e3", "1.34235e4", "3.33333e-4"] + arr, _ = scalars_array_value.compute_values( + [ + ops.AsTypeOp(to_type=bigframes.dtypes.FLOAT_DTYPE).as_expr( + expression.const(val) + ) + for val in vals + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_bool(scalars_array_value: array_value.ArrayValue, engine): + arr = apply_op( + scalars_array_value, ops.AsTypeOp(to_type=bigframes.dtypes.BOOL_DTYPE) + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_string(scalars_array_value: array_value.ArrayValue, engine): + # floats work slightly different with trailing zeroes rn + arr = apply_op( + scalars_array_value, + ops.AsTypeOp(to_type=bigframes.dtypes.STRING_DTYPE), + excluded_cols=["float64_col"], + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_numeric(scalars_array_value: array_value.ArrayValue, engine): + arr = apply_op( + scalars_array_value, + ops.AsTypeOp(to_type=bigframes.dtypes.NUMERIC_DTYPE), + excluded_cols=["string_col"], + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_string_numeric( + scalars_array_value: array_value.ArrayValue, engine +): + vals = ["1", "1.1", ".1", "23428975070235903.209", "-23428975070235903.209"] + arr, _ = scalars_array_value.compute_values( + [ + ops.AsTypeOp(to_type=bigframes.dtypes.NUMERIC_DTYPE).as_expr( + expression.const(val) + ) + for val in vals + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_date(scalars_array_value: array_value.ArrayValue, engine): + arr = apply_op( + scalars_array_value, + ops.AsTypeOp(to_type=bigframes.dtypes.DATE_DTYPE), + excluded_cols=["string_col"], + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_string_date( + scalars_array_value: array_value.ArrayValue, engine +): + vals = ["2014-08-15", "2215-08-15", "2016-02-29"] + arr, _ = scalars_array_value.compute_values( + [ + ops.AsTypeOp(to_type=bigframes.dtypes.DATE_DTYPE).as_expr( + expression.const(val) + ) + for val in vals + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_datetime(scalars_array_value: array_value.ArrayValue, engine): + arr = apply_op( + scalars_array_value, + ops.AsTypeOp(to_type=bigframes.dtypes.DATETIME_DTYPE), + excluded_cols=["string_col"], + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_string_datetime( + scalars_array_value: array_value.ArrayValue, engine +): + vals = ["2014-08-15 08:15:12", "2015-08-15 08:15:12.654754", "2016-02-29 00:00:00"] + arr, _ = scalars_array_value.compute_values( + [ + ops.AsTypeOp(to_type=bigframes.dtypes.DATETIME_DTYPE).as_expr( + expression.const(val) + ) + for val in vals + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_timestamp(scalars_array_value: array_value.ArrayValue, engine): + arr = apply_op( + scalars_array_value, + ops.AsTypeOp(to_type=bigframes.dtypes.TIMESTAMP_DTYPE), + excluded_cols=["string_col"], + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_string_timestamp( + scalars_array_value: array_value.ArrayValue, engine +): + vals = [ + "2014-08-15 08:15:12+00:00", + "2015-08-15 08:15:12.654754+05:00", + "2016-02-29 00:00:00+08:00", + ] + arr, _ = scalars_array_value.compute_values( + [ + ops.AsTypeOp(to_type=bigframes.dtypes.TIMESTAMP_DTYPE).as_expr( + expression.const(val) + ) + for val in vals + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_time(scalars_array_value: array_value.ArrayValue, engine): + arr = apply_op( + scalars_array_value, + ops.AsTypeOp(to_type=bigframes.dtypes.TIME_DTYPE), + excluded_cols=["string_col", "int64_col", "int64_too"], + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_from_json(scalars_array_value: array_value.ArrayValue, engine): + exprs = [ + ops.AsTypeOp(to_type=bigframes.dtypes.INT_DTYPE).as_expr( + expression.const("5", bigframes.dtypes.JSON_DTYPE) + ), + ops.AsTypeOp(to_type=bigframes.dtypes.FLOAT_DTYPE).as_expr( + expression.const("5", bigframes.dtypes.JSON_DTYPE) + ), + ops.AsTypeOp(to_type=bigframes.dtypes.BOOL_DTYPE).as_expr( + expression.const("true", bigframes.dtypes.JSON_DTYPE) + ), + ops.AsTypeOp(to_type=bigframes.dtypes.STRING_DTYPE).as_expr( + expression.const('"hello world"', bigframes.dtypes.JSON_DTYPE) + ), + ] + arr, _ = scalars_array_value.compute_values(exprs) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_to_json(scalars_array_value: array_value.ArrayValue, engine): + exprs = [ + ops.AsTypeOp(to_type=bigframes.dtypes.JSON_DTYPE).as_expr( + expression.deref("int64_col") + ), + ops.AsTypeOp(to_type=bigframes.dtypes.JSON_DTYPE).as_expr( + # Use a const since float to json has precision issues + expression.const(5.2, bigframes.dtypes.FLOAT_DTYPE) + ), + ops.AsTypeOp(to_type=bigframes.dtypes.JSON_DTYPE).as_expr( + expression.deref("bool_col") + ), + ops.AsTypeOp(to_type=bigframes.dtypes.JSON_DTYPE).as_expr( + # Use a const since "str_col" has special chars. + expression.const('"hello world"', bigframes.dtypes.STRING_DTYPE) + ), + ] + arr, _ = scalars_array_value.compute_values(exprs) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_astype_timedelta(scalars_array_value: array_value.ArrayValue, engine): + arr = apply_op( + scalars_array_value, + ops.AsTypeOp(to_type=bigframes.dtypes.TIMEDELTA_DTYPE), + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_where_op(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.where_op.as_expr( + expression.deref("int64_col"), + expression.deref("bool_col"), + expression.deref("float64_col"), + ) + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_coalesce_op(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.coalesce_op.as_expr( + expression.deref("int64_col"), + expression.deref("float64_col"), + ) + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_fillna_op(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.fillna_op.as_expr( + expression.deref("int64_col"), + expression.deref("float64_col"), + ) + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_casewhen_op_single_case( + scalars_array_value: array_value.ArrayValue, engine +): + arr, _ = scalars_array_value.compute_values( + [ + ops.case_when_op.as_expr( + expression.deref("bool_col"), + expression.deref("int64_col"), + ) + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_casewhen_op_double_case( + scalars_array_value: array_value.ArrayValue, engine +): + arr, _ = scalars_array_value.compute_values( + [ + ops.case_when_op.as_expr( + ops.gt_op.as_expr(expression.deref("int64_col"), expression.const(3)), + expression.deref("int64_col"), + ops.lt_op.as_expr(expression.deref("int64_col"), expression.const(-3)), + expression.deref("int64_too"), + ) + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_isnull_op(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ops.isnull_op.as_expr(expression.deref("string_col"))] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_notnull_op(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ops.notnull_op.as_expr(expression.deref("string_col"))] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_invert_op(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.invert_op.as_expr(expression.deref("bytes_col")), + ops.invert_op.as_expr(expression.deref("bool_col")), + ] + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_isin_op(scalars_array_value: array_value.ArrayValue, engine): + arr, col_ids = scalars_array_value.compute_values( + [ + ops.IsInOp((1, 2, 3)).as_expr(expression.deref("int64_col")), + ops.IsInOp((None, 123456)).as_expr(expression.deref("int64_col")), + ops.IsInOp((None, 123456), match_nulls=False).as_expr( + expression.deref("int64_col") + ), + ops.IsInOp((1.0, 2.0, 3.0)).as_expr(expression.deref("int64_col")), + ops.IsInOp(("1.0", "2.0")).as_expr(expression.deref("int64_col")), + ops.IsInOp(("1.0", 2.5, 3)).as_expr(expression.deref("int64_col")), + ops.IsInOp(()).as_expr(expression.deref("int64_col")), + ops.IsInOp((1, 2, 3, None)).as_expr(expression.deref("float64_col")), + ] + ) + new_names = ( + "int in ints", + "int in ints w null", + "int in ints w null wo match nulls", + "int in floats", + "int in strings", + "int in mixed", + "int in empty", + "float in ints", + ) + arr = arr.rename_columns( + {old_name: new_names[i] for i, old_name in enumerate(col_ids)} + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_isin_op_nested_filter( + scalars_array_value: array_value.ArrayValue, engine +): + isin_clause = ops.IsInOp((1, 2, 3)).as_expr(expression.deref("int64_col")) + filter_clause = ops.invert_op.as_expr( + ops.or_op.as_expr( + expression.deref("bool_col"), ops.invert_op.as_expr(isin_clause) + ) + ) + arr = scalars_array_value.filter(filter_clause) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_join.py b/tests/system/small/engines/test_join.py new file mode 100644 index 0000000000..15dbfabdac --- /dev/null +++ b/tests/system/small/engines/test_join.py @@ -0,0 +1,111 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Literal + +import pytest + +from bigframes import operations as ops +from bigframes.core import array_value, expression, ordering +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("join_type", ["left", "inner", "right", "outer"]) +def test_engines_join_on_key( + scalars_array_value: array_value.ArrayValue, + engine, + join_type: Literal["inner", "outer", "left", "right"], +): + result, _ = scalars_array_value.relational_join( + scalars_array_value, conditions=(("int64_col", "int64_col"),), type=join_type + ) + + assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("join_type", ["left", "inner", "right", "outer"]) +def test_engines_join_on_coerced_key( + scalars_array_value: array_value.ArrayValue, + engine, + join_type: Literal["inner", "outer", "left", "right"], +): + result, _ = scalars_array_value.relational_join( + scalars_array_value, conditions=(("int64_col", "float64_col"),), type=join_type + ) + + assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("join_type", ["left", "inner", "right", "outer"]) +def test_engines_join_multi_key( + scalars_array_value: array_value.ArrayValue, + engine, + join_type: Literal["inner", "outer", "left", "right"], +): + l_input = scalars_array_value.order_by([ordering.ascending_over("float64_col")]) + l_input, l_join_cols = scalars_array_value.compute_values( + [ + ops.mod_op.as_expr("int64_col", expression.const(2)), + ops.invert_op.as_expr("bool_col"), + ] + ) + r_input, r_join_cols = scalars_array_value.compute_values( + [ops.mod_op.as_expr("int64_col", expression.const(3)), expression.const(True)] + ) + + conditions = tuple((l_col, r_col) for l_col, r_col in zip(l_join_cols, r_join_cols)) + + result, _ = l_input.relational_join(r_input, conditions=conditions, type=join_type) + + assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_cross_join( + scalars_array_value: array_value.ArrayValue, + engine, +): + result, _ = scalars_array_value.relational_join(scalars_array_value, type="cross") + + assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize( + ("left_key", "right_key"), + [ + ("int64_col", "float64_col"), + ("float64_col", "int64_col"), + ("int64_too", "int64_col"), + ], +) +def test_engines_isin( + scalars_array_value: array_value.ArrayValue, engine, left_key, right_key +): + other = scalars_array_value.select_columns([right_key]) + result, _ = scalars_array_value.isin( + other, + lcol=left_key, + ) + + assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_numeric_ops.py b/tests/system/small/engines/test_numeric_ops.py new file mode 100644 index 0000000000..ef0f8d9d0d --- /dev/null +++ b/tests/system/small/engines/test_numeric_ops.py @@ -0,0 +1,170 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import itertools + +import pytest + +from bigframes.core import array_value, expression +import bigframes.operations as ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +def apply_op_pairwise( + array: array_value.ArrayValue, op: ops.BinaryOp, excluded_cols=[] +) -> array_value.ArrayValue: + exprs = [] + labels = [] + for l_arg, r_arg in itertools.product(array.column_ids, array.column_ids): + if (l_arg in excluded_cols) or (r_arg in excluded_cols): + continue + try: + _ = op.output_type( + array.get_column_type(l_arg), array.get_column_type(r_arg) + ) + expr = op.as_expr(l_arg, r_arg) + exprs.append(expr) + labels.append(f"{l_arg}_{r_arg}") + except TypeError: + continue + assert len(exprs) > 0 + new_arr, ids = array.compute_values(exprs) + new_arr = new_arr.rename_columns( + {new_col: label for new_col, label in zip(ids, labels)} + ) + return new_arr + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_project_add( + scalars_array_value: array_value.ArrayValue, + engine, +): + arr = apply_op_pairwise(scalars_array_value, ops.add_op) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_project_sub( + scalars_array_value: array_value.ArrayValue, + engine, +): + arr = apply_op_pairwise(scalars_array_value, ops.sub_op) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_project_mul( + scalars_array_value: array_value.ArrayValue, + engine, +): + arr = apply_op_pairwise(scalars_array_value, ops.mul_op) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_project_div(scalars_array_value: array_value.ArrayValue, engine): + # TODO: Duration div is sensitive to zeroes + # TODO: Numeric col is sensitive to scale shifts + arr = apply_op_pairwise( + scalars_array_value, ops.div_op, excluded_cols=["duration_col", "numeric_col"] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_project_div_durations( + scalars_array_value: array_value.ArrayValue, engine +): + arr, _ = scalars_array_value.compute_values( + [ + ops.div_op.as_expr( + expression.deref("duration_col"), + expression.const(datetime.timedelta(seconds=3)), + ), + ops.div_op.as_expr( + expression.deref("duration_col"), + expression.const(datetime.timedelta(seconds=-3)), + ), + ops.div_op.as_expr(expression.deref("duration_col"), expression.const(4)), + ops.div_op.as_expr(expression.deref("duration_col"), expression.const(-4)), + ops.div_op.as_expr( + expression.deref("duration_col"), expression.const(55.55) + ), + ops.div_op.as_expr( + expression.deref("duration_col"), expression.const(-55.55) + ), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_project_floordiv( + scalars_array_value: array_value.ArrayValue, + engine, +): + arr = apply_op_pairwise( + scalars_array_value, + ops.floordiv_op, + excluded_cols=["duration_col", "numeric_col"], + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_project_floordiv_durations( + scalars_array_value: array_value.ArrayValue, engine +): + arr, _ = scalars_array_value.compute_values( + [ + ops.floordiv_op.as_expr( + expression.deref("duration_col"), + expression.const(datetime.timedelta(seconds=3)), + ), + ops.floordiv_op.as_expr( + expression.deref("duration_col"), + expression.const(datetime.timedelta(seconds=-3)), + ), + ops.floordiv_op.as_expr( + expression.deref("duration_col"), expression.const(4) + ), + ops.floordiv_op.as_expr( + expression.deref("duration_col"), expression.const(-4) + ), + ops.floordiv_op.as_expr( + expression.deref("duration_col"), expression.const(55.55) + ), + ops.floordiv_op.as_expr( + expression.deref("duration_col"), expression.const(-55.55) + ), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_project_mod( + scalars_array_value: array_value.ArrayValue, + engine, +): + arr = apply_op_pairwise(scalars_array_value, ops.mod_op) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_read_local.py b/tests/system/small/engines/test_read_local.py new file mode 100644 index 0000000000..257bddd917 --- /dev/null +++ b/tests/system/small/engines/test_read_local.py @@ -0,0 +1,121 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes +from bigframes.core import identifiers, local_data, nodes +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +def test_engines_read_local( + fake_session: bigframes.Session, + managed_data_source: local_data.ManagedArrowTable, + engine, +): + scan_list = nodes.ScanList.from_items( + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + for item in managed_data_source.schema.items + ) + local_node = nodes.ReadLocalNode( + managed_data_source, scan_list, fake_session, offsets_col=None + ) + assert_equivalence_execution(local_node, REFERENCE_ENGINE, engine) + + +def test_engines_read_local_w_offsets( + fake_session: bigframes.Session, + managed_data_source: local_data.ManagedArrowTable, + engine, +): + scan_list = nodes.ScanList.from_items( + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + for item in managed_data_source.schema.items + ) + local_node = nodes.ReadLocalNode( + managed_data_source, + scan_list, + fake_session, + offsets_col=identifiers.ColumnId("offsets"), + ) + assert_equivalence_execution(local_node, REFERENCE_ENGINE, engine) + + +def test_engines_read_local_w_col_subset( + fake_session: bigframes.Session, + managed_data_source: local_data.ManagedArrowTable, + engine, +): + scan_list = nodes.ScanList.from_items( + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + for item in managed_data_source.schema.items[::-2] + ) + local_node = nodes.ReadLocalNode( + managed_data_source, scan_list, fake_session, offsets_col=None + ) + assert_equivalence_execution(local_node, REFERENCE_ENGINE, engine) + + +def test_engines_read_local_w_zero_row_source( + fake_session: bigframes.Session, + zero_row_source: local_data.ManagedArrowTable, + engine, +): + scan_list = nodes.ScanList.from_items( + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + for item in zero_row_source.schema.items + ) + local_node = nodes.ReadLocalNode( + zero_row_source, scan_list, fake_session, offsets_col=None + ) + assert_equivalence_execution(local_node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize( + "engine", ["polars", "bq", "pyarrow", "bq-sqlglot"], indirect=True +) +def test_engines_read_local_w_nested_source( + fake_session: bigframes.Session, + nested_data_source: local_data.ManagedArrowTable, + engine, +): + scan_list = nodes.ScanList.from_items( + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + for item in nested_data_source.schema.items + ) + local_node = nodes.ReadLocalNode( + nested_data_source, scan_list, fake_session, offsets_col=None + ) + assert_equivalence_execution(local_node, REFERENCE_ENGINE, engine) + + +def test_engines_read_local_w_repeated_source( + fake_session: bigframes.Session, + repeated_data_source: local_data.ManagedArrowTable, + engine, +): + scan_list = nodes.ScanList.from_items( + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + for item in repeated_data_source.schema.items + ) + local_node = nodes.ReadLocalNode( + repeated_data_source, scan_list, fake_session, offsets_col=None + ) + assert_equivalence_execution(local_node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_selection.py b/tests/system/small/engines/test_selection.py new file mode 100644 index 0000000000..94c8a6463c --- /dev/null +++ b/tests/system/small/engines/test_selection.py @@ -0,0 +1,60 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value, expression, identifiers, nodes +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +def test_engines_select_identity( + scalars_array_value: array_value.ArrayValue, + engine, +): + selection = tuple( + nodes.AliasedRef(expression.deref(col), identifiers.ColumnId(col)) + for col in scalars_array_value.column_ids + ) + node = nodes.SelectionNode(scalars_array_value.node, selection) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +def test_engines_select_rename( + scalars_array_value: array_value.ArrayValue, + engine, +): + selection = tuple( + nodes.AliasedRef(expression.deref(col), identifiers.ColumnId(f"renamed_{col}")) + for col in scalars_array_value.column_ids + ) + node = nodes.SelectionNode(scalars_array_value.node, selection) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +def test_engines_select_reorder_rename_drop( + scalars_array_value: array_value.ArrayValue, + engine, +): + selection = tuple( + nodes.AliasedRef(expression.deref(col), identifiers.ColumnId(f"renamed_{col}")) + for col in scalars_array_value.column_ids[::-2] + ) + node = nodes.SelectionNode(scalars_array_value.node, selection) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_slicing.py b/tests/system/small/engines/test_slicing.py new file mode 100644 index 0000000000..022758893d --- /dev/null +++ b/tests/system/small/engines/test_slicing.py @@ -0,0 +1,56 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value, nodes +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (1, None, None), + (None, 4, None), + (None, None, 2), + (None, 50_000_000_000, 1), + (5, 4, None), + (3, None, 2), + (1, 7, 2), + (1, 7, 50_000_000_000), + (-1, -7, -2), + (None, -7, -2), + (-1, None, -2), + (-7, -1, 2), + (-7, -1, None), + (-7, 7, None), + (7, -7, -2), + ], +) +def test_engines_slice( + scalars_array_value: array_value.ArrayValue, + engine, + start, + stop, + step, +): + node = nodes.SliceNode(scalars_array_value.node, start, stop, step) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_sorting.py b/tests/system/small/engines/test_sorting.py new file mode 100644 index 0000000000..ec1c0d95ee --- /dev/null +++ b/tests/system/small/engines/test_sorting.py @@ -0,0 +1,103 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value, nodes, ordering +import bigframes.operations as bf_ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_reverse( + scalars_array_value: array_value.ArrayValue, + engine, +): + node = apply_reverse(scalars_array_value.node) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_double_reverse( + scalars_array_value: array_value.ArrayValue, + engine, +): + node = apply_reverse(scalars_array_value.node) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize( + "sort_col", + [ + "bool_col", + "int64_col", + "bytes_col", + "date_col", + "datetime_col", + "int64_col", + "int64_too", + "numeric_col", + "float64_col", + "string_col", + "time_col", + "timestamp_col", + ], +) +def test_engines_sort_over_column( + scalars_array_value: array_value.ArrayValue, engine, sort_col +): + node = apply_reverse(scalars_array_value.node) + ORDER_EXPRESSIONS = (ordering.descending_over(sort_col, nulls_last=False),) + node = nodes.OrderByNode(node, ORDER_EXPRESSIONS) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_sort_multi_column_refs( + scalars_array_value: array_value.ArrayValue, + engine, +): + node = scalars_array_value.node + ORDER_EXPRESSIONS = ( + ordering.ascending_over("bool_col", nulls_last=False), + ordering.descending_over("int64_col"), + ) + node = nodes.OrderByNode(node, ORDER_EXPRESSIONS) + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars"], indirect=True) +def test_polars_engines_skips_unrecognized_order_expr( + scalars_array_value: array_value.ArrayValue, + engine, +): + node = scalars_array_value.node + ORDER_EXPRESSIONS = ( + ordering.OrderingExpression( + scalar_expression=bf_ops.sin_op.as_expr("float_col") + ), + ) + node = nodes.OrderByNode(node, ORDER_EXPRESSIONS) + assert engine.execute(node, ordered=True) is None + + +def apply_reverse(node: nodes.BigFrameNode) -> nodes.BigFrameNode: + return nodes.ReversedNode(node) diff --git a/tests/system/small/engines/test_strings.py b/tests/system/small/engines/test_strings.py new file mode 100644 index 0000000000..d450474504 --- /dev/null +++ b/tests/system/small/engines/test_strings.py @@ -0,0 +1,77 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value +import bigframes.operations as ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_str_contains(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.StrContainsOp("(?i)hEllo").as_expr("string_col"), + ops.StrContainsOp("Hello").as_expr("string_col"), + ops.StrContainsOp("T").as_expr("string_col"), + ops.StrContainsOp(".*").as_expr("string_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_str_contains_regex( + scalars_array_value: array_value.ArrayValue, engine +): + arr, _ = scalars_array_value.compute_values( + [ + ops.StrContainsRegexOp("(?i)hEllo").as_expr("string_col"), + ops.StrContainsRegexOp("Hello").as_expr("string_col"), + ops.StrContainsRegexOp("T").as_expr("string_col"), + ops.StrContainsRegexOp(".*").as_expr("string_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_str_startswith(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.StartsWithOp("He").as_expr("string_col"), + ops.StartsWithOp("llo").as_expr("string_col"), + ops.StartsWithOp(("He", "T", "ca")).as_expr("string_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_str_endswith(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.EndsWithOp("!").as_expr("string_col"), + ops.EndsWithOp("llo").as_expr("string_col"), + ops.EndsWithOp(("He", "T", "ca")).as_expr("string_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_temporal_ops.py b/tests/system/small/engines/test_temporal_ops.py new file mode 100644 index 0000000000..66edfeddcc --- /dev/null +++ b/tests/system/small/engines/test_temporal_ops.py @@ -0,0 +1,66 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value +import bigframes.operations as ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_dt_floor(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.FloorDtOp("us").as_expr("timestamp_col"), + ops.FloorDtOp("ms").as_expr("timestamp_col"), + ops.FloorDtOp("s").as_expr("timestamp_col"), + ops.FloorDtOp("min").as_expr("timestamp_col"), + ops.FloorDtOp("h").as_expr("timestamp_col"), + ops.FloorDtOp("D").as_expr("timestamp_col"), + ops.FloorDtOp("W").as_expr("timestamp_col"), + ops.FloorDtOp("M").as_expr("timestamp_col"), + ops.FloorDtOp("Q").as_expr("timestamp_col"), + ops.FloorDtOp("Y").as_expr("timestamp_col"), + ops.FloorDtOp("Q").as_expr("datetime_col"), + ops.FloorDtOp("us").as_expr("datetime_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_date_accessors(scalars_array_value: array_value.ArrayValue, engine): + datelike_cols = ["datetime_col", "timestamp_col", "date_col"] + accessors = [ + ops.day_op, + ops.dayofweek_op, + ops.month_op, + ops.quarter_op, + ops.year_op, + ops.iso_day_op, + ops.iso_week_op, + ops.iso_year_op, + ] + + exprs = [acc.as_expr(col) for acc in accessors for col in datelike_cols] + + arr, _ = scalars_array_value.compute_values(exprs) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_windowing.py b/tests/system/small/engines/test_windowing.py new file mode 100644 index 0000000000..5e4a94d900 --- /dev/null +++ b/tests/system/small/engines/test_windowing.py @@ -0,0 +1,73 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.cloud import bigquery +import pytest + +from bigframes.core import ( + agg_expressions, + array_value, + events, + expression, + identifiers, + nodes, + window_spec, +) +import bigframes.operations.aggregations as agg_ops +from bigframes.session import direct_gbq_execution, polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_with_offsets( + scalars_array_value: array_value.ArrayValue, + engine, +): + result, _ = scalars_array_value.promote_offsets() + assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("agg_op", [agg_ops.sum_op, agg_ops.count_op]) +def test_engines_with_rows_window( + scalars_array_value: array_value.ArrayValue, + bigquery_client: bigquery.Client, + agg_op, +): + window = window_spec.WindowSpec( + bounds=window_spec.RowsWindowBounds.from_window_size(3, "left"), + ) + window_node = nodes.WindowOpNode( + child=scalars_array_value.node, + agg_exprs=( + nodes.ColumnDef( + agg_expressions.UnaryAggregation(agg_op, expression.deref("int64_too")), + identifiers.ColumnId("agg_int64"), + ), + ), + window_spec=window, + ) + + publisher = events.Publisher() + bq_executor = direct_gbq_execution.DirectGbqExecutor( + bigquery_client, publisher=publisher + ) + bq_sqlgot_executor = direct_gbq_execution.DirectGbqExecutor( + bigquery_client, compiler="sqlglot", publisher=publisher + ) + assert_equivalence_execution(window_node, bq_executor, bq_sqlgot_executor) diff --git a/tests/system/small/functions/test_remote_function.py b/tests/system/small/functions/test_remote_function.py index 0dc8960f62..1ee60dafd6 100644 --- a/tests/system/small/functions/test_remote_function.py +++ b/tests/system/small/functions/test_remote_function.py @@ -14,20 +14,27 @@ import inspect import re +import textwrap +from typing import Sequence +import bigframes_vendored.constants as constants import google.api_core.exceptions from google.cloud import bigquery +import pandas import pandas as pd import pyarrow import pytest import test_utils.prefixer import bigframes +import bigframes.clients +import bigframes.core.events import bigframes.dtypes import bigframes.exceptions from bigframes.functions import _utils as bff_utils from bigframes.functions import function as bff -from tests.system.utils import assert_pandas_df_equal +import bigframes.session._io.bigquery +from bigframes.testing.utils import assert_frame_equal, get_function_name _prefixer = test_utils.prefixer.Prefixer("bigframes", "") @@ -90,21 +97,12 @@ def session_with_bq_connection(bq_cf_connection) -> bigframes.Session: return session -def get_rf_name(func, package_requirements=None, is_row_processor=False): - """Get a remote function name for testing given a udf.""" - # Augment user package requirements with any internal package - # requirements - package_requirements = bff_utils._get_updated_package_requirements( - package_requirements, is_row_processor - ) - - # Compute a unique hash representing the user code - function_hash = bff_utils._get_hash(func, package_requirements) - - return f"bigframes_{function_hash}" +def get_bq_connection_id_path_format(connection_id_dot_format): + fields = connection_id_dot_format.split(".") + return f"projects/{fields[0]}/locations/{fields[1]}/connections/{fields[2]}" -@pytest.mark.flaky(retries=2, delay=120) +# @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_direct_no_session_param( bigquery_client, bigqueryconnection_client, @@ -118,8 +116,8 @@ def square(x): return x * x square = bff.remote_function( - int, - int, + input_types=int, + output_type=int, bigquery_client=bigquery_client, bigquery_connection_client=bigqueryconnection_client, cloud_functions_client=cloudfunctions_client, @@ -128,7 +126,8 @@ def square(x): bigquery_connection=bq_cf_connection, # See e2e tests for tests that actually deploy the Cloud Function. reuse=True, - name=get_rf_name(square), + name=get_function_name(square), + cloud_function_service_account="default", )(square) # Function should still work normally. @@ -136,8 +135,8 @@ def square(x): # Function should have extra metadata attached for remote execution. assert hasattr(square, "bigframes_remote_function") + assert hasattr(square, "bigframes_bigquery_function") assert hasattr(square, "bigframes_cloud_function") - assert hasattr(square, "ibis_node") scalars_df, scalars_pandas_df = scalars_dfs @@ -160,15 +159,12 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_direct_no_session_param_location_specified( - bigquery_client, - bigqueryconnection_client, - cloudfunctions_client, - resourcemanager_client, +def test_remote_function_connection_w_location( + session, scalars_dfs, dataset_id_permanent, bq_cf_connection_location, @@ -177,17 +173,15 @@ def square(x): return x * x square = bff.remote_function( - int, - int, - bigquery_client=bigquery_client, - bigquery_connection_client=bigqueryconnection_client, - cloud_functions_client=cloudfunctions_client, - resource_manager_client=resourcemanager_client, + input_types=int, + output_type=int, + session=session, dataset=dataset_id_permanent, bigquery_connection=bq_cf_connection_location, # See e2e tests for tests that actually deploy the Cloud Function. reuse=True, - name=get_rf_name(square), + name=get_function_name(square), + cloud_function_service_account="default", )(square) # Function should still work normally. @@ -214,15 +208,12 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_direct_no_session_param_location_mismatched( - bigquery_client, - bigqueryconnection_client, - cloudfunctions_client, - resourcemanager_client, +def test_remote_function_connection_w_location_mismatched( + session, dataset_id_permanent, bq_cf_connection_location_mismatched, ): @@ -231,31 +222,41 @@ def square(x): # connection doesn't match the location of the dataset. return x * x # pragma: NO COVER - with pytest.raises( - ValueError, - match=re.escape("The location does not match BigQuery connection location:"), - ): - bff.remote_function( - int, - int, - bigquery_client=bigquery_client, - bigquery_connection_client=bigqueryconnection_client, - cloud_functions_client=cloudfunctions_client, - resource_manager_client=resourcemanager_client, - dataset=dataset_id_permanent, - bigquery_connection=bq_cf_connection_location_mismatched, - # See e2e tests for tests that actually deploy the Cloud Function. - reuse=True, - name=get_rf_name(square), - )(square) + bq_cf_connection_location_mismatched_path_fmt = get_bq_connection_id_path_format( + bigframes.clients.get_canonical_bq_connection_id( + bq_cf_connection_location_mismatched, + session.bqclient.project, + session._location, + ) + ) + connection_ids = [ + bq_cf_connection_location_mismatched, + bq_cf_connection_location_mismatched_path_fmt, + ] + + for connection_id in connection_ids: + with pytest.raises( + ValueError, + match=re.escape( + "The location does not match BigQuery connection location:" + ), + ): + bff.remote_function( + input_types=int, + output_type=int, + session=session, + dataset=dataset_id_permanent, + bigquery_connection=connection_id, + # See e2e tests for tests that actually deploy the Cloud Function. + reuse=True, + name=get_function_name(square), + cloud_function_service_account="default", + )(square) @pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_direct_no_session_param_location_project_specified( - bigquery_client, - bigqueryconnection_client, - cloudfunctions_client, - resourcemanager_client, +def test_remote_function_connection_w_location_project( + session, scalars_dfs, dataset_id_permanent, bq_cf_connection_location_project, @@ -264,17 +265,15 @@ def square(x): return x * x square = bff.remote_function( - int, - int, - bigquery_client=bigquery_client, - bigquery_connection_client=bigqueryconnection_client, - cloud_functions_client=cloudfunctions_client, - resource_manager_client=resourcemanager_client, + input_types=int, + output_type=int, + session=session, dataset=dataset_id_permanent, bigquery_connection=bq_cf_connection_location_project, # See e2e tests for tests that actually deploy the Cloud Function. reuse=True, - name=get_rf_name(square), + name=get_function_name(square), + cloud_function_service_account="default", )(square) # Function should still work normally. @@ -301,15 +300,12 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_direct_no_session_param_project_mismatched( - bigquery_client, - bigqueryconnection_client, - cloudfunctions_client, - resourcemanager_client, +def test_remote_function_connection_w_project_mismatched( + session, dataset_id_permanent, bq_cf_connection_location_project_mismatched, ): @@ -318,25 +314,38 @@ def square(x): # connection doesn't match the project of the dataset. return x * x # pragma: NO COVER - with pytest.raises( - ValueError, - match=re.escape( - "The project_id does not match BigQuery connection gcp_project_id:" - ), - ): - bff.remote_function( - int, - int, - bigquery_client=bigquery_client, - bigquery_connection_client=bigqueryconnection_client, - cloud_functions_client=cloudfunctions_client, - resource_manager_client=resourcemanager_client, - dataset=dataset_id_permanent, - bigquery_connection=bq_cf_connection_location_project_mismatched, - # See e2e tests for tests that actually deploy the Cloud Function. - reuse=True, - name=get_rf_name(square), - )(square) + bq_cf_connection_location_project_mismatched_path_fmt = ( + get_bq_connection_id_path_format( + bigframes.clients.get_canonical_bq_connection_id( + bq_cf_connection_location_project_mismatched, + session.bqclient.project, + session._location, + ) + ) + ) + connection_ids = [ + bq_cf_connection_location_project_mismatched, + bq_cf_connection_location_project_mismatched_path_fmt, + ] + + for connection_id in connection_ids: + with pytest.raises( + ValueError, + match=re.escape( + "The project_id does not match BigQuery connection gcp_project_id:" + ), + ): + bff.remote_function( + input_types=int, + output_type=int, + session=session, + dataset=dataset_id_permanent, + bigquery_connection=connection_id, + # See e2e tests for tests that actually deploy the Cloud Function. + reuse=True, + name=get_function_name(square), + cloud_function_service_account="default", + )(square) @pytest.mark.flaky(retries=2, delay=120) @@ -347,11 +356,12 @@ def square(x): return x * x square = bff.remote_function( - int, - int, + input_types=int, + output_type=int, session=session_with_bq_connection, dataset=dataset_id_permanent, - name=get_rf_name(square), + name=get_function_name(square), + cloud_function_service_account="default", )(square) # Function should still work normally. @@ -378,7 +388,7 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -396,7 +406,11 @@ def square(x): # udf is same as the one used in other tests in this file so the underlying # cloud function would be common and quickly reused. square = session_with_bq_connection.remote_function( - int, int, dataset_id_permanent, name=get_rf_name(square) + input_types=int, + output_type=int, + dataset=dataset_id_permanent, + name=get_function_name(square), + cloud_function_service_account="default", )(square) # Function should still work normally. @@ -423,7 +437,7 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -434,13 +448,14 @@ def square(x): return x * x square = session.remote_function( - int, - int, - dataset_id_permanent, - bq_cf_connection, + input_types=int, + output_type=int, + dataset=dataset_id_permanent, + bigquery_connection=bq_cf_connection, # See e2e tests for tests that actually deploy the Cloud Function. reuse=True, - name=get_rf_name(square), + name=get_function_name(square), + cloud_function_service_account="default", )(square) # Function should still work normally. @@ -467,7 +482,7 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -478,7 +493,11 @@ def add_one(x): return x + 1 remote_add_one = session_with_bq_connection.remote_function( - [int], int, dataset_id_permanent, name=get_rf_name(add_one) + input_types=[int], + output_type=int, + dataset=dataset_id_permanent, + name=get_function_name(add_one), + cloud_function_service_account="default", )(add_one) scalars_df, scalars_pandas_df = scalars_dfs @@ -498,7 +517,7 @@ def add_one(x): for col in pd_result: pd_result[col] = pd_result[col].astype(pd_int64_df_filtered[col].dtype) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -509,7 +528,11 @@ def add_one(x): return x + 1 remote_add_one = session_with_bq_connection.remote_function( - [int], int, dataset_id_permanent, name=get_rf_name(add_one) + input_types=[int], + output_type=int, + dataset=dataset_id_permanent, + name=get_function_name(add_one), + cloud_function_service_account="default", )(add_one) scalars_df, scalars_pandas_df = scalars_dfs @@ -529,7 +552,7 @@ def add_one(x): for col in pd_result: pd_result[col] = pd_result[col].astype(pd_int64_df_filtered[col].dtype) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -540,7 +563,11 @@ def add_one(x): return x + 1 remote_add_one = session_with_bq_connection.remote_function( - [int], int, dataset_id_permanent, name=get_rf_name(add_one) + input_types=[int], + output_type=int, + dataset=dataset_id_permanent, + name=get_function_name(add_one), + cloud_function_service_account="default", )(add_one) scalars_df, scalars_pandas_df = scalars_dfs @@ -558,7 +585,7 @@ def add_one(x): for col in pd_result: pd_result[col] = pd_result[col].astype(pd_int64_df[col].dtype) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -584,8 +611,9 @@ def bytes_to_hex(mybytes: bytes) -> bytes: packages = ["pandas"] remote_bytes_to_hex = session_with_bq_connection.remote_function( dataset=dataset_id_permanent, - name=get_rf_name(bytes_to_hex, package_requirements=packages), + name=get_function_name(bytes_to_hex, package_requirements=packages), packages=packages, + cloud_function_service_account="default", )(bytes_to_hex) bf_result = scalars_df.bytes_col.map(remote_bytes_to_hex).to_pandas() @@ -628,11 +656,14 @@ def add_one(x): return x + 1 # pragma: NO COVER session.remote_function( - [int], int, dataset=dataset_id_permanent, name=get_rf_name(add_one) + input_types=[int], + output_type=int, + dataset=dataset_id_permanent, + name=get_function_name(add_one), + cloud_function_service_account="default", )(add_one) -@pytest.mark.flaky(retries=2, delay=120) def test_read_gbq_function_detects_invalid_function(session, dataset_id): dataset_ref = bigquery.DatasetReference.from_string(dataset_id) with pytest.raises(ValueError) as e: @@ -659,8 +690,8 @@ def square1(x): return x * x square1 = bff.remote_function( - [int], - int, + input_types=[int], + output_type=int, bigquery_client=bigquery_client, bigquery_connection_client=bigqueryconnection_client, dataset=dataset_id_permanent, @@ -668,14 +699,15 @@ def square1(x): resource_manager_client=resourcemanager_client, bigquery_connection=bq_cf_connection, reuse=True, - name=get_rf_name(square1), + name=get_function_name(square1), + cloud_function_service_account="default", )(square1) # Function should still work normally. assert square1(2) == 4 square2 = bff.read_gbq_function( - function_name=square1.bigframes_remote_function, # type: ignore + function_name=square1.bigframes_bigquery_function, # type: ignore session=session, ) @@ -683,13 +715,17 @@ def square1(x): # cloud function associated with it, while the read-back version (square2) # should only have a remote function. assert square1.bigframes_remote_function # type: ignore + assert square1.bigframes_bigquery_function # type: ignore assert square1.bigframes_cloud_function # type: ignore assert square2.bigframes_remote_function - assert not hasattr(square2, "bigframes_cloud_function") + assert square2.bigframes_bigquery_function + assert square2.bigframes_cloud_function is None # They should point to the same function. assert square1.bigframes_remote_function == square2.bigframes_remote_function # type: ignore + assert square1.bigframes_bigquery_function == square2.bigframes_bigquery_function # type: ignore + assert square2.bigframes_remote_function == square2.bigframes_bigquery_function # type: ignore # The result of applying them should be the same. int64_col = scalars_df_index["int64_col"] @@ -702,24 +738,154 @@ def square1(x): s2_result_col = int64_col_filtered.apply(square2) s2_result = int64_col_filtered.to_frame().assign(result=s2_result_col) - assert_pandas_df_equal(s1_result.to_pandas(), s2_result.to_pandas()) + assert_frame_equal(s1_result.to_pandas(), s2_result.to_pandas()) -@pytest.mark.flaky(retries=2, delay=120) def test_read_gbq_function_runs_existing_udf(session): func = session.read_gbq_function("bqutil.fn.cw_lower_case_ascii_only") got = func("AURÉLIE") assert got == "aurÉlie" -@pytest.mark.flaky(retries=2, delay=120) def test_read_gbq_function_runs_existing_udf_4_params(session): func = session.read_gbq_function("bqutil.fn.cw_instr4") got = func("TestStr123456Str", "Str", 1, 2) assert got == 14 -@pytest.mark.flaky(retries=2, delay=120) +def test_read_gbq_function_runs_existing_udf_array_output(session, routine_id_unique): + bigframes.session._io.bigquery.start_query_with_client( + session.bqclient, + textwrap.dedent( + f""" + CREATE OR REPLACE FUNCTION `{routine_id_unique}`(x STRING) + RETURNS ARRAY + AS ( + [x, x] + ) + """ + ), + job_config=bigquery.QueryJobConfig(), + location=None, + project=None, + timeout=None, + metrics=None, + query_with_job=True, + publisher=bigframes.core.events.Publisher(), + ) + func = session.read_gbq_function(routine_id_unique) + + # Test on scalar value + got = func("hello") + assert got == ["hello", "hello"] + + # Test on a series, assert pandas parity + pd_s = pd.Series(["alpha", "beta", "gamma"]) + bf_s = session.read_pandas(pd_s) + pd_result = pd_s.apply(func) + bf_result = bf_s.apply(func) + assert bigframes.dtypes.is_array_string_like(bf_result.dtype) + pd.testing.assert_series_equal( + pd_result, bf_result.to_pandas(), check_dtype=False, check_index_type=False + ) + + +def test_read_gbq_function_runs_existing_udf_2_params_array_output( + session, routine_id_unique +): + bigframes.session._io.bigquery.start_query_with_client( + session.bqclient, + textwrap.dedent( + f""" + CREATE OR REPLACE FUNCTION `{routine_id_unique}`(x STRING, y STRING) + RETURNS ARRAY + AS ( + [x, y] + ) + """ + ), + job_config=bigquery.QueryJobConfig(), + location=None, + project=None, + timeout=None, + metrics=None, + query_with_job=True, + publisher=bigframes.core.events.Publisher(), + ) + func = session.read_gbq_function(routine_id_unique) + + # Test on scalar value + got = func("hello", "world") + assert got == ["hello", "world"] + + # Test on series, assert pandas parity + pd_df = pd.DataFrame( + {"col0": ["alpha", "beta", "gamma"], "col1": ["delta", "theta", "phi"]} + ) + bf_df = session.read_pandas(pd_df) + pd_result = pd_df["col0"].combine(pd_df["col1"], func) + bf_result = bf_df["col0"].combine(bf_df["col1"], func) + assert bigframes.dtypes.is_array_string_like(bf_result.dtype) + pd.testing.assert_series_equal( + pd_result, bf_result.to_pandas(), check_dtype=False, check_index_type=False + ) + + +def test_read_gbq_function_runs_existing_udf_4_params_array_output( + session, routine_id_unique +): + bigframes.session._io.bigquery.start_query_with_client( + session.bqclient, + textwrap.dedent( + f""" + CREATE OR REPLACE FUNCTION `{routine_id_unique}`(x STRING, y BOOL, z INT64, w FLOAT64) + RETURNS ARRAY + AS ( + [x, CAST(y AS STRING), CAST(z AS STRING), CAST(w AS STRING)] + ) + """ + ), + job_config=bigquery.QueryJobConfig(), + location=None, + project=None, + timeout=None, + metrics=None, + query_with_job=True, + publisher=bigframes.core.events.Publisher(), + ) + func = session.read_gbq_function(routine_id_unique) + + # Test on scalar value + got = func("hello", True, 1, 2.3) + assert got == ["hello", "true", "1", "2.3"] + + # Test on a dataframe, assert pandas parity + pd_df = pd.DataFrame( + { + "col0": ["alpha", "beta", "gamma"], + "col1": [True, False, True], + "col2": [1, 2, 3], + "col3": [4.5, 6, 7.75], + } + ) + bf_df = session.read_pandas(pd_df) + # Simulate the result directly, since the function cannot be applied + # directly on a pandas dataframe with axis=1, as this is a special type of + # function with multiple params supported only on bigframes dataframe. + pd_result = pd.Series( + [ + ["alpha", "true", "1", "4.5"], + ["beta", "false", "2", "6"], + ["gamma", "true", "3", "7.75"], + ] + ) + bf_result = bf_df.apply(func, axis=1) + assert bigframes.dtypes.is_array_string_like(bf_result.dtype) + pd.testing.assert_series_equal( + pd_result, bf_result.to_pandas(), check_dtype=False, check_index_type=False + ) + + def test_read_gbq_function_reads_udfs(session, bigquery_client, dataset_id): dataset_ref = bigquery.DatasetReference.from_string(dataset_id) arg = bigquery.RoutineArgument( @@ -751,9 +917,13 @@ def test_read_gbq_function_reads_udfs(session, bigquery_client, dataset_id): ) # It should point to the named routine and yield the expected results. - assert square.bigframes_remote_function == str(routine.reference) + assert square.bigframes_bigquery_function == str(routine.reference) assert square.input_dtypes == (bigframes.dtypes.INT_DTYPE,) assert square.output_dtype == bigframes.dtypes.INT_DTYPE + assert ( + square.bigframes_bigquery_function_output_dtype + == bigframes.dtypes.INT_DTYPE + ) src = {"x": [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5]} @@ -767,12 +937,11 @@ def test_read_gbq_function_reads_udfs(session, bigquery_client, dataset_id): indirect_df = indirect_df.assign(y=indirect_df.x.apply(square)) converted_indirect_df = indirect_df.to_pandas() - assert_pandas_df_equal( + assert_frame_equal( direct_df, converted_indirect_df, ignore_order=True, check_index_type=False ) -@pytest.mark.flaky(retries=2, delay=120) def test_read_gbq_function_requires_explicit_types( session, bigquery_client, dataset_id ): @@ -824,7 +993,7 @@ def test_read_gbq_function_requires_explicit_types( ) with pytest.warns( bigframes.exceptions.UnknownDataTypeWarning, - match="missing input data types.*assume default data type", + match=r"missing input data types[\s\S]*assume default data type", ): bff.read_gbq_function( str(only_return_type_specified.reference), @@ -863,7 +1032,6 @@ def test_read_gbq_function_requires_explicit_types( ), ], ) -@pytest.mark.flaky(retries=2, delay=120) def test_read_gbq_function_respects_python_output_type( request, session_fixture, bigquery_client, dataset_id, array_type, expected_data ): @@ -906,7 +1074,6 @@ def test_read_gbq_function_respects_python_output_type( pytest.param(list[str], id="list-str"), ], ) -@pytest.mark.flaky(retries=2, delay=120) def test_read_gbq_function_supports_python_output_type_only_for_string_outputs( session, bigquery_client, dataset_id, array_type ): @@ -945,7 +1112,6 @@ def test_read_gbq_function_supports_python_output_type_only_for_string_outputs( pytest.param(list[str], id="list-str"), ], ) -@pytest.mark.flaky(retries=2, delay=120) def test_read_gbq_function_supported_python_output_type( session, bigquery_client, dataset_id, array_type ): @@ -985,25 +1151,11 @@ def test_df_apply_scalar_func(session, scalars_dfs): with pytest.raises(NotImplementedError) as context: bdf.apply(func_ref) assert str(context.value) == ( - "BigFrames DataFrame '.apply()' does not support remote function for " - "column-wise (i.e. with axis=0) operations, please use a regular python " - "function instead. For element-wise operations of the remote function, " - "please use '.map()'." - ) - - -@pytest.mark.flaky(retries=2, delay=120) -def test_read_gbq_function_multiple_inputs_not_a_row_processor(session): - with pytest.raises(ValueError) as context: - # The remote function has two args, which cannot be row processed. Throw - # a ValueError for it. - session.read_gbq_function( - function_name="bqutil.fn.cw_regexp_instr_2", - is_row_processor=True, - ) - assert str(context.value) == ( - "A multi-input function cannot be a row processor. A row processor function " - "takes in a single input representing the row." + "BigFrames DataFrame '.apply()' does not support BigFrames BigQuery " + "function for column-wise (i.e. with axis=0) operations, please use a " + "regular python function instead. For element-wise operations of the " + "BigFrames BigQuery function, please use '.map()'. " + f"{constants.FEEDBACK_LINK}" ) @@ -1019,7 +1171,7 @@ def test_df_apply_axis_1(session, scalars_dfs, dataset_id_permanent): ] scalars_df, scalars_pandas_df = scalars_dfs - def add_ints(row): + def add_ints(row: pandas.Series) -> int: return row["int64_col"] + row["int64_too"] with pytest.warns( @@ -1027,12 +1179,12 @@ def add_ints(row): match="input_types=Series is in preview.", ): add_ints_remote = session.remote_function( - bigframes.series.Series, - int, - dataset_id_permanent, - name=get_rf_name(add_ints, is_row_processor=True), + dataset=dataset_id_permanent, + name=get_function_name(add_ints, is_row_processor=True), + cloud_function_service_account="default", )(add_ints) assert add_ints_remote.bigframes_remote_function # type: ignore + assert add_ints_remote.bigframes_bigquery_function # type: ignore assert add_ints_remote.bigframes_cloud_function # type: ignore with pytest.warns( @@ -1054,11 +1206,13 @@ def add_ints(row): # Read back the deployed BQ remote function using read_gbq_function. func_ref = session.read_gbq_function( - function_name=add_ints_remote.bigframes_remote_function, # type: ignore + function_name=add_ints_remote.bigframes_bigquery_function, # type: ignore is_row_processor=True, ) assert func_ref.bigframes_remote_function == add_ints_remote.bigframes_remote_function # type: ignore + assert func_ref.bigframes_bigquery_function == add_ints_remote.bigframes_bigquery_function # type: ignore + assert func_ref.bigframes_remote_function == func_ref.bigframes_bigquery_function # type: ignore bf_result_gbq = scalars_df[columns].apply(func_ref, axis=1).to_pandas() pd.testing.assert_series_equal( @@ -1072,14 +1226,15 @@ def test_df_apply_axis_1_ordering(session, scalars_dfs, dataset_id_permanent): ordering_columns = ["bool_col", "int64_col"] scalars_df, scalars_pandas_df = scalars_dfs - def add_ints(row): + def add_ints(row: pandas.Series) -> int: return row["int64_col"] + row["int64_too"] add_ints_remote = session.remote_function( - bigframes.series.Series, - int, - dataset_id_permanent, - name=get_rf_name(add_ints, is_row_processor=True), + input_types=pandas.Series, + output_type=int, + dataset=dataset_id_permanent, + name=get_function_name(add_ints, is_row_processor=True), + cloud_function_service_account="default", )(add_ints) bf_result = ( @@ -1115,10 +1270,11 @@ def add_numbers(row): return row["x"] + row["y"] add_numbers_remote = session.remote_function( - bigframes.series.Series, - float, - dataset_id_permanent, - name=get_rf_name(add_numbers, is_row_processor=True), + input_types=pandas.Series, + output_type=float, + dataset=dataset_id_permanent, + name=get_function_name(add_numbers, is_row_processor=True), + cloud_function_service_account="default", )(add_numbers) bf_result = bf_df.apply(add_numbers_remote, axis=1).to_pandas() @@ -1145,7 +1301,9 @@ def add_ints(row): # pandas works scalars_pandas_df.apply(add_ints, axis=1) - with pytest.raises(ValueError, match="For axis=1 a remote function must be used."): + with pytest.raises( + ValueError, match="For axis=1 a BigFrames BigQuery function must be used." + ): scalars_df[columns].apply(add_ints, axis=1) @@ -1166,10 +1324,11 @@ def echo_len(row): return len(row) echo_len_remote = session.remote_function( - bigframes.series.Series, - float, - dataset_id_permanent, - name=get_rf_name(echo_len, is_row_processor=True), + input_types=pandas.Series, + output_type=float, + dataset=dataset_id_permanent, + name=get_function_name(echo_len, is_row_processor=True), + cloud_function_service_account="default", )(echo_len) for column in columns_with_not_supported_dtypes: @@ -1202,7 +1361,9 @@ def should_mask(name: str) -> bool: assert "name" in inspect.signature(should_mask).parameters should_mask = session.remote_function( - dataset=dataset_id_permanent, name=get_rf_name(should_mask) + dataset=dataset_id_permanent, + name=get_function_name(should_mask), + cloud_function_service_account="default", )(should_mask) s = bigframes.series.Series(["Alice", "Bob", "Caroline"]) @@ -1214,20 +1375,19 @@ def should_mask(name: str) -> bool: repr(s.mask(should_mask, "REDACTED")) -@pytest.mark.flaky(retries=2, delay=120) -def test_read_gbq_function_application_repr(session, dataset_id, scalars_df_index): - gbq_function = f"{dataset_id}.should_mask" - +def test_read_gbq_function_application_repr( + session, routine_id_unique, scalars_df_index +): # This function deliberately has a param with name "name", this is to test # a specific ibis' internal handling of object names session.bqclient.query_and_wait( - f"CREATE OR REPLACE FUNCTION `{gbq_function}`(name STRING) RETURNS BOOL AS (MOD(LENGTH(name), 2) = 1)" + f"CREATE OR REPLACE FUNCTION `{routine_id_unique}`(name STRING) RETURNS BOOL AS (MOD(LENGTH(name), 2) = 1)" ) - routine = session.bqclient.get_routine(gbq_function) + routine = session.bqclient.get_routine(routine_id_unique) assert "name" in [arg.name for arg in routine.arguments] # read the function and apply to dataframe - should_mask = session.read_gbq_function(gbq_function) + should_mask = session.read_gbq_function(routine_id_unique) s = scalars_df_index["string_col"] @@ -1262,7 +1422,9 @@ def is_odd(x: int) -> bool: # create a remote function is_odd_remote = session.remote_function( - dataset=dataset_id_permanent, name=get_rf_name(is_odd) + dataset=dataset_id_permanent, + name=get_function_name(is_odd), + cloud_function_service_account="default", )(is_odd) # with nulls in the series the remote function application would fail @@ -1312,7 +1474,9 @@ def add(x: int, y: int) -> int: # create a remote function add_remote = session.remote_function( - dataset=dataset_id_permanent, name=get_rf_name(add) + dataset=dataset_id_permanent, + name=get_function_name(add), + cloud_function_service_account="default", )(add) # with nulls in the series the remote function application would fail @@ -1365,7 +1529,9 @@ def add(x: int, y: int, z: float) -> float: # create a remote function add_remote = session.remote_function( - dataset=dataset_id_permanent, name=get_rf_name(add) + dataset=dataset_id_permanent, + name=get_function_name(add), + cloud_function_service_account="default", )(add) # pandas does not support nary functions, so let's create a proxy function @@ -1419,7 +1585,9 @@ def is_long_duration(minutes: int) -> bool: return minutes >= 120 is_long_duration = unordered_session.remote_function( - dataset=dataset_id_permanent, name=get_rf_name(is_long_duration) + dataset=dataset_id_permanent, + name=get_function_name(is_long_duration), + cloud_function_service_account="default", )(is_long_duration) method = getattr(df["duration_minutes"], method) @@ -1438,7 +1606,9 @@ def combiner(x: int, y: int) -> int: return x combiner = unordered_session.remote_function( - dataset=dataset_id_permanent, name=get_rf_name(combiner) + dataset=dataset_id_permanent, + name=get_function_name(combiner), + cloud_function_service_account="default", )(combiner) df = scalars_df_index[["int64_col", "int64_too", "float64_col", "string_col"]] @@ -1454,9 +1624,37 @@ def processor(x: int, y: int, z: float, w: str) -> str: return f"I got x={x}, y={y}, z={z} and w={w}" processor = unordered_session.remote_function( - dataset=dataset_id_permanent, name=get_rf_name(processor) + dataset=dataset_id_permanent, + name=get_function_name(processor), + cloud_function_service_account="default", )(processor) df = scalars_df_index[["int64_col", "int64_too", "float64_col", "string_col"]] df1 = df.assign(combined=df.apply(processor, axis=1)) repr(df1) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_unsupported_type( + session, + dataset_id_permanent, + bq_cf_connection, +): + # Remote functions do not support tuple return types. + def func_tuple(x): + return (x, x, x) + + with pytest.raises( + ValueError, + match=r"must be one of the supported types", + ): + bff.remote_function( + input_types=int, + output_type=Sequence[int], + session=session, + dataset=dataset_id_permanent, + bigquery_connection=bq_cf_connection, + reuse=True, + name=get_function_name(func_tuple), + cloud_function_service_account="default", + )(func_tuple) diff --git a/tests/system/small/geopandas/test_geoseries.py b/tests/system/small/geopandas/test_geoseries.py index b27009d9d8..a2f0759161 100644 --- a/tests/system/small/geopandas/test_geoseries.py +++ b/tests/system/small/geopandas/test_geoseries.py @@ -12,19 +12,29 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import re import bigframes_vendored.constants as constants import geopandas # type: ignore from geopandas.array import GeometryDtype # type:ignore +import geopandas.testing # type:ignore import google.api_core.exceptions import pandas as pd import pytest -from shapely.geometry import LineString, Point, Polygon # type: ignore +from shapely.geometry import ( # type: ignore + GeometryCollection, + LineString, + Point, + Polygon, +) import bigframes.geopandas +import bigframes.pandas import bigframes.series -from tests.system.utils import assert_series_equal +import bigframes.session +from bigframes.testing.utils import assert_series_equal @pytest.fixture(scope="session") @@ -68,7 +78,7 @@ def test_geo_y(urban_areas_dfs): ) -def test_geo_area_not_supported(): +def test_geo_area_not_supported(session: bigframes.session.Session): s = bigframes.pandas.Series( [ Polygon([(0, 0), (1, 1), (0, 1)]), @@ -78,6 +88,7 @@ def test_geo_area_not_supported(): Point(0, 1), ], dtype=GeometryDtype(), + session=session, ) bf_series: bigframes.geopandas.GeoSeries = s.geo with pytest.raises( @@ -89,11 +100,51 @@ def test_geo_area_not_supported(): bf_series.area -def test_geo_from_xy(): +def test_geoseries_length_property_not_implemented(session): + gs = bigframes.geopandas.GeoSeries([Point(0, 0)], session=session) + with pytest.raises( + NotImplementedError, + match=re.escape( + "GeoSeries.length is not yet implemented. Please use bigframes.bigquery.st_length(geoseries) instead." + ), + ): + _ = gs.length + + +def test_geo_distance_not_supported(session: bigframes.session.Session): + s1 = bigframes.pandas.Series( + [ + Polygon([(0, 0), (1, 1), (0, 1)]), + Polygon([(10, 0), (10, 5), (0, 0)]), + Polygon([(0, 0), (2, 2), (2, 0)]), + LineString([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ], + dtype=GeometryDtype(), + session=session, + ) + s2 = bigframes.geopandas.GeoSeries( + [ + Polygon([(0, 0), (1, 1), (0, 1)]), + Polygon([(10, 0), (10, 5), (0, 0)]), + Polygon([(0, 0), (2, 2), (2, 0)]), + LineString([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ], + session=session, + ) + with pytest.raises( + NotImplementedError, + match=re.escape("GeoSeries.distance is not supported."), + ): + s1.geo.distance(s2) + + +def test_geo_from_xy(session: bigframes.session.Session): x = [2.5, 5, -3.0] y = [0.5, 1, 1.5] bf_result = ( - bigframes.geopandas.GeoSeries.from_xy(x, y) + bigframes.geopandas.GeoSeries.from_xy(x, y, session=session) .astype(geopandas.array.GeometryDtype()) .to_pandas() ) @@ -109,7 +160,7 @@ def test_geo_from_xy(): ) -def test_geo_from_wkt(): +def test_geo_from_wkt(session: bigframes.session.Session): wkts = [ "Point(0 1)", "Point(2 4)", @@ -117,7 +168,9 @@ def test_geo_from_wkt(): "Point(6 8)", ] - bf_result = bigframes.geopandas.GeoSeries.from_wkt(wkts).to_pandas() + bf_result = bigframes.geopandas.GeoSeries.from_wkt( + wkts, session=session + ).to_pandas() pd_result = geopandas.GeoSeries.from_wkt(wkts) @@ -129,14 +182,15 @@ def test_geo_from_wkt(): ) -def test_geo_to_wkt(): +def test_geo_to_wkt(session: bigframes.session.Session): bf_geo = bigframes.geopandas.GeoSeries( [ Point(0, 1), Point(2, 4), Point(5, 3), Point(6, 8), - ] + ], + session=session, ) pd_geo = geopandas.GeoSeries( @@ -162,3 +216,362 @@ def test_geo_to_wkt(): pd_result, check_index=False, ) + + +def test_geo_boundary(session: bigframes.session.Session): + bf_s = bigframes.series.Series( + [ + Polygon([(0, 0), (1, 1), (0, 1)]), + Polygon([(10, 0), (10, 5), (0, 0)]), + Polygon([(0, 0), (2, 2), (2, 0)]), + LineString([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ], + session=session, + ) + + pd_s = geopandas.GeoSeries( + [ + Polygon([(0, 0), (1, 1), (0, 1)]), + Polygon([(10, 0), (10, 5), (0, 0)]), + Polygon([(0, 0), (2, 2), (2, 0)]), + LineString([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ], + index=pd.Index([0, 1, 2, 3, 4], dtype="Int64"), + crs="WGS84", + ) + + bf_result = bf_s.geo.boundary.to_pandas() + pd_result = pd_s.boundary + + geopandas.testing.assert_geoseries_equal( + bf_result, + pd_result, + check_series_type=False, + check_index_type=False, + ) + + +# the GeoSeries and GeoPandas results are not always the same. +# For example, when the difference between two polygons is empty, +# GeoPandas returns 'POLYGON EMPTY' while GeoSeries returns 'GeometryCollection([])'. +# This is why we are hard-coding the expected results. +def test_geo_difference_with_geometry_objects(session: bigframes.session.Session): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + Point(0, 1), + ] + + data2 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + LineString([(2, 0), (0, 2)]), + ] + + bf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + bf_s2 = bigframes.geopandas.GeoSeries(data=data2, session=session) + + bf_result = bf_s1.difference(bf_s2).to_pandas() + + expected = bigframes.geopandas.GeoSeries( + [ + Polygon([]), + Polygon([]), + Point(0, 1), + ], + index=[0, 1, 2], + session=session, + ).to_pandas() + + assert bf_result.dtype == "geometry" + assert expected.iloc[0].equals(bf_result.iloc[0]) + assert expected.iloc[1].equals(bf_result.iloc[1]) + assert expected.iloc[2].equals(bf_result.iloc[2]) + + +def test_geo_difference_with_single_geometry_object(session: bigframes.session.Session): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(4, 2), (6, 2), (8, 6), (4, 2)]), + Point(0, 1), + ] + + bf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + bf_result = bf_s1.difference( + bigframes.geopandas.GeoSeries( + [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(1, 0), (0, 5), (0, 0), (1, 0)]), + ], + session=session, + ), + ).to_pandas() + + expected = bigframes.geopandas.GeoSeries( + [ + GeometryCollection([]), + Polygon([(4, 2), (6, 2), (8, 6), (4, 2)]), + None, + ], + index=[0, 1, 2], + session=session, + ).to_pandas() + + assert bf_result.dtype == "geometry" + assert (expected.iloc[0]).equals(bf_result.iloc[0]) + assert expected.iloc[1] == bf_result.iloc[1] + assert expected.iloc[2] == bf_result.iloc[2] + + +def test_geo_difference_with_similar_geometry_objects( + session: bigframes.session.Session, +): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ] + + bf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + bf_result = bf_s1.difference(bf_s1).to_pandas() + + expected = bigframes.geopandas.GeoSeries( + [GeometryCollection([]), GeometryCollection([]), GeometryCollection([])], + index=[0, 1, 2], + session=session, + ).to_pandas() + + assert bf_result.dtype == "geometry" + assert expected.iloc[0].equals(bf_result.iloc[0]) + assert expected.iloc[1].equals(bf_result.iloc[1]) + assert expected.iloc[2].equals(bf_result.iloc[2]) + + +def test_geo_drop_duplicates(session: bigframes.session.Session): + bf_series = bigframes.geopandas.GeoSeries( + [Point(1, 1), Point(2, 2), Point(3, 3), Point(2, 2)], + session=session, + ) + + pd_series = geopandas.GeoSeries( + [Point(1, 1), Point(2, 2), Point(3, 3), Point(2, 2)] + ) + + bf_result = bf_series.drop_duplicates().to_pandas() + pd_result = pd_series.drop_duplicates() + + pd.testing.assert_series_equal( + geopandas.GeoSeries(bf_result), pd_result, check_index=False + ) + + +# the GeoSeries and GeoPandas results are not always the same. +# For example, when the intersection between two polygons is empty, +# GeoPandas returns 'POLYGON EMPTY' while GeoSeries returns 'GeometryCollection([])'. +# This is why we are hard-coding the expected results. +def test_geo_intersection_with_geometry_objects(session: bigframes.session.Session): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + Point(0, 1), + ] + + data2 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + LineString([(2, 0), (0, 2)]), + ] + + bf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + bf_s2 = bigframes.geopandas.GeoSeries(data=data2, session=session) + + bf_result = bf_s1.intersection(bf_s2).to_pandas() + + expected = bigframes.geopandas.GeoSeries( + [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]), + GeometryCollection([]), + ], + session=session, + ).to_pandas() + + assert bf_result.dtype == "geometry" + assert expected.iloc[0].equals(bf_result.iloc[0]) + assert expected.iloc[1].equals(bf_result.iloc[1]) + assert expected.iloc[2].equals(bf_result.iloc[2]) + + +def test_geo_intersection_with_single_geometry_object( + session: bigframes.session.Session, +): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(4, 2), (6, 2), (8, 6), (4, 2)]), + Point(0, 1), + ] + + bf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + bf_result = bf_s1.intersection( + bigframes.geopandas.GeoSeries( + [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(1, 0), (0, 5), (0, 0), (1, 0)]), + ], + session=session, + ), + ).to_pandas() + + expected = bigframes.geopandas.GeoSeries( + [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + GeometryCollection([]), + None, + ], + index=[0, 1, 2], + session=session, + ).to_pandas() + + assert bf_result.dtype == "geometry" + assert (expected.iloc[0]).equals(bf_result.iloc[0]) + assert expected.iloc[1] == bf_result.iloc[1] + assert expected.iloc[2] == bf_result.iloc[2] + + +def test_geo_intersection_with_similar_geometry_objects( + session: bigframes.session.Session, +): + data1 = [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ] + + bf_s1 = bigframes.geopandas.GeoSeries(data=data1, session=session) + bf_result = bf_s1.intersection(bf_s1).to_pandas() + + expected = bigframes.geopandas.GeoSeries( + [ + Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]), + Polygon([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ], + index=[0, 1, 2], + session=session, + ).to_pandas() + + assert bf_result.dtype == "geometry" + assert expected.iloc[0].equals(bf_result.iloc[0]) + assert expected.iloc[1].equals(bf_result.iloc[1]) + assert expected.iloc[2].equals(bf_result.iloc[2]) + + +def test_geo_is_closed_not_supported(session: bigframes.session.Session): + s = bigframes.series.Series( + [ + Polygon([(0, 0), (1, 1), (0, 1)]), + Polygon([(10, 0), (10, 5), (0, 0)]), + Polygon([(0, 0), (2, 2), (2, 0)]), + LineString([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ], + dtype=GeometryDtype(), + session=session, + ) + bf_series: bigframes.geopandas.GeoSeries = s.geo + with pytest.raises( + NotImplementedError, + match=re.escape( + f"GeoSeries.is_closed is not supported. Use bigframes.bigquery.st_isclosed(series), instead. {constants.FEEDBACK_LINK}" + ), + ): + bf_series.is_closed + + +def test_geo_buffer_raises_notimplemented(session: bigframes.session.Session): + """GeoPandas takes distance in units of the coordinate system, but BigQuery + uses meters. + """ + s = bigframes.geopandas.GeoSeries( + [ + Point(0, 0), + ], + session=session, + ) + with pytest.raises( + NotImplementedError, match=re.escape("bigframes.bigquery.st_buffer") + ): + s.buffer(1000) + + +def test_geo_centroid(session: bigframes.session.Session): + bf_s = bigframes.series.Series( + [ + Polygon([(0, 0), (0.1, 0.1), (0, 0.1)]), + LineString([(10, 10), (10.0001, 10.0001), (10, 10.0001)]), + Point(-10, -10), + ], + session=session, + ) + + pd_s = geopandas.GeoSeries( + [ + Polygon([(0, 0), (0.1, 0.1), (0, 0.1)]), + LineString([(10, 10), (10.0001, 10.0001), (10, 10.0001)]), + Point(-10, -10), + ], + index=pd.Index([0, 1, 2], dtype="Int64"), + crs="WGS84", + ) + + bf_result = bf_s.geo.centroid.to_pandas() + # Avoid warning that centroid is incorrect for geographic CRS. + # https://gis.stackexchange.com/a/401815/275289 + pd_result = pd_s.to_crs("+proj=cea").centroid.to_crs("WGS84") + + geopandas.testing.assert_geoseries_equal( + bf_result, + pd_result, + check_series_type=False, + check_index_type=False, + # BigQuery geography calculations are on a sphere, so results will be + # slightly different. + check_less_precise=True, + ) + + +def test_geo_convex_hull(session: bigframes.session.Session): + bf_s = bigframes.series.Series( + [ + Polygon([(0, 0), (1, 1), (0, 1)]), + Polygon([(10, 0), (10, 5), (0, 0)]), + Polygon([(0, 0), (2, 2), (2, 0)]), + LineString([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ], + session=session, + ) + + pd_s = geopandas.GeoSeries( + [ + Polygon([(0, 0), (1, 1), (0, 1)]), + Polygon([(10, 0), (10, 5), (0, 0)]), + Polygon([(0, 0), (2, 2), (2, 0)]), + LineString([(0, 0), (1, 1), (0, 1)]), + Point(0, 1), + ], + index=pd.Index([0, 1, 2, 3, 4], dtype="Int64"), + crs="WGS84", + ) + + bf_result = bf_s.geo.convex_hull.to_pandas() + pd_result = pd_s.convex_hull + + geopandas.testing.assert_geoseries_equal( + bf_result, + pd_result, + check_series_type=False, + check_index_type=False, + ) diff --git a/tests/system/small/ml/conftest.py b/tests/system/small/ml/conftest.py index 0e8489c513..c735dbc76b 100644 --- a/tests/system/small/ml/conftest.py +++ b/tests/system/small/ml/conftest.py @@ -29,7 +29,6 @@ globals, imported, linear_model, - llm, remote, ) @@ -193,64 +192,6 @@ def xgboost_iris_df(session, xgboost_iris_pandas_df): return session.read_pandas(xgboost_iris_pandas_df) -@pytest.fixture(scope="session") -def bqml_palm2_text_generator_model(session, bq_connection) -> core.BqmlModel: - options = { - "remote_service_type": "CLOUD_AI_LARGE_LANGUAGE_MODEL_V1", - } - return globals.bqml_model_factory().create_remote_model( - session=session, connection_name=bq_connection, options=options - ) - - -@pytest.fixture(scope="session") -def palm2_text_generator_model(session, bq_connection) -> llm.PaLM2TextGenerator: - return llm.PaLM2TextGenerator(session=session, connection_name=bq_connection) - - -@pytest.fixture(scope="session") -def palm2_text_generator_32k_model(session, bq_connection) -> llm.PaLM2TextGenerator: - return llm.PaLM2TextGenerator( - model_name="text-bison-32k", session=session, connection_name=bq_connection - ) - - -@pytest.fixture(scope="function") -def ephemera_palm2_text_generator_model( - session, bq_connection -) -> llm.PaLM2TextGenerator: - return llm.PaLM2TextGenerator(session=session, connection_name=bq_connection) - - -@pytest.fixture(scope="session") -def palm2_embedding_generator_model( - session, bq_connection -) -> llm.PaLM2TextEmbeddingGenerator: - return llm.PaLM2TextEmbeddingGenerator( - session=session, connection_name=bq_connection - ) - - -@pytest.fixture(scope="session") -def palm2_embedding_generator_model_002( - session, bq_connection -) -> llm.PaLM2TextEmbeddingGenerator: - return llm.PaLM2TextEmbeddingGenerator( - version="002", session=session, connection_name=bq_connection - ) - - -@pytest.fixture(scope="session") -def palm2_embedding_generator_multilingual_model( - session, bq_connection -) -> llm.PaLM2TextEmbeddingGenerator: - return llm.PaLM2TextEmbeddingGenerator( - model_name="textembedding-gecko-multilingual", - session=session, - connection_name=bq_connection, - ) - - @pytest.fixture(scope="session") def linear_remote_model_params() -> dict: # Pre-deployed endpoint of linear reg model in Vertex. diff --git a/tests/system/small/ml/test_cluster.py b/tests/system/small/ml/test_cluster.py index 96066e5fbe..2a5e979b30 100644 --- a/tests/system/small/ml/test_cluster.py +++ b/tests/system/small/ml/test_cluster.py @@ -16,7 +16,7 @@ from bigframes.ml import cluster import bigframes.pandas as bpd -from tests.system.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal _PD_NEW_PENGUINS = pd.DataFrame.from_dict( { @@ -71,7 +71,7 @@ def test_kmeans_predict(session, penguins_kmeans_model: cluster.KMeans): dtype="Int64", index=pd.Index(["test1", "test2", "test3", "test4"], dtype="string[pyarrow]"), ) - assert_pandas_df_equal(result, expected, ignore_order=True) + assert_frame_equal(result, expected, ignore_order=True) def test_kmeans_detect_anomalies( diff --git a/tests/system/small/ml/test_core.py b/tests/system/small/ml/test_core.py index 1c2591b90a..9add4a4a53 100644 --- a/tests/system/small/ml/test_core.py +++ b/tests/system/small/ml/test_core.py @@ -23,7 +23,7 @@ import bigframes import bigframes.features from bigframes.ml import core -from tests.system import utils +from bigframes.testing import utils def test_model_eval( @@ -233,7 +233,7 @@ def test_pca_model_principal_component_info(penguins_bqml_pca_model: core.BqmlMo "cumulative_explained_variance_ratio": [0.469357, 0.651283, 0.812383], }, ) - utils.assert_pandas_df_equal( + utils.assert_frame_equal( result, expected, check_exact=False, @@ -390,26 +390,6 @@ def test_remote_model_predict( ) -@pytest.mark.flaky(retries=2) -def test_model_generate_text( - bqml_palm2_text_generator_model: core.BqmlModel, llm_text_df -): - options = { - "temperature": 0.5, - "max_output_tokens": 100, - "top_k": 20, - "top_p": 0.5, - "flatten_json_output": True, - } - df = bqml_palm2_text_generator_model.generate_text( - llm_text_df, options=options - ).to_pandas() - - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - @pytest.mark.parametrize("id_col_name", [None, "id"]) def test_model_forecast( time_series_bqml_arima_plus_model: core.BqmlModel, diff --git a/tests/system/small/ml/test_decomposition.py b/tests/system/small/ml/test_decomposition.py index 9eb9b25ea1..297ee49739 100644 --- a/tests/system/small/ml/test_decomposition.py +++ b/tests/system/small/ml/test_decomposition.py @@ -16,7 +16,7 @@ from bigframes.ml import decomposition import bigframes.pandas as bpd -import tests.system.utils +import bigframes.testing.utils def test_pca_predict( @@ -33,7 +33,7 @@ def test_pca_predict( index=pd.Index([1633, 1672, 1690], name="tag_number", dtype="Int64"), ) - tests.system.utils.assert_pandas_df_equal_pca( + bigframes.testing.utils.assert_pandas_df_equal_pca( predictions, expected, check_exact=False, rtol=0.1 ) @@ -161,7 +161,7 @@ def test_pca_components_(penguins_pca_model: decomposition.PCA): .reset_index(drop=True) ) - tests.system.utils.assert_pandas_df_equal_pca_components( + bigframes.testing.utils.assert_pandas_df_equal_pca_components( result, expected, check_exact=False, @@ -180,7 +180,7 @@ def test_pca_explained_variance_(penguins_pca_model: decomposition.PCA): "explained_variance": [3.278657, 1.270829, 1.125354], }, ) - tests.system.utils.assert_pandas_df_equal( + bigframes.testing.utils.assert_frame_equal( result, expected, check_exact=False, @@ -200,7 +200,7 @@ def test_pca_explained_variance_ratio_(penguins_pca_model: decomposition.PCA): "explained_variance_ratio": [0.469357, 0.181926, 0.1611], }, ) - tests.system.utils.assert_pandas_df_equal( + bigframes.testing.utils.assert_frame_equal( result, expected, check_exact=False, diff --git a/tests/system/small/ml/test_forecasting.py b/tests/system/small/ml/test_forecasting.py index d1b6b18fbe..134f82e96e 100644 --- a/tests/system/small/ml/test_forecasting.py +++ b/tests/system/small/ml/test_forecasting.py @@ -432,8 +432,10 @@ def test_arima_plus_detect_anomalies_params( }, ) pd.testing.assert_frame_equal( - anomalies[["is_anomaly", "lower_bound", "upper_bound", "anomaly_probability"]], - expected, + anomalies[["is_anomaly", "lower_bound", "upper_bound", "anomaly_probability"]] + .sort_values("anomaly_probability") + .reset_index(drop=True), + expected.sort_values("anomaly_probability").reset_index(drop=True), rtol=0.1, check_index_type=False, check_dtype=False, @@ -449,11 +451,16 @@ def test_arima_plus_score( id_col_name, ): if id_col_name: - result = time_series_arima_plus_model_w_id.score( - new_time_series_df_w_id[["parsed_date"]], - new_time_series_df_w_id[["total_visits"]], - new_time_series_df_w_id[["id"]], - ).to_pandas() + result = ( + time_series_arima_plus_model_w_id.score( + new_time_series_df_w_id[["parsed_date"]], + new_time_series_df_w_id[["total_visits"]], + new_time_series_df_w_id[["id"]], + ) + .to_pandas() + .sort_values("id") + .reset_index(drop=True) + ) else: result = time_series_arima_plus_model.score( new_time_series_df[["parsed_date"]], new_time_series_df[["total_visits"]] @@ -472,6 +479,8 @@ def test_arima_plus_score( ) expected["id"] = expected["id"].astype(str).str.replace(r"\.0$", "", regex=True) expected["id"] = expected["id"].astype("string[pyarrow]") + expected = expected.sort_values("id") + expected = expected.reset_index(drop=True) else: expected = pd.DataFrame( { @@ -488,6 +497,7 @@ def test_arima_plus_score( expected, rtol=0.1, check_index_type=False, + check_dtype=False, ) @@ -542,11 +552,16 @@ def test_arima_plus_score_series( id_col_name, ): if id_col_name: - result = time_series_arima_plus_model_w_id.score( - new_time_series_df_w_id["parsed_date"], - new_time_series_df_w_id["total_visits"], - new_time_series_df_w_id["id"], - ).to_pandas() + result = ( + time_series_arima_plus_model_w_id.score( + new_time_series_df_w_id["parsed_date"], + new_time_series_df_w_id["total_visits"], + new_time_series_df_w_id["id"], + ) + .to_pandas() + .sort_values("id") + .reset_index(drop=True) + ) else: result = time_series_arima_plus_model.score( new_time_series_df["parsed_date"], new_time_series_df["total_visits"] @@ -565,6 +580,8 @@ def test_arima_plus_score_series( ) expected["id"] = expected["id"].astype(str).str.replace(r"\.0$", "", regex=True) expected["id"] = expected["id"].astype("string[pyarrow]") + expected = expected.sort_values("id") + expected = expected.reset_index(drop=True) else: expected = pd.DataFrame( { @@ -581,6 +598,7 @@ def test_arima_plus_score_series( expected, rtol=0.1, check_index_type=False, + check_dtype=False, ) diff --git a/tests/system/small/ml/test_llm.py b/tests/system/small/ml/test_llm.py index 90d5e9f1d7..d15c5d3160 100644 --- a/tests/system/small/ml/test_llm.py +++ b/tests/system/small/ml/test_llm.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Callable from unittest import mock import pandas as pd @@ -20,181 +21,7 @@ from bigframes import exceptions from bigframes.ml import core, llm import bigframes.pandas as bpd -from tests.system import utils - - -def test_create_load_text_generator_model( - palm2_text_generator_model, dataset_id, bq_connection -): - # Model creation doesn't return error - assert palm2_text_generator_model is not None - assert palm2_text_generator_model._bqml_model is not None - - # save, load to ensure configuration was kept - reloaded_model = palm2_text_generator_model.to_gbq( - f"{dataset_id}.temp_text_model", replace=True - ) - assert f"{dataset_id}.temp_text_model" == reloaded_model._bqml_model.model_name - assert reloaded_model.model_name == "text-bison" - assert reloaded_model.connection_name == bq_connection - - -def test_create_load_text_generator_32k_model( - palm2_text_generator_32k_model, dataset_id, bq_connection -): - # Model creation doesn't return error - assert palm2_text_generator_32k_model is not None - assert palm2_text_generator_32k_model._bqml_model is not None - - # save, load to ensure configuration was kept - reloaded_model = palm2_text_generator_32k_model.to_gbq( - f"{dataset_id}.temp_text_model", replace=True - ) - assert f"{dataset_id}.temp_text_model" == reloaded_model._bqml_model.model_name - assert reloaded_model.model_name == "text-bison-32k" - assert reloaded_model.connection_name == bq_connection - - -@pytest.mark.flaky(retries=2) -def test_create_text_generator_model_default_session( - bq_connection, llm_text_pandas_df, bigquery_client -): - import bigframes.pandas as bpd - - # Note: This starts a thread-local session. - with bpd.option_context( - "bigquery.bq_connection", - bq_connection, - "bigquery.location", - "US", - ): - model = llm.PaLM2TextGenerator() - assert model is not None - assert model._bqml_model is not None - assert ( - model.connection_name.casefold() - == f"{bigquery_client.project}.us.bigframes-rf-conn" - ) - - llm_text_df = bpd.read_pandas(llm_text_pandas_df) - - df = model.predict(llm_text_df).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -@pytest.mark.flaky(retries=2) -def test_create_text_generator_32k_model_default_session( - bq_connection, llm_text_pandas_df, bigquery_client -): - import bigframes.pandas as bpd - - # Note: This starts a thread-local session. - with bpd.option_context( - "bigquery.bq_connection", - bq_connection, - "bigquery.location", - "US", - ): - model = llm.PaLM2TextGenerator(model_name="text-bison-32k") - assert model is not None - assert model._bqml_model is not None - assert ( - model.connection_name.casefold() - == f"{bigquery_client.project}.us.bigframes-rf-conn" - ) - - llm_text_df = bpd.read_pandas(llm_text_pandas_df) - - df = model.predict(llm_text_df).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -@pytest.mark.flaky(retries=2) -def test_create_text_generator_model_default_connection( - llm_text_pandas_df, bigquery_client -): - from bigframes import _config - import bigframes.pandas as bpd - - bpd.close_session() - _config.options = _config.Options() # reset configs - - llm_text_df = bpd.read_pandas(llm_text_pandas_df) - - model = llm.PaLM2TextGenerator() - assert model is not None - assert model._bqml_model is not None - assert ( - model.connection_name.casefold() - == f"{bigquery_client.project}.us.bigframes-default-connection" - ) - - df = model.predict(llm_text_df).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -# Marked as flaky only because BQML LLM is in preview, the service only has limited capacity, not stable enough. -@pytest.mark.flaky(retries=2) -def test_text_generator_predict_default_params_success( - palm2_text_generator_model, llm_text_df -): - df = palm2_text_generator_model.predict(llm_text_df).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -@pytest.mark.flaky(retries=2) -def test_text_generator_predict_series_default_params_success( - palm2_text_generator_model, llm_text_df -): - df = palm2_text_generator_model.predict(llm_text_df["prompt"]).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -@pytest.mark.flaky(retries=2) -def test_text_generator_predict_arbitrary_col_label_success( - palm2_text_generator_model, llm_text_df -): - llm_text_df = llm_text_df.rename(columns={"prompt": "arbitrary"}) - df = palm2_text_generator_model.predict(llm_text_df).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -@pytest.mark.flaky(retries=2) -def test_text_generator_predict_multiple_cols_success( - palm2_text_generator_model, llm_text_df: bpd.DataFrame -): - df = llm_text_df.assign(additional_col=1) - pd_df = palm2_text_generator_model.predict(df).to_pandas() - utils.check_pandas_df_schema_and_index( - pd_df, - columns=utils.ML_GENERATE_TEXT_OUTPUT + ["additional_col"], - index=3, - col_exact=False, - ) - - -@pytest.mark.flaky(retries=2) -def test_text_generator_predict_with_params_success( - palm2_text_generator_model, llm_text_df -): - df = palm2_text_generator_model.predict( - llm_text_df, temperature=0.5, max_output_tokens=100, top_k=20, top_p=0.5 - ).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) +from bigframes.testing import utils @pytest.mark.parametrize( @@ -260,119 +87,21 @@ def test_text_embedding_generator_multi_cols_predict_success( assert len(pd_df["ml_generate_embedding_result"][0]) == 768 -@pytest.mark.parametrize( - "model_name", - ( - "gemini-pro", - "gemini-1.5-pro-preview-0514", - "gemini-1.5-flash-preview-0514", - "gemini-1.5-pro-001", - "gemini-1.5-pro-002", - "gemini-1.5-flash-001", - "gemini-1.5-flash-002", - "gemini-2.0-flash-exp", - ), -) -def test_create_load_gemini_text_generator_model( - dataset_id, model_name, session, bq_connection +def test_create_load_multimodal_embedding_generator_model( + dataset_id, session, bq_connection ): - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session + mm_embedding_model = llm.MultimodalEmbeddingGenerator( + connection_name=bq_connection, session=session ) - assert gemini_text_generator_model is not None - assert gemini_text_generator_model._bqml_model is not None + assert mm_embedding_model is not None + assert mm_embedding_model._bqml_model is not None # save, load to ensure configuration was kept - reloaded_model = gemini_text_generator_model.to_gbq( - f"{dataset_id}.temp_text_model", replace=True + reloaded_model = mm_embedding_model.to_gbq( + f"{dataset_id}.temp_mm_model", replace=True ) - assert f"{dataset_id}.temp_text_model" == reloaded_model._bqml_model.model_name + assert f"{dataset_id}.temp_mm_model" == reloaded_model._bqml_model.model_name assert reloaded_model.connection_name == bq_connection - assert reloaded_model.model_name == model_name - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-pro", - "gemini-1.5-pro-preview-0514", - "gemini-1.5-flash-preview-0514", - "gemini-1.5-pro-001", - "gemini-1.5-pro-002", - "gemini-1.5-flash-001", - "gemini-1.5-flash-002", - "gemini-2.0-flash-exp", - ), -) -@pytest.mark.flaky(retries=2) -def test_gemini_text_generator_predict_default_params_success( - llm_text_df, model_name, session, bq_connection -): - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - df = gemini_text_generator_model.predict(llm_text_df).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-pro", - "gemini-1.5-pro-preview-0514", - "gemini-1.5-flash-preview-0514", - "gemini-1.5-pro-001", - "gemini-1.5-pro-002", - "gemini-1.5-flash-001", - "gemini-1.5-flash-002", - "gemini-2.0-flash-exp", - ), -) -@pytest.mark.flaky(retries=2) -def test_gemini_text_generator_predict_with_params_success( - llm_text_df, model_name, session, bq_connection -): - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - df = gemini_text_generator_model.predict( - llm_text_df, temperature=0.5, max_output_tokens=100, top_k=20, top_p=0.5 - ).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-pro", - "gemini-1.5-pro-preview-0514", - "gemini-1.5-flash-preview-0514", - "gemini-1.5-pro-001", - "gemini-1.5-pro-002", - "gemini-1.5-flash-001", - "gemini-1.5-flash-002", - "gemini-2.0-flash-exp", - ), -) -@pytest.mark.flaky(retries=2) -def test_gemini_text_generator_multi_cols_predict_success( - llm_text_df: bpd.DataFrame, model_name, session, bq_connection -): - df = llm_text_df.assign(additional_col=1) - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - pd_df = gemini_text_generator_model.predict(df).to_pandas() - utils.check_pandas_df_schema_and_index( - pd_df, - columns=utils.ML_GENERATE_TEXT_OUTPUT + ["additional_col"], - index=3, - col_exact=False, - ) # Overrides __eq__ function for comparing as mock.call parameter @@ -381,6 +110,7 @@ def __eq__(self, other): return self.equals(other) +@pytest.mark.skip("b/436340035 test failed") @pytest.mark.parametrize( ( "model_class", @@ -392,9 +122,7 @@ def __eq__(self, other): { "temperature": 0.9, "max_output_tokens": 8192, - "top_k": 40, "top_p": 1.0, - "flatten_json_output": True, "ground_with_google_search": False, }, ), @@ -404,12 +132,16 @@ def __eq__(self, other): "max_output_tokens": 128, "top_k": 40, "top_p": 0.95, - "flatten_json_output": True, }, ), ], ) -def test_text_generator_retry_success(session, bq_connection, model_class, options): +def test_text_generator_retry_success( + session, + model_class, + options, + bq_connection, +): # Requests. df0 = EqCmpAllDataFrame( { @@ -444,11 +176,16 @@ def test_text_generator_retry_success(session, bq_connection, model_class, optio session=session, ) + mock_generate_text = mock.create_autospec( + Callable[[core.BqmlModel, bpd.DataFrame, dict], bpd.DataFrame] + ) mock_bqml_model = mock.create_autospec(spec=core.BqmlModel) type(mock_bqml_model).session = mock.PropertyMock(return_value=session) - + generate_text_tvf = core.BqmlModel.TvfDef( + mock_generate_text, "ml_generate_text_status" + ) # Responses. Retry twice then all succeeded. - mock_bqml_model.generate_text.side_effect = [ + mock_generate_text.side_effect = [ EqCmpAllDataFrame( { "ml_generate_text_status": ["", "error", "error"], @@ -487,34 +224,36 @@ def test_text_generator_retry_success(session, bq_connection, model_class, optio text_generator_model = model_class(connection_name=bq_connection, session=session) text_generator_model._bqml_model = mock_bqml_model - # 3rd retry isn't triggered - result = text_generator_model.predict(df0, max_retries=3) + with mock.patch.object(core.BqmlModel, "generate_text_tvf", generate_text_tvf): + # 3rd retry isn't triggered + result = text_generator_model.predict(df0, max_retries=3) - mock_bqml_model.generate_text.assert_has_calls( - [ - mock.call(df0, options), - mock.call(df1, options), - mock.call(df2, options), - ] - ) - pd.testing.assert_frame_equal( - result.to_pandas(), - pd.DataFrame( - { - "ml_generate_text_status": ["", "", ""], - "prompt": [ - "What is BigQuery?", - "What is BigQuery DataFrame?", - "What is BQML?", - ], - }, - index=[0, 2, 1], - ), - check_dtype=False, - check_index_type=False, - ) + mock_generate_text.assert_has_calls( + [ + mock.call(mock_bqml_model, df0, options), + mock.call(mock_bqml_model, df1, options), + mock.call(mock_bqml_model, df2, options), + ] + ) + pd.testing.assert_frame_equal( + result.to_pandas(), + pd.DataFrame( + { + "ml_generate_text_status": ["", "", ""], + "prompt": [ + "What is BigQuery?", + "What is BigQuery DataFrame?", + "What is BQML?", + ], + }, + index=[0, 2, 1], + ), + check_dtype=False, + check_index_type=False, + ) +@pytest.mark.skip("b/436340035 test failed") @pytest.mark.parametrize( ( "model_class", @@ -526,9 +265,7 @@ def test_text_generator_retry_success(session, bq_connection, model_class, optio { "temperature": 0.9, "max_output_tokens": 8192, - "top_k": 40, "top_p": 1.0, - "flatten_json_output": True, "ground_with_google_search": False, }, ), @@ -538,12 +275,11 @@ def test_text_generator_retry_success(session, bq_connection, model_class, optio "max_output_tokens": 128, "top_k": 40, "top_p": 0.95, - "flatten_json_output": True, }, ), ], ) -def test_text_generator_retry_no_progress(session, bq_connection, model_class, options): +def test_text_generator_retry_no_progress(session, model_class, options, bq_connection): # Requests. df0 = EqCmpAllDataFrame( { @@ -568,10 +304,16 @@ def test_text_generator_retry_no_progress(session, bq_connection, model_class, o session=session, ) + mock_generate_text = mock.create_autospec( + Callable[[core.BqmlModel, bpd.DataFrame, dict], bpd.DataFrame] + ) mock_bqml_model = mock.create_autospec(spec=core.BqmlModel) type(mock_bqml_model).session = mock.PropertyMock(return_value=session) + generate_text_tvf = core.BqmlModel.TvfDef( + mock_generate_text, "ml_generate_text_status" + ) # Responses. Retry once, no progress, just stop. - mock_bqml_model.generate_text.side_effect = [ + mock_generate_text.side_effect = [ EqCmpAllDataFrame( { "ml_generate_text_status": ["", "error", "error"], @@ -600,33 +342,35 @@ def test_text_generator_retry_no_progress(session, bq_connection, model_class, o text_generator_model = model_class(connection_name=bq_connection, session=session) text_generator_model._bqml_model = mock_bqml_model - # No progress, only conduct retry once - result = text_generator_model.predict(df0, max_retries=3) + with mock.patch.object(core.BqmlModel, "generate_text_tvf", generate_text_tvf): + # No progress, only conduct retry once + result = text_generator_model.predict(df0, max_retries=3) - mock_bqml_model.generate_text.assert_has_calls( - [ - mock.call(df0, options), - mock.call(df1, options), - ] - ) - pd.testing.assert_frame_equal( - result.to_pandas(), - pd.DataFrame( - { - "ml_generate_text_status": ["", "error", "error"], - "prompt": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[0, 1, 2], - ), - check_dtype=False, - check_index_type=False, - ) + mock_generate_text.assert_has_calls( + [ + mock.call(mock_bqml_model, df0, options), + mock.call(mock_bqml_model, df1, options), + ] + ) + pd.testing.assert_frame_equal( + result.to_pandas(), + pd.DataFrame( + { + "ml_generate_text_status": ["", "error", "error"], + "prompt": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[0, 1, 2], + ), + check_dtype=False, + check_index_type=False, + ) +@pytest.mark.skip("b/436340035 test failed") def test_text_embedding_generator_retry_success(session, bq_connection): # Requests. df0 = EqCmpAllDataFrame( @@ -662,11 +406,17 @@ def test_text_embedding_generator_retry_success(session, bq_connection): session=session, ) + mock_generate_embedding = mock.create_autospec( + Callable[[core.BqmlModel, bpd.DataFrame, dict], bpd.DataFrame] + ) mock_bqml_model = mock.create_autospec(spec=core.BqmlModel) type(mock_bqml_model).session = mock.PropertyMock(return_value=session) + generate_embedding_tvf = core.BqmlModel.TvfDef( + mock_generate_embedding, "ml_generate_embedding_status" + ) # Responses. Retry twice then all succeeded. - mock_bqml_model.generate_embedding.side_effect = [ + mock_generate_embedding.side_effect = [ EqCmpAllDataFrame( { "ml_generate_embedding_status": ["", "error", "error"], @@ -701,41 +451,42 @@ def test_text_embedding_generator_retry_success(session, bq_connection): session=session, ), ] - options = { - "flatten_json_output": True, - } + options: dict = {} text_embedding_model = llm.TextEmbeddingGenerator( connection_name=bq_connection, session=session ) text_embedding_model._bqml_model = mock_bqml_model - # 3rd retry isn't triggered - result = text_embedding_model.predict(df0, max_retries=3) - - mock_bqml_model.generate_embedding.assert_has_calls( - [ - mock.call(df0, options), - mock.call(df1, options), - mock.call(df2, options), - ] - ) - pd.testing.assert_frame_equal( - result.to_pandas(), - pd.DataFrame( - { - "ml_generate_embedding_status": ["", "", ""], - "content": [ - "What is BigQuery?", - "What is BigQuery DataFrame?", - "What is BQML?", - ], - }, - index=[0, 2, 1], - ), - check_dtype=False, - check_index_type=False, - ) + with mock.patch.object( + core.BqmlModel, "generate_embedding_tvf", generate_embedding_tvf + ): + # 3rd retry isn't triggered + result = text_embedding_model.predict(df0, max_retries=3) + + mock_generate_embedding.assert_has_calls( + [ + mock.call(mock_bqml_model, df0, options), + mock.call(mock_bqml_model, df1, options), + mock.call(mock_bqml_model, df2, options), + ] + ) + pd.testing.assert_frame_equal( + result.to_pandas(), + pd.DataFrame( + { + "ml_generate_embedding_status": ["", "", ""], + "content": [ + "What is BigQuery?", + "What is BigQuery DataFrame?", + "What is BQML?", + ], + }, + index=[0, 2, 1], + ), + check_dtype=False, + check_index_type=False, + ) def test_text_embedding_generator_retry_no_progress(session, bq_connection): @@ -763,10 +514,17 @@ def test_text_embedding_generator_retry_no_progress(session, bq_connection): session=session, ) + mock_generate_embedding = mock.create_autospec( + Callable[[core.BqmlModel, bpd.DataFrame, dict], bpd.DataFrame] + ) mock_bqml_model = mock.create_autospec(spec=core.BqmlModel) type(mock_bqml_model).session = mock.PropertyMock(return_value=session) + generate_embedding_tvf = core.BqmlModel.TvfDef( + mock_generate_embedding, "ml_generate_embedding_status" + ) + # Responses. Retry once, no progress, just stop. - mock_bqml_model.generate_embedding.side_effect = [ + mock_generate_embedding.side_effect = [ EqCmpAllDataFrame( { "ml_generate_embedding_status": ["", "error", "error"], @@ -791,166 +549,63 @@ def test_text_embedding_generator_retry_no_progress(session, bq_connection): session=session, ), ] - options = { - "flatten_json_output": True, - } + options: dict = {} text_embedding_model = llm.TextEmbeddingGenerator( connection_name=bq_connection, session=session ) text_embedding_model._bqml_model = mock_bqml_model - # No progress, only conduct retry once - result = text_embedding_model.predict(df0, max_retries=3) - - mock_bqml_model.generate_embedding.assert_has_calls( - [ - mock.call(df0, options), - mock.call(df1, options), - ] - ) - pd.testing.assert_frame_equal( - result.to_pandas(), - pd.DataFrame( - { - "ml_generate_embedding_status": ["", "error", "error"], - "content": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[0, 1, 2], - ), - check_dtype=False, - check_index_type=False, - ) - - -@pytest.mark.flaky(retries=2) -def test_llm_palm_score(llm_fine_tune_df_default_index): - model = llm.PaLM2TextGenerator(model_name="text-bison") - - # Check score to ensure the model was fitted - score_result = model.score( - X=llm_fine_tune_df_default_index[["prompt"]], - y=llm_fine_tune_df_default_index[["label"]], - ).to_pandas() - utils.check_pandas_df_schema_and_index( - score_result, - columns=[ - "bleu4_score", - "rouge-l_precision", - "rouge-l_recall", - "rouge-l_f1_score", - "evaluation_status", - ], - index=1, - ) - - -@pytest.mark.flaky(retries=2) -def test_llm_palm_score_params(llm_fine_tune_df_default_index): - model = llm.PaLM2TextGenerator(model_name="text-bison", max_iterations=1) - - # Check score to ensure the model was fitted - score_result = model.score( - X=llm_fine_tune_df_default_index["prompt"], - y=llm_fine_tune_df_default_index["label"], - task_type="classification", - ).to_pandas() - utils.check_pandas_df_schema_and_index( - score_result, - columns=[ - "precision", - "recall", - "f1_score", - "label", - "evaluation_status", - ], - ) - + with mock.patch.object( + core.BqmlModel, "generate_embedding_tvf", generate_embedding_tvf + ): + # No progress, only conduct retry once + result = text_embedding_model.predict(df0, max_retries=3) -@pytest.mark.flaky(retries=2) -@pytest.mark.parametrize( - "model_name", - ( - "gemini-pro", - "gemini-1.5-pro-002", - "gemini-1.5-flash-002", - ), -) -def test_llm_gemini_score(llm_fine_tune_df_default_index, model_name): - model = llm.GeminiTextGenerator(model_name=model_name) - - # Check score to ensure the model was fitted - score_result = model.score( - X=llm_fine_tune_df_default_index[["prompt"]], - y=llm_fine_tune_df_default_index[["label"]], - ).to_pandas() - utils.check_pandas_df_schema_and_index( - score_result, - columns=[ - "bleu4_score", - "rouge-l_precision", - "rouge-l_recall", - "rouge-l_f1_score", - "evaluation_status", - ], - index=1, - ) + mock_generate_embedding.assert_has_calls( + [ + mock.call(mock_bqml_model, df0, options), + mock.call(mock_bqml_model, df1, options), + ] + ) + pd.testing.assert_frame_equal( + result.to_pandas(), + pd.DataFrame( + { + "ml_generate_embedding_status": ["", "error", "error"], + "content": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[0, 1, 2], + ), + check_dtype=False, + check_index_type=False, + ) @pytest.mark.parametrize( "model_name", - ( - "gemini-pro", - "gemini-1.5-pro-002", - "gemini-1.5-flash-002", - ), + ("gemini-2.0-flash-exp",), ) -def test_llm_gemini_pro_score_params(llm_fine_tune_df_default_index, model_name): - model = llm.GeminiTextGenerator(model_name=model_name) - - # Check score to ensure the model was fitted - score_result = model.score( - X=llm_fine_tune_df_default_index["prompt"], - y=llm_fine_tune_df_default_index["label"], - task_type="classification", - ).to_pandas() - utils.check_pandas_df_schema_and_index( - score_result, - columns=[ - "precision", - "recall", - "f1_score", - "label", - "evaluation_status", - ], - ) - - -def test_palm2_text_generator_deprecated(): - with pytest.warns(exceptions.ApiDeprecationWarning): - llm.PaLM2TextGenerator() - - -def test_palm2_text_embedding_deprecated(): - with pytest.warns(exceptions.ApiDeprecationWarning): - try: - llm.PaLM2TextEmbeddingGenerator() - except (Exception): - pass +def test_gemini_preview_model_warnings(model_name): + with pytest.warns(exceptions.PreviewWarning): + llm.GeminiTextGenerator(model_name=model_name) +# b/436340035 temp disable the test to unblock presumbit @pytest.mark.parametrize( - "model_name", - ( - "gemini-1.5-pro-preview-0514", - "gemini-1.5-flash-preview-0514", - "gemini-2.0-flash-exp", - ), + "model_class", + [ + llm.TextEmbeddingGenerator, + llm.MultimodalEmbeddingGenerator, + llm.GeminiTextGenerator, + # llm.Claude3TextGenerator, + ], ) -def test_gemini_preview_model_warnings(model_name): - with pytest.warns(exceptions.PreviewWarning): - llm.GeminiTextGenerator(model_name=model_name) +def test_text_embedding_generator_no_default_model_warning(model_class): + message = "Since upgrading the default model can cause unintended breakages, the\ndefault model will be removed in BigFrames 3.0. Please supply an\nexplicit model to avoid this message." + with pytest.warns(FutureWarning, match=message): + model_class(model_name=None) diff --git a/tests/system/small/ml/test_metrics.py b/tests/system/small/ml/test_metrics.py index 81e1b2f77f..040d4d97f6 100644 --- a/tests/system/small/ml/test_metrics.py +++ b/tests/system/small/ml/test_metrics.py @@ -17,7 +17,6 @@ import numpy as np import pandas as pd import pytest -import sklearn.metrics as sklearn_metrics # type: ignore import bigframes from bigframes.ml import metrics @@ -66,6 +65,7 @@ def test_r2_score_force_finite(session): def test_r2_score_ok_fit_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame({"y_true": [1, 2, 3, 4, 5], "y_pred": [2, 3, 4, 3, 6]}) df = session.read_pandas(pd_df) @@ -113,6 +113,7 @@ def test_accuracy_score_not_normailze(session): def test_accuracy_score_fit_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame({"y_true": [1, 2, 3, 4, 5], "y_pred": [2, 3, 4, 3, 6]}) df = session.read_pandas(pd_df) @@ -203,6 +204,7 @@ def test_roc_curve_binary_classification_prediction_returns_expected(session): def test_roc_curve_binary_classification_prediction_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": [0, 0, 1, 1, 0, 1, 0, 1, 1, 1], @@ -294,6 +296,7 @@ def test_roc_curve_binary_classification_decision_returns_expected(session): def test_roc_curve_binary_classification_decision_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") # Instead of operating on probabilities, assume a 70% decision threshold # has been applied, and operate on the final output y_score = [0.1, 0.4, 0.35, 0.8, 0.65, 0.9, 0.5, 0.3, 0.6, 0.45] @@ -420,6 +423,7 @@ def test_roc_auc_score_returns_expected(session): def test_roc_auc_score_returns_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": [0, 0, 1, 1, 0, 1, 0, 1, 1, 1], @@ -525,6 +529,7 @@ def test_confusion_matrix_column_index(session): def test_confusion_matrix_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": [2, 3, 3, 3, 4, 1], @@ -543,6 +548,7 @@ def test_confusion_matrix_matches_sklearn(session): def test_confusion_matrix_str_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": ["cat", "ant", "cat", "cat", "ant", "bird"], @@ -603,6 +609,7 @@ def test_recall_score(session): def test_recall_score_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": [2, 0, 2, 2, 0, 1], @@ -620,6 +627,7 @@ def test_recall_score_matches_sklearn(session): def test_recall_score_str_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": ["cat", "ant", "cat", "cat", "ant", "bird"], @@ -673,6 +681,7 @@ def test_precision_score(session): def test_precision_score_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": [2, 0, 2, 2, 0, 1], @@ -695,6 +704,7 @@ def test_precision_score_matches_sklearn(session): def test_precision_score_str_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": ["cat", "ant", "cat", "cat", "ant", "bird"], @@ -733,6 +743,71 @@ def test_precision_score_series(session): ) +@pytest.mark.parametrize( + ("pos_label", "expected_score"), + [ + ("a", 1 / 3), + ("b", 0), + ], +) +def test_precision_score_binary(session, pos_label, expected_score): + pd_df = pd.DataFrame( + { + "y_true": ["a", "a", "a", "b", "b"], + "y_pred": ["b", "b", "a", "a", "a"], + } + ) + df = session.read_pandas(pd_df) + + precision_score = metrics.precision_score( + df["y_true"], df["y_pred"], average="binary", pos_label=pos_label + ) + + assert precision_score == pytest.approx(expected_score) + + +def test_precision_score_binary_default_arguments(session): + pd_df = pd.DataFrame( + { + "y_true": [1, 1, 1, 0, 0], + "y_pred": [0, 0, 1, 1, 1], + } + ) + df = session.read_pandas(pd_df) + + precision_score = metrics.precision_score(df["y_true"], df["y_pred"]) + + assert precision_score == pytest.approx(1 / 3) + + +@pytest.mark.parametrize( + ("y_true", "y_pred", "pos_label"), + [ + pytest.param( + pd.Series([1, 2, 3]), pd.Series([1, 0]), 1, id="y_true-non-binary-label" + ), + pytest.param( + pd.Series([1, 0]), pd.Series([1, 2, 3]), 1, id="y_pred-non-binary-label" + ), + pytest.param( + pd.Series([1, 0]), pd.Series([1, 2]), 1, id="combined-non-binary-label" + ), + pytest.param(pd.Series([1, 0]), pd.Series([1, 0]), 2, id="invalid-pos_label"), + ], +) +def test_precision_score_binary_invalid_input_raise_error( + session, y_true, y_pred, pos_label +): + + bf_y_true = session.read_pandas(y_true) + bf_y_pred = session.read_pandas(y_pred) + + with pytest.raises(ValueError): + metrics.precision_score( + bf_y_true, bf_y_pred, average="binary", pos_label=pos_label + ) + + def test_f1_score(session): pd_df = pd.DataFrame( { @@ -752,6 +827,7 @@ def test_f1_score(session): def test_f1_score_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": [2, 0, 2, 2, 0, 1], @@ -769,6 +845,7 @@ def test_f1_score_matches_sklearn(session): def test_f1_score_str_matches_sklearn(session): + sklearn_metrics = pytest.importorskip("sklearn.metrics") pd_df = pd.DataFrame( { "y_true": ["cat", "ant", "cat", "cat", "ant", "bird"], @@ -806,3 +883,10 @@ def test_mean_squared_error(session: bigframes.Session): df = session.read_pandas(pd_df) mse = metrics.mean_squared_error(df["y_true"], df["y_pred"]) assert mse == 0.375 + + +def test_mean_absolute_error(session: bigframes.Session): + pd_df = pd.DataFrame({"y_true": [3, -0.5, 2, 7], "y_pred": [2.5, 0.0, 2, 8]}) + df = session.read_pandas(pd_df) + mse = metrics.mean_absolute_error(df["y_true"], df["y_pred"]) + assert mse == 0.5 diff --git a/tests/system/small/ml/test_model_selection.py b/tests/system/small/ml/test_model_selection.py index c1a1e073b9..ebce6e405a 100644 --- a/tests/system/small/ml/test_model_selection.py +++ b/tests/system/small/ml/test_model_selection.py @@ -13,12 +13,14 @@ # limitations under the License. import math +from typing import cast import pandas as pd import pytest from bigframes.ml import model_selection import bigframes.pandas as bpd +import bigframes.session @pytest.mark.parametrize( @@ -219,6 +221,78 @@ def test_train_test_split_seeded_correct_rows( ) +def test_train_test_split_no_shuffle_correct_shape( + penguins_df_default_index: bpd.DataFrame, +): + X = penguins_df_default_index[["species"]] + y = penguins_df_default_index["body_mass_g"] + X_train, X_test, y_train, y_test = model_selection.train_test_split( + X, y, shuffle=False + ) + assert isinstance(X_train, bpd.DataFrame) + assert isinstance(X_test, bpd.DataFrame) + assert isinstance(y_train, bpd.Series) + assert isinstance(y_test, bpd.Series) + + assert X_train.shape == (258, 1) + assert X_test.shape == (86, 1) + assert y_train.shape == (258,) + assert y_test.shape == (86,) + + +def test_train_test_split_no_shuffle_correct_rows( + session: bigframes.session.Session, penguins_pandas_df_default_index: bpd.DataFrame +): + # Note that we're using `penguins_pandas_df_default_index` as this test depends + # on a stable row order being present end to end + # filter down to the chunkiest penguins, to keep our test code a reasonable size + all_data = penguins_pandas_df_default_index[ + penguins_pandas_df_default_index.body_mass_g > 5500 + ].sort_index() + + # Note that bigframes loses the index if it doesn't have a name + all_data.index.name = "rowindex" + + df = session.read_pandas(all_data) + + X = df[ + [ + "species", + "island", + "culmen_length_mm", + ] + ] + y = df["body_mass_g"] + X_train, X_test, y_train, y_test = model_selection.train_test_split( + X, y, shuffle=False + ) + + X_train_pd = cast(bpd.DataFrame, X_train).to_pandas() + X_test_pd = cast(bpd.DataFrame, X_test).to_pandas() + y_train_pd = cast(bpd.Series, y_train).to_pandas() + y_test_pd = cast(bpd.Series, y_test).to_pandas() + + total_rows = len(all_data) + train_size = 0.75 + train_rows = int(total_rows * train_size) + test_rows = total_rows - train_rows + + expected_X_train = all_data.head(train_rows)[ + ["species", "island", "culmen_length_mm"] + ] + expected_y_train = all_data.head(train_rows)["body_mass_g"] + + expected_X_test = all_data.tail(test_rows)[ + ["species", "island", "culmen_length_mm"] + ] + expected_y_test = all_data.tail(test_rows)["body_mass_g"] + + pd.testing.assert_frame_equal(X_train_pd, expected_X_train) + pd.testing.assert_frame_equal(X_test_pd, expected_X_test) + pd.testing.assert_series_equal(y_train_pd, expected_y_train) + pd.testing.assert_series_equal(y_test_pd, expected_y_test) + + @pytest.mark.parametrize( ("train_size", "test_size"), [ diff --git a/tests/system/small/ml/test_multimodal_llm.py b/tests/system/small/ml/test_multimodal_llm.py new file mode 100644 index 0000000000..e29669afd3 --- /dev/null +++ b/tests/system/small/ml/test_multimodal_llm.py @@ -0,0 +1,84 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pyarrow as pa +import pytest + +from bigframes.ml import llm +import bigframes.pandas as bpd +from bigframes.testing import utils + + +@pytest.mark.flaky(retries=2) +def test_multimodal_embedding_generator_predict_default_params_success( + images_mm_df, session, bq_connection +): + text_embedding_model = llm.MultimodalEmbeddingGenerator( + connection_name=bq_connection, session=session + ) + df = text_embedding_model.predict(images_mm_df).to_pandas() + utils.check_pandas_df_schema_and_index( + df, + columns=utils.ML_MULTIMODAL_GENERATE_EMBEDDING_OUTPUT, + index=2, + col_exact=False, + ) + assert len(df["ml_generate_embedding_result"][0]) == 1408 + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + ), +) +@pytest.mark.flaky(retries=2) +def test_gemini_text_generator_multimodal_structured_output( + images_mm_df: bpd.DataFrame, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + output_schema = { + "bool_output": "bool", + "int_output": "int64", + "float_output": "float64", + "str_output": "string", + "array_output": "array", + "struct_output": "struct", + } + df = gemini_text_generator_model.predict( + images_mm_df, + prompt=["Describe", images_mm_df["blob_col"]], + output_schema=output_schema, + ) + assert df["bool_output"].dtype == pd.BooleanDtype() + assert df["int_output"].dtype == pd.Int64Dtype() + assert df["float_output"].dtype == pd.Float64Dtype() + assert df["str_output"].dtype == pd.StringDtype(storage="pyarrow") + assert df["array_output"].dtype == pd.ArrowDtype(pa.list_(pa.int64())) + assert df["struct_output"].dtype == pd.ArrowDtype( + pa.struct([("number", pa.int64())]) + ) + + pd_df = df.to_pandas() + utils.check_pandas_df_schema_and_index( + pd_df, + columns=list(output_schema.keys()) + + ["blob_col", "prompt", "full_response", "status"], + index=2, + col_exact=False, + ) diff --git a/tests/system/small/ml/test_preprocessing.py b/tests/system/small/ml/test_preprocessing.py index 16b153ab45..3280b16f42 100644 --- a/tests/system/small/ml/test_preprocessing.py +++ b/tests/system/small/ml/test_preprocessing.py @@ -19,7 +19,8 @@ import bigframes.features from bigframes.ml import preprocessing -from tests.system import utils +import bigframes.pandas as bpd +from bigframes.testing import utils ONE_HOT_ENCODED_DTYPE = ( pd.ArrowDtype(pa.list_(pa.struct([("index", pa.int64()), ("value", pa.float64())]))) @@ -62,7 +63,7 @@ def test_standard_scaler_normalizes(penguins_df_default_index, new_penguins_df): pd.testing.assert_frame_equal(result, expected, rtol=0.1) -def test_standard_scaler_normalizeds_fit_transform(new_penguins_df): +def test_standard_scaler_normalizes_fit_transform(new_penguins_df): # TODO(http://b/292431644): add a second test that compares output to sklearn.preprocessing.StandardScaler, when BQML's change is in prod. scaler = preprocessing.StandardScaler() result = scaler.fit_transform( @@ -114,6 +115,37 @@ def test_standard_scaler_series_normalizes(penguins_df_default_index, new_pengui pd.testing.assert_frame_equal(result, expected, rtol=0.1) +def test_standard_scaler_normalizes_non_standard_column_names( + new_penguins_df: bpd.DataFrame, +): + new_penguins_df = new_penguins_df.rename( + columns={ + "culmen_length_mm": "culmen?metric", + "culmen_depth_mm": "culmen/metric", + } + ) + scaler = preprocessing.StandardScaler() + result = scaler.fit_transform( + new_penguins_df[["culmen?metric", "culmen/metric", "flipper_length_mm"]] + ).to_pandas() + + # If standard-scaled correctly, mean should be 0.0 + for column in result.columns: + assert math.isclose(result[column].mean(), 0.0, abs_tol=1e-3) + + expected = pd.DataFrame( + { + "standard_scaled_culmen_metric": [1.313249, -0.20198, -1.111118], + "standard_scaled_culmen_metric_1": [1.17072, -1.272416, 0.101848], + "standard_scaled_flipper_length_mm": [1.251089, -1.196588, -0.054338], + }, + dtype="Float64", + index=pd.Index([1633, 1672, 1690], name="tag_number", dtype="Int64"), + ) + + pd.testing.assert_frame_equal(result, expected, rtol=0.1) + + def test_standard_scaler_save_load(new_penguins_df, dataset_id): transformer = preprocessing.StandardScaler() transformer.fit( @@ -245,7 +277,7 @@ def test_max_abs_scaler_save_load(new_penguins_df, dataset_id): index=pd.Index([1633, 1672, 1690], name="tag_number", dtype="Int64"), ) - pd.testing.assert_frame_equal(result, expected, rtol=0.1) + pd.testing.assert_frame_equal(result.sort_index(), expected.sort_index(), rtol=0.1) def test_min_max_scaler_normalized_fit_transform(new_penguins_df): diff --git a/tests/system/small/ml/test_register.py b/tests/system/small/ml/test_register.py index 6d8ff0a712..f21567da63 100644 --- a/tests/system/small/ml/test_register.py +++ b/tests/system/small/ml/test_register.py @@ -14,9 +14,7 @@ from typing import cast -import pytest - -from bigframes.ml import core, imported, linear_model, llm +from bigframes.ml import core, imported, linear_model def test_linear_reg_register( @@ -53,13 +51,6 @@ def test_linear_reg_register_with_params( ) -def test_palm2_text_generator_register( - ephemera_palm2_text_generator_model: llm.PaLM2TextGenerator, -): - with pytest.raises(AttributeError): - ephemera_palm2_text_generator_model.register() # type: ignore - - def test_imported_tensorflow_register( ephemera_imported_tensorflow_model: imported.TensorFlowModel, ): diff --git a/tests/system/small/operations/test_ai.py b/tests/system/small/operations/test_ai.py new file mode 100644 index 0000000000..d6ec3cacad --- /dev/null +++ b/tests/system/small/operations/test_ai.py @@ -0,0 +1,276 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# Note that the tests in this files uses fake models for deterministic results. +# Tests that use real LLM models are under system/large/test_ai.py + +import pandas as pd +import pandas.testing +import pytest + +import bigframes +from bigframes import dataframe, dtypes +from bigframes.ml import llm +import bigframes.operations.ai +from bigframes.testing import utils + +AI_OP_EXP_OPTION = "experiments.ai_operators" +THRESHOLD_OPTION = "compute.ai_ops_confirmation_threshold" +AI_FORECAST_COLUMNS = [ + "forecast_timestamp", + "forecast_value", + "confidence_level", + "prediction_interval_lower_bound", + "prediction_interval_upper_bound", + "ai_forecast_status", +] + + +class FakeGeminiTextGenerator(llm.GeminiTextGenerator): + def __init__(self, prediction): + self.prediction = prediction + + def predict(self, *args, **kwargs): + return self.prediction + + +@pytest.mark.parametrize( + ("func", "kwargs"), + [ + pytest.param( + bigframes.operations.ai.AIAccessor.filter, + {"instruction": None, "model": None}, + id="filter", + ), + pytest.param( + bigframes.operations.ai.AIAccessor.map, + {"instruction": None, "model": None}, + id="map", + ), + pytest.param( + bigframes.operations.ai.AIAccessor.classify, + {"instruction": None, "model": None, "labels": None}, + id="classify", + ), + pytest.param( + bigframes.operations.ai.AIAccessor.join, + {"other": None, "instruction": None, "model": None}, + id="join", + ), + pytest.param( + bigframes.operations.ai.AIAccessor.search, + {"search_column": None, "query": None, "top_k": None, "model": None}, + id="search", + ), + pytest.param( + bigframes.operations.ai.AIAccessor.sim_join, + {"other": None, "left_on": None, "right_on": None, "model": None}, + id="sim_join", + ), + ], +) +def test_experiment_off_raise_error(session, func, kwargs): + df = dataframe.DataFrame( + {"country": ["USA", "Germany"], "city": ["Seattle", "Berlin"]}, session=session + ) + + with bigframes.option_context(AI_OP_EXP_OPTION, False), pytest.raises( + NotImplementedError + ): + func(df.ai, **kwargs) + + +def test_filter(session): + df = dataframe.DataFrame({"col": ["A", "B"]}, session=session) + model = FakeGeminiTextGenerator( + dataframe.DataFrame( + { + "answer": [True, False], + "full_response": _create_dummy_full_response(2), + }, + session=session, + ), + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ): + result = df.ai.filter( + "filter {col}", + model=model, + ).to_pandas() + + pandas.testing.assert_frame_equal( + result, + pd.DataFrame({"col": ["A"]}, dtype=dtypes.STRING_DTYPE), + check_index_type=False, + ) + + +def test_map(session): + df = dataframe.DataFrame({"col": ["A", "B"]}, session=session) + model = FakeGeminiTextGenerator( + dataframe.DataFrame( + { + "output": ["true", "false"], + "full_response": _create_dummy_full_response(2), + }, + session=session, + ), + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ): + result = df.ai.map( + "map {col}", model=model, output_schema={"output": "string"} + ).to_pandas() + + pandas.testing.assert_frame_equal( + result, + pd.DataFrame( + {"col": ["A", "B"], "output": ["true", "false"]}, dtype=dtypes.STRING_DTYPE + ), + check_index_type=False, + ) + + +def test_classify(session): + df = dataframe.DataFrame({"col": ["A", "B"]}, session=session) + model = FakeGeminiTextGenerator( + dataframe.DataFrame( + { + "result": ["A", "B"], + "full_response": _create_dummy_full_response(2), + }, + session=session, + ), + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ): + result = df.ai.classify( + "classify {col}", model=model, labels=["A", "B"] + ).to_pandas() + + pandas.testing.assert_frame_equal( + result, + pd.DataFrame( + {"col": ["A", "B"], "result": ["A", "B"]}, dtype=dtypes.STRING_DTYPE + ), + check_index_type=False, + ) + + +@pytest.mark.parametrize( + "labels", + [ + pytest.param([], id="empty-label"), + pytest.param(["A", "A", "B"], id="duplicate-labels"), + ], +) +def test_classify_invalid_labels_raise_error(session, labels): + df = dataframe.DataFrame({"col": ["A", "B"]}, session=session) + model = FakeGeminiTextGenerator( + dataframe.DataFrame( + { + "result": ["A", "B"], + "full_response": _create_dummy_full_response(2), + }, + session=session, + ), + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ), pytest.raises(ValueError): + df.ai.classify("classify {col}", model=model, labels=labels) + + +def test_join(session): + left_df = dataframe.DataFrame({"col_A": ["A"]}, session=session) + right_df = dataframe.DataFrame({"col_B": ["B"]}, session=session) + model = FakeGeminiTextGenerator( + dataframe.DataFrame( + { + "answer": [True], + "full_response": _create_dummy_full_response(1), + }, + session=session, + ), + ) + + with bigframes.option_context( + AI_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ): + result = left_df.ai.join( + right_df, "join {col_A} and {col_B}", model + ).to_pandas() + + pandas.testing.assert_frame_equal( + result, + pd.DataFrame({"col_A": ["A"], "col_B": ["B"]}, dtype=dtypes.STRING_DTYPE), + check_index_type=False, + ) + + +def test_forecast_default(time_series_df_default_index: dataframe.DataFrame): + df = time_series_df_default_index[time_series_df_default_index["id"] == "1"] + + result = df.ai.forecast(timestamp_column="parsed_date", data_column="total_visits") + + utils.check_pandas_df_schema_and_index( + result, + columns=AI_FORECAST_COLUMNS, + index=10, + ) + + +def test_forecast_w_params(time_series_df_default_index: dataframe.DataFrame): + result = time_series_df_default_index.ai.forecast( + timestamp_column="parsed_date", + data_column="total_visits", + id_columns=["id"], + horizon=20, + confidence_level=0.98, + ) + + utils.check_pandas_df_schema_and_index( + result, + columns=["id"] + AI_FORECAST_COLUMNS, + index=20 * 2, # 20 for each id + ) + + +def _create_dummy_full_response(row_count: int) -> pd.Series: + entry = """{"candidates": [{"avg_logprobs": -0.5}]}""" + + return pd.Series([entry] * row_count) diff --git a/tests/system/small/operations/test_dates.py b/tests/system/small/operations/test_dates.py new file mode 100644 index 0000000000..9e8da64209 --- /dev/null +++ b/tests/system/small/operations/test_dates.py @@ -0,0 +1,91 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import datetime + +from packaging import version +import pandas as pd +import pandas.testing +import pytest + +from bigframes import dtypes + + +def test_date_diff_between_series(session): + pd_df = pd.DataFrame( + { + "col_1": [datetime.date(2025, 1, 2), datetime.date(2025, 2, 1)], + "col_2": [datetime.date(2024, 1, 2), datetime.date(2026, 1, 30)], + } + ).astype(dtypes.DATE_DTYPE) + bf_df = session.read_pandas(pd_df) + + actual_result = (bf_df["col_1"] - bf_df["col_2"]).to_pandas() + + expected_result = (pd_df["col_1"] - pd_df["col_2"]).astype(dtypes.TIMEDELTA_DTYPE) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) + + +def test_date_diff_literal_sub_series(scalars_dfs): + bf_df, pd_df = scalars_dfs + literal = datetime.date(2030, 5, 20) + + actual_result = (literal - bf_df["date_col"]).to_pandas() + + expected_result = (literal - pd_df["date_col"]).astype(dtypes.TIMEDELTA_DTYPE) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) + + +def test_date_diff_series_sub_literal(scalars_dfs): + bf_df, pd_df = scalars_dfs + literal = datetime.date(1980, 5, 20) + + actual_result = (bf_df["date_col"] - literal).to_pandas() + + expected_result = (pd_df["date_col"] - literal).astype(dtypes.TIMEDELTA_DTYPE) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) + + +def test_date_series_diff_agg(scalars_dfs): + bf_df, pd_df = scalars_dfs + + actual_result = bf_df["date_col"].diff().to_pandas() + + expected_result = pd_df["date_col"].diff().astype(dtypes.TIMEDELTA_DTYPE) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) + + +def test_date_can_cast_after_accessor(scalars_dfs): + if version.Version(pd.__version__) <= version.Version("2.1.0"): + pytest.skip("pd timezone conversion bug") + bf_df, pd_df = scalars_dfs + + actual_result = bf_df["date_col"].dt.isocalendar().week.astype("Int64").to_pandas() + # convert to pd date type rather than arrow, as pandas doesn't handle arrow date well here + expected_result = ( + pd.to_datetime(pd_df["date_col"]).dt.isocalendar().week.astype("Int64") + ) + + pandas.testing.assert_series_equal( + actual_result, expected_result, check_dtype=False, check_index_type=False + ) diff --git a/tests/system/small/operations/test_datetimes.py b/tests/system/small/operations/test_datetimes.py index ca83604dd5..0e023189d5 100644 --- a/tests/system/small/operations/test_datetimes.py +++ b/tests/system/small/operations/test_datetimes.py @@ -13,14 +13,17 @@ # limitations under the License. import datetime +import typing import numpy +from packaging import version from pandas import testing import pandas as pd import pytest +import bigframes.pandas as bpd import bigframes.series -from tests.system.utils import assert_series_equal, skip_legacy_pandas +from bigframes.testing.utils import assert_series_equal DATETIME_COL_NAMES = [("datetime_col",), ("timestamp_col",)] DATE_COLUMNS = [ @@ -30,12 +33,21 @@ ] +@pytest.fixture +def timedelta_series(session): + pd_s = pd.Series(pd.to_timedelta([1.1010101, 2.2020102, 3.3030103], unit="d")) + bf_s = session.read_pandas(pd_s) + + return bf_s, pd_s + + @pytest.mark.parametrize( ("col_name",), DATE_COLUMNS, ) -@skip_legacy_pandas def test_dt_day(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.day.to_pandas() @@ -51,8 +63,9 @@ def test_dt_day(scalars_dfs, col_name): ("col_name",), DATETIME_COL_NAMES, ) -@skip_legacy_pandas def test_dt_date(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.date.to_pandas() @@ -68,22 +81,100 @@ def test_dt_date(scalars_dfs, col_name): ("col_name",), DATE_COLUMNS, ) -@skip_legacy_pandas def test_dt_dayofweek(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] + bf_result = bf_series.dt.dayofweek.to_pandas() pd_result = scalars_pandas_df[col_name].dt.dayofweek assert_series_equal(pd_result, bf_result, check_dtype=False) +@pytest.mark.parametrize( + ("col_name",), + DATE_COLUMNS, +) +def test_dt_day_of_week(scalars_dfs, col_name): + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + bf_series: bigframes.series.Series = scalars_df[col_name] + + bf_result = bf_series.dt.day_of_week.to_pandas() + pd_result = scalars_pandas_df[col_name].dt.day_of_week + + assert_series_equal(pd_result, bf_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("col_name",), + DATE_COLUMNS, +) +def test_dt_weekday(scalars_dfs, col_name): + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + bf_series: bigframes.series.Series = scalars_df[col_name] + + bf_result = bf_series.dt.weekday.to_pandas() + pd_result = scalars_pandas_df[col_name].dt.weekday + + assert_series_equal(pd_result, bf_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("col_name",), + DATE_COLUMNS, +) +def test_dt_dayofyear(scalars_dfs, col_name): + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + bf_series: bigframes.series.Series = scalars_df[col_name] + + bf_result = bf_series.dt.dayofyear.to_pandas() + pd_result = scalars_pandas_df[col_name].dt.dayofyear + + assert_series_equal(pd_result, bf_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("col_name",), + DATE_COLUMNS, +) +def test_dt_day_name(scalars_dfs, col_name): + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + bf_series: bigframes.series.Series = scalars_df[col_name] + + bf_result = bf_series.dt.day_name().to_pandas() + pd_result = scalars_pandas_df[col_name].dt.day_name() + + assert_series_equal(pd_result, bf_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("col_name",), + DATE_COLUMNS, +) +def test_dt_day_of_year(scalars_dfs, col_name): + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + bf_series: bigframes.series.Series = scalars_df[col_name] + + bf_result = bf_series.dt.day_of_year.to_pandas() + pd_result = scalars_pandas_df[col_name].dt.day_of_year + + assert_series_equal(pd_result, bf_result, check_dtype=False) + + @pytest.mark.parametrize( ("col_name",), DATETIME_COL_NAMES, ) -@skip_legacy_pandas def test_dt_hour(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.hour.to_pandas() @@ -99,8 +190,9 @@ def test_dt_hour(scalars_dfs, col_name): ("col_name",), DATETIME_COL_NAMES, ) -@skip_legacy_pandas def test_dt_minute(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.minute.to_pandas() @@ -116,8 +208,9 @@ def test_dt_minute(scalars_dfs, col_name): ("col_name",), DATE_COLUMNS, ) -@skip_legacy_pandas def test_dt_month(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.month.to_pandas() @@ -133,8 +226,9 @@ def test_dt_month(scalars_dfs, col_name): ("col_name",), DATE_COLUMNS, ) -@skip_legacy_pandas def test_dt_quarter(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.quarter.to_pandas() @@ -150,8 +244,9 @@ def test_dt_quarter(scalars_dfs, col_name): ("col_name",), DATETIME_COL_NAMES, ) -@skip_legacy_pandas def test_dt_second(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.second.to_pandas() @@ -167,8 +262,9 @@ def test_dt_second(scalars_dfs, col_name): ("col_name",), DATETIME_COL_NAMES, ) -@skip_legacy_pandas def test_dt_time(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.time.to_pandas() @@ -184,8 +280,9 @@ def test_dt_time(scalars_dfs, col_name): ("col_name",), DATE_COLUMNS, ) -@skip_legacy_pandas def test_dt_year(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.year.to_pandas() @@ -197,12 +294,28 @@ def test_dt_year(scalars_dfs, col_name): ) +def test_dt_isocalendar(session): + # We don't re-use the exisintg scalars_dfs fixture because iso calendar + # get tricky when a new year starts, but the dataset `scalars_dfs` does not cover + # this case. + pd_s = pd.Series(pd.date_range("2009-12-25", "2010-01-07", freq="d")) + bf_s = session.read_pandas(pd_s) + + actual_result = bf_s.dt.isocalendar().to_pandas() + + expected_result = pd_s.dt.isocalendar() + testing.assert_frame_equal( + actual_result, expected_result, check_dtype=False, check_index_type=False + ) + + @pytest.mark.parametrize( ("col_name",), DATETIME_COL_NAMES, ) -@skip_legacy_pandas def test_dt_tz(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.tz @@ -215,8 +328,9 @@ def test_dt_tz(scalars_dfs, col_name): ("col_name",), DATETIME_COL_NAMES, ) -@skip_legacy_pandas def test_dt_unit(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_series: bigframes.series.Series = scalars_df[col_name] bf_result = bf_series.dt.unit @@ -234,8 +348,9 @@ def test_dt_unit(scalars_dfs, col_name): ("datetime_col", "%H:%M"), ], ) -@skip_legacy_pandas def test_dt_strftime(scalars_df_index, scalars_pandas_df_index, column, date_format): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") bf_result = scalars_df_index[column].dt.strftime(date_format).to_pandas() pd_result = scalars_pandas_df_index[column].dt.strftime(date_format) pd.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) @@ -276,8 +391,9 @@ def test_dt_strftime_time(): ("col_name",), DATETIME_COL_NAMES, ) -@skip_legacy_pandas def test_dt_normalize(scalars_dfs, col_name): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_result = scalars_df[col_name].dt.normalize().to_pandas() pd_result = scalars_pandas_df[col_name].dt.normalize() @@ -297,8 +413,9 @@ def test_dt_normalize(scalars_dfs, col_name): ("datetime_col", "us"), ], ) -@skip_legacy_pandas def test_dt_floor(scalars_dfs, col_name, freq): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_result = scalars_df[col_name].dt.floor(freq).to_pandas() pd_result = scalars_pandas_df[col_name].dt.floor(freq) @@ -460,3 +577,59 @@ def test_timestamp_series_diff_agg(scalars_dfs, column): expected_result = pd_series.diff() assert_series_equal(actual_result, expected_result) + + +@pytest.mark.parametrize( + "access", + [ + pytest.param(lambda x: x.dt.days, id="dt.days"), + pytest.param(lambda x: x.dt.seconds, id="dt.seconds"), + pytest.param(lambda x: x.dt.microseconds, id="dt.microseconds"), + pytest.param(lambda x: x.dt.total_seconds(), id="dt.total_seconds()"), + ], +) +def test_timedelta_dt_accessors(timedelta_series, access): + bf_s, pd_s = timedelta_series + + actual_result = access(bf_s).to_pandas() + + expected_result = access(pd_s) + assert_series_equal( + actual_result, expected_result, check_dtype=False, check_index_type=False + ) + + +@pytest.mark.parametrize( + "access", + [ + pytest.param(lambda x: x.dt.days, id="dt.days"), + pytest.param(lambda x: x.dt.seconds, id="dt.seconds"), + pytest.param(lambda x: x.dt.microseconds, id="dt.microseconds"), + pytest.param(lambda x: x.dt.total_seconds(), id="dt.total_seconds()"), + ], +) +def test_timedelta_dt_accessors_on_wrong_type_raise_exception(scalars_dfs, access): + bf_df, _ = scalars_dfs + + with pytest.raises(TypeError): + access(bf_df["timestamp_col"]) + + +@pytest.mark.parametrize( + "col", + # TODO(b/431276706) test timestamp_col too. + ["date_col", "datetime_col"], +) +def test_to_datetime(scalars_dfs, col): + if version.Version(pd.__version__) <= version.Version("2.1.0"): + pytest.skip("timezone conversion bug") + bf_df, pd_df = scalars_dfs + + actual_result = typing.cast( + bigframes.series.Series, bpd.to_datetime(bf_df[col]) + ).to_pandas() + + expected_result = pd.Series(pd.to_datetime(pd_df[col])) + testing.assert_series_equal( + actual_result, expected_result, check_dtype=False, check_index_type=False + ) diff --git a/tests/system/small/operations/test_lists.py b/tests/system/small/operations/test_lists.py index 7b39bdebd5..16a6802572 100644 --- a/tests/system/small/operations/test_lists.py +++ b/tests/system/small/operations/test_lists.py @@ -18,7 +18,7 @@ import pyarrow as pa import pytest -from ...utils import assert_series_equal +from bigframes.testing.utils import assert_series_equal @pytest.mark.parametrize( @@ -106,3 +106,33 @@ def test_len(column_name, dtype, repeated_df, repeated_pandas_df): check_index_type=False, check_names=False, ) + + +@pytest.mark.parametrize( + ("column_name", "dtype"), + [ + pytest.param("int_list_col", pd.ArrowDtype(pa.list_(pa.int64()))), + pytest.param("float_list_col", pd.ArrowDtype(pa.list_(pa.float64()))), + ], +) +@pytest.mark.parametrize( + ("func",), + [ + pytest.param(len), + pytest.param(all), + pytest.param(any), + pytest.param(min), + pytest.param(max), + pytest.param(sum), + ], +) +def test_list_apply_callable(column_name, dtype, repeated_df, repeated_pandas_df, func): + bf_result = repeated_df[column_name].apply(func).to_pandas() + pd_result = repeated_pandas_df[column_name].astype(dtype).apply(func) + pd_result.index = pd_result.index.astype("Int64") + + assert_series_equal( + pd_result, + bf_result, + check_dtype=False, + ) diff --git a/tests/system/small/operations/test_plotting.py b/tests/system/small/operations/test_plotting.py index c2f3ba423f..2585ac8e81 100644 --- a/tests/system/small/operations/test_plotting.py +++ b/tests/system/small/operations/test_plotting.py @@ -264,6 +264,42 @@ def test_bar(scalars_dfs, col_names, alias): tm.assert_almost_equal(line.get_data()[1], pd_line.get_data()[1]) +@pytest.mark.parametrize( + ("col_names",), + [ + pytest.param(["int64_col", "float64_col", "int64_too"], id="df"), + pytest.param(["int64_col"], id="series"), + ], +) +def test_barh(scalars_dfs, col_names): + scalars_df, scalars_pandas_df = scalars_dfs + ax = scalars_df[col_names].plot.barh() + pd_ax = scalars_pandas_df[col_names].plot.barh() + tm.assert_almost_equal(ax.get_xticks(), pd_ax.get_xticks()) + tm.assert_almost_equal(ax.get_yticks(), pd_ax.get_yticks()) + for line, pd_line in zip(ax.lines, pd_ax.lines): + # Compare y coordinates between the lines + tm.assert_almost_equal(line.get_data()[1], pd_line.get_data()[1]) + + +@pytest.mark.parametrize( + ("col_names",), + [ + pytest.param(["int64_col", "float64_col", "int64_too"], id="df"), + pytest.param(["int64_col"], id="series"), + ], +) +def test_pie(scalars_dfs, col_names): + scalars_df, scalars_pandas_df = scalars_dfs + ax = scalars_df[col_names].abs().plot.pie(y="int64_col") + pd_ax = scalars_pandas_df[col_names].abs().plot.pie(y="int64_col") + tm.assert_almost_equal(ax.get_xticks(), pd_ax.get_xticks()) + tm.assert_almost_equal(ax.get_yticks(), pd_ax.get_yticks()) + for line, pd_line in zip(ax.lines, pd_ax.lines): + # Compare y coordinates between the lines + tm.assert_almost_equal(line.get_data()[1], pd_line.get_data()[1]) + + @pytest.mark.parametrize( ("col_names", "alias"), [ diff --git a/tests/system/small/operations/test_semantics.py b/tests/system/small/operations/test_semantics.py new file mode 100644 index 0000000000..8b520d8c03 --- /dev/null +++ b/tests/system/small/operations/test_semantics.py @@ -0,0 +1,143 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# Note that the tests in this files uses fake models for deterministic results. +# Tests that use real LLM models are under system/large/test_semantcs.py + +import pandas as pd +import pandas.testing +import pytest + +import bigframes +from bigframes import dataframe, dtypes +from bigframes.ml import llm + +SEM_OP_EXP_OPTION = "experiments.semantic_operators" +THRESHOLD_OPTION = "compute.semantic_ops_confirmation_threshold" + + +class FakeGeminiTextGenerator(llm.GeminiTextGenerator): + def __init__(self, prediction): + self.prediction = prediction + + def predict(self, *args, **kwargs): + return self.prediction + + +def test_semantics_experiment_off_raise_error(session): + df = dataframe.DataFrame( + {"country": ["USA", "Germany"], "city": ["Seattle", "Berlin"]}, session=session + ) + + with bigframes.option_context(SEM_OP_EXP_OPTION, False), pytest.raises( + NotImplementedError + ): + df.semantics + + +def test_filter(session): + df = dataframe.DataFrame({"col": ["A", "B"]}, session=session) + model = FakeGeminiTextGenerator( + dataframe.DataFrame( + {"ml_generate_text_llm_result": ["true", "false"]}, session=session + ), + ) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ): + result = df.semantics.filter( + "filter {col}", + model=model, + ).to_pandas() + + pandas.testing.assert_frame_equal( + result, + pd.DataFrame({"col": ["A"]}, dtype=dtypes.STRING_DTYPE), + check_index_type=False, + ) + + +def test_map(session): + df = dataframe.DataFrame({"col": ["A", "B"]}, session=session) + model = FakeGeminiTextGenerator( + dataframe.DataFrame( + {"ml_generate_text_llm_result": ["true", "false"]}, session=session + ), + ) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ): + result = df.semantics.map( + "map {col}", model=model, output_column="output" + ).to_pandas() + + pandas.testing.assert_frame_equal( + result, + pd.DataFrame( + {"col": ["A", "B"], "output": ["true", "false"]}, dtype=dtypes.STRING_DTYPE + ), + check_index_type=False, + ) + + +def test_join(session): + left_df = dataframe.DataFrame({"col_A": ["A"]}, session=session) + right_df = dataframe.DataFrame({"col_B": ["B"]}, session=session) + model = FakeGeminiTextGenerator( + dataframe.DataFrame({"ml_generate_text_llm_result": ["true"]}, session=session), + ) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ): + result = left_df.semantics.join( + right_df, "join {col_A} and {col_B}", model + ).to_pandas() + + pandas.testing.assert_frame_equal( + result, + pd.DataFrame({"col_A": ["A"], "col_B": ["B"]}, dtype=dtypes.STRING_DTYPE), + check_index_type=False, + ) + + +def test_top_k(session): + df = dataframe.DataFrame({"col": ["A", "B"]}, session=session) + model = FakeGeminiTextGenerator( + dataframe.DataFrame( + {"ml_generate_text_llm_result": ["Document 1"]}, session=session + ), + ) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ): + result = df.semantics.top_k("top k of {col}", model, k=1).to_pandas() + + assert len(result) == 1 diff --git a/tests/system/small/operations/test_strings.py b/tests/system/small/operations/test_strings.py index bb328360ee..657fc231d1 100644 --- a/tests/system/small/operations/test_strings.py +++ b/tests/system/small/operations/test_strings.py @@ -20,8 +20,7 @@ import bigframes.dtypes as dtypes import bigframes.pandas as bpd - -from ...utils import assert_series_equal +from bigframes.testing.utils import assert_series_equal def test_find(scalars_dfs): @@ -79,8 +78,6 @@ def test_str_extract(scalars_dfs, pat): bf_result = bf_series.str.extract(pat).to_pandas() pd_result = scalars_pandas_df[col_name].str.extract(pat) - # Pandas produces int col labels, while bq df only supports str labels at present - pd_result = pd_result.set_axis(pd_result.columns.astype(str), axis=1) pd.testing.assert_frame_equal( pd_result, bf_result, @@ -98,6 +95,7 @@ def test_str_extract(scalars_dfs, pat): (re.compile("(?i).e.."), "blah", None, 0, True), ("H", "h", True, 0, False), (", ", "__", True, 0, False), + (re.compile(r"hEllo", flags=re.I), "blah", None, 0, True), ], ) def test_str_replace(scalars_dfs, pat, repl, case, flags, regex): @@ -236,7 +234,20 @@ def test_reverse(scalars_dfs): @pytest.mark.parametrize( - ["start", "stop"], [(0, 1), (3, 5), (100, 101), (None, 1), (0, 12), (0, None)] + ["start", "stop"], + [ + (0, 1), + (3, 5), + (100, 101), + (None, 1), + (0, 12), + (0, None), + (None, -1), + (-1, None), + (-5, -1), + (1, -1), + (-10, 10), + ], ) def test_slice(scalars_dfs, start, stop): scalars_df, scalars_pandas_df = scalars_dfs @@ -265,6 +276,28 @@ def test_strip(scalars_dfs): ) +@pytest.mark.parametrize( + ("to_strip"), + [ + pytest.param(None, id="none"), + pytest.param(" ", id="space"), + pytest.param(" \n", id="space_newline"), + pytest.param("123.!? \n\t", id="multiple_chars"), + ], +) +def test_strip_w_to_strip(to_strip): + s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", pd.NA]) + pd_s = s.to_pandas() + + bf_result = s.str.strip(to_strip=to_strip).to_pandas() + pd_result = pd_s.str.strip(to_strip=to_strip) + + assert_series_equal( + pd_result, + bf_result, + ) + + def test_upper(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs col_name = "string_col" @@ -303,7 +336,9 @@ def test_isalpha(weird_strings, weird_strings_pd): def test_isdigit(weird_strings, weird_strings_pd): - pd_result = weird_strings_pd.str.isdigit() + # check the behavior against normal pandas str, since pyarrow has a bug with superscripts/fractions b/333484335 + # astype object instead of str to support pd.NA + pd_result = weird_strings_pd.astype(object).str.isdigit() bf_result = weird_strings.str.isdigit().to_pandas() pd.testing.assert_series_equal( @@ -387,6 +422,28 @@ def test_rstrip(scalars_dfs): ) +@pytest.mark.parametrize( + ("to_strip"), + [ + pytest.param(None, id="none"), + pytest.param(" ", id="space"), + pytest.param(" \n", id="space_newline"), + pytest.param("123.!? \n\t", id="multiple_chars"), + ], +) +def test_rstrip_w_to_strip(to_strip): + s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", pd.NA]) + pd_s = s.to_pandas() + + bf_result = s.str.rstrip(to_strip=to_strip).to_pandas() + pd_result = pd_s.str.rstrip(to_strip=to_strip) + + assert_series_equal( + pd_result, + bf_result, + ) + + def test_lstrip(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs col_name = "string_col" @@ -400,6 +457,28 @@ def test_lstrip(scalars_dfs): ) +@pytest.mark.parametrize( + ("to_strip"), + [ + pytest.param(None, id="none"), + pytest.param(" ", id="space"), + pytest.param(" \n", id="space_newline"), + pytest.param("123.!? \n\t", id="multiple_chars"), + ], +) +def test_lstrip_w_to_strip(to_strip): + s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", pd.NA]) + pd_s = s.to_pandas() + + bf_result = s.str.lstrip(to_strip=to_strip).to_pandas() + pd_result = pd_s.str.lstrip(to_strip=to_strip) + + assert_series_equal( + pd_result, + bf_result, + ) + + @pytest.mark.parametrize(["repeats"], [(5,), (0,), (1,)]) def test_repeat(scalars_dfs, repeats): scalars_df, scalars_pandas_df = scalars_dfs @@ -668,3 +747,14 @@ def test_getitem_w_struct_array(): expected = bpd.Series(expected_data, dtype=bpd.ArrowDtype((pa_struct))) assert_series_equal(result.to_pandas(), expected.to_pandas()) + + +def test_string_join(session): + pd_series = pd.Series([["a", "b", "c"], ["100"], ["hello", "world"], []]) + bf_series = session.read_pandas(pd_series) + + pd_result = pd_series.str.join("--") + bf_result = bf_series.str.join("--").to_pandas() + + pd_result = pd_result.astype("string[pyarrow]") + assert_series_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) diff --git a/tests/system/small/operations/test_timedeltas.py b/tests/system/small/operations/test_timedeltas.py index 356000b3f6..18c88db8eb 100644 --- a/tests/system/small/operations/test_timedeltas.py +++ b/tests/system/small/operations/test_timedeltas.py @@ -17,8 +17,10 @@ import operator import numpy as np +from packaging import version import pandas as pd import pandas.testing +import pyarrow as pa import pytest from bigframes import dtypes @@ -38,17 +40,27 @@ def temporal_dfs(session): pd.Timestamp("2024-01-02 02:00:00", tz="UTC"), pd.Timestamp("2005-03-05 02:00:00", tz="UTC"), ], + "date_col": pd.Series( + [ + datetime.date(2000, 1, 1), + datetime.date(2001, 2, 3), + datetime.date(2020, 9, 30), + ], + dtype=pd.ArrowDtype(pa.date32()), + ), "timedelta_col_1": [ pd.Timedelta(5, "s"), - pd.Timedelta(-4, "d"), + pd.Timedelta(-4, "m"), pd.Timedelta(5, "h"), ], "timedelta_col_2": [ pd.Timedelta(3, "s"), - pd.Timedelta(-4, "d"), + pd.Timedelta(-4, "m"), pd.Timedelta(6, "h"), ], - "numeric_col": [1.5, 2, -3], + "float_col": [1.5, 2, -3], + "int_col": [1, 2, -3], + "positive_int_col": [1, 2, 3], } ) @@ -82,10 +94,11 @@ def _assert_series_equal(actual: pd.Series, expected: pd.Series): (operator.sub, "timedelta_col_1", "timedelta_col_2"), (operator.truediv, "timedelta_col_1", "timedelta_col_2"), (operator.floordiv, "timedelta_col_1", "timedelta_col_2"), - (operator.truediv, "timedelta_col_1", "numeric_col"), - (operator.floordiv, "timedelta_col_1", "numeric_col"), - (operator.mul, "timedelta_col_1", "numeric_col"), - (operator.mul, "numeric_col", "timedelta_col_1"), + (operator.truediv, "timedelta_col_1", "float_col"), + (operator.floordiv, "timedelta_col_1", "float_col"), + (operator.mul, "timedelta_col_1", "float_col"), + (operator.mul, "float_col", "timedelta_col_1"), + (operator.mod, "timedelta_col_1", "timedelta_col_2"), ], ) def test_timedelta_binary_ops_between_series(temporal_dfs, op, col_1, col_2): @@ -107,7 +120,8 @@ def test_timedelta_binary_ops_between_series(temporal_dfs, op, col_1, col_2): (operator.truediv, "timedelta_col_1", 3), (operator.floordiv, "timedelta_col_1", 3), (operator.mul, "timedelta_col_1", 3), - (operator.mul, "numeric_col", pd.Timedelta(1, "s")), + (operator.mul, "float_col", pd.Timedelta(1, "s")), + (operator.mod, "timedelta_col_1", pd.Timedelta(7, "s")), ], ) def test_timedelta_binary_ops_series_and_literal(temporal_dfs, op, col, literal): @@ -126,10 +140,11 @@ def test_timedelta_binary_ops_series_and_literal(temporal_dfs, op, col, literal) (operator.sub, "timedelta_col_1", pd.Timedelta(2, "s")), (operator.truediv, "timedelta_col_1", pd.Timedelta(2, "s")), (operator.floordiv, "timedelta_col_1", pd.Timedelta(2, "s")), - (operator.truediv, "numeric_col", pd.Timedelta(2, "s")), - (operator.floordiv, "numeric_col", pd.Timedelta(2, "s")), + (operator.truediv, "float_col", pd.Timedelta(2, "s")), + (operator.floordiv, "float_col", pd.Timedelta(2, "s")), (operator.mul, "timedelta_col_1", 3), - (operator.mul, "numeric_col", pd.Timedelta(1, "s")), + (operator.mul, "float_col", pd.Timedelta(1, "s")), + (operator.mod, "timedelta_col_1", pd.Timedelta(7, "s")), ], ) def test_timedelta_binary_ops_literal_and_series(temporal_dfs, op, col, literal): @@ -171,6 +186,16 @@ def test_timestamp_add__ts_series_plus_td_series(temporal_dfs, column, pd_dtype) ) +@pytest.mark.parametrize("column", ["datetime_col", "timestamp_col"]) +def test_timestamp_add__ts_series_plus_td_series__explicit_cast(temporal_dfs, column): + bf_df, _ = temporal_dfs + dtype = pd.ArrowDtype(pa.duration("us")) + + actual_result = bf_df[column] + bf_df["int_col"].astype(dtype) + + assert len(actual_result) > 0 + + @pytest.mark.parametrize( "literal", [ @@ -365,6 +390,81 @@ def test_timestamp_sub_dataframes(temporal_dfs): ) +@pytest.mark.parametrize( + ("left_col", "right_col"), + [ + ("date_col", "timedelta_col_1"), + ("timedelta_col_1", "date_col"), + ], +) +def test_date_add__series_add_series(temporal_dfs, left_col, right_col): + if version.Version(pd.__version__) < version.Version("2.1.0"): + pytest.skip("not supported by Pandas < 2.1.0") + + bf_df, pd_df = temporal_dfs + + actual_result = (bf_df[left_col] + bf_df[right_col]).to_pandas() + + expected_result = (pd_df[left_col] + pd_df[right_col]).astype(dtypes.DATETIME_DTYPE) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) + + +# Pandas does not support date literal + timedelta series so we don't test it here. +def test_date_add__literal_add_series(temporal_dfs): + bf_df, pd_df = temporal_dfs + literal = pd.Timedelta(1, "d") + + actual_result = (literal + bf_df["date_col"]).to_pandas() + + expected_result = (literal + pd_df["date_col"]).astype(dtypes.DATETIME_DTYPE) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) + + +# Pandas does not support timedelta series + date literal so we don't test it here. +def test_date_add__series_add_literal(temporal_dfs): + bf_df, pd_df = temporal_dfs + literal = pd.Timedelta(1, "d") + + actual_result = (bf_df["date_col"] + literal).to_pandas() + + expected_result = (pd_df["date_col"] + literal).astype(dtypes.DATETIME_DTYPE) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) + + +def test_date_sub__series_sub_series(temporal_dfs): + if version.Version(pd.__version__) < version.Version("2.1.0"): + pytest.skip("not supported by Pandas < 2.1.0") + + bf_df, pd_df = temporal_dfs + + actual_result = (bf_df["date_col"] - bf_df["timedelta_col_1"]).to_pandas() + + expected_result = (pd_df["date_col"] - pd_df["timedelta_col_1"]).astype( + dtypes.DATETIME_DTYPE + ) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) + + +def test_date_sub__series_sub_literal(temporal_dfs): + bf_df, pd_df = temporal_dfs + literal = pd.Timedelta(1, "d") + + actual_result = (bf_df["date_col"] - literal).to_pandas() + + expected_result = (pd_df["date_col"] - literal).astype(dtypes.DATETIME_DTYPE) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) + + @pytest.mark.parametrize( "compare_func", [ @@ -465,3 +565,70 @@ def test_timedelta_ordering(session): pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) + + +def test_timedelta_cumsum(temporal_dfs): + bf_df, pd_df = temporal_dfs + + actual_result = bf_df["timedelta_col_1"].cumsum().to_pandas() + + expected_result = pd_df["timedelta_col_1"].cumsum() + _assert_series_equal(actual_result, expected_result) + + +@pytest.mark.parametrize( + "agg_func", + [ + pytest.param(lambda x: x.min(), id="min"), + pytest.param(lambda x: x.max(), id="max"), + pytest.param(lambda x: x.sum(), id="sum"), + pytest.param(lambda x: x.mean(), id="mean"), + pytest.param(lambda x: x.median(), id="median"), + pytest.param(lambda x: x.quantile(0.5), id="quantile"), + pytest.param(lambda x: x.std(), id="std"), + ], +) +def test_timedelta_agg__timedelta_result(temporal_dfs, agg_func): + bf_df, pd_df = temporal_dfs + + actual_result = agg_func(bf_df["timedelta_col_1"]) + + expected_result = agg_func(pd_df["timedelta_col_1"]).floor("us") + assert actual_result == expected_result + + +@pytest.mark.parametrize( + "agg_func", + [ + pytest.param(lambda x: x.count(), id="count"), + pytest.param(lambda x: x.nunique(), id="nunique"), + ], +) +def test_timedelta_agg__int_result(temporal_dfs, agg_func): + bf_df, pd_df = temporal_dfs + + actual_result = agg_func(bf_df["timedelta_col_1"]) + + expected_result = agg_func(pd_df["timedelta_col_1"]) + assert actual_result == expected_result + + +def test_timestamp_diff_after_type_casting(temporal_dfs): + if version.Version(pd.__version__) <= version.Version("2.1.0"): + pytest.skip( + "Temporal type casting is not well-supported in older verions of Pandas." + ) + + bf_df, pd_df = temporal_dfs + dtype = pd.ArrowDtype(pa.timestamp("us", tz="UTC")) + + actual_result = ( + bf_df["timestamp_col"] - bf_df["positive_int_col"].astype(dtype) + ).to_pandas() + + expected_result = pd_df["timestamp_col"] - pd_df["positive_int_col"].astype( + "datetime64[us, UTC]" + ) + pandas.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False, check_dtype=False + ) diff --git a/tests/system/small/pandas/test_describe.py b/tests/system/small/pandas/test_describe.py new file mode 100644 index 0000000000..6f28811512 --- /dev/null +++ b/tests/system/small/pandas/test_describe.py @@ -0,0 +1,354 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas.testing +import pytest + + +def test_df_describe_non_temporal(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + # excluding temporal columns here because BigFrames cannot perform percentiles operations on them + unsupported_columns = [ + "datetime_col", + "timestamp_col", + "time_col", + "date_col", + "duration_col", + ] + bf_result = scalars_df.drop(columns=unsupported_columns).describe().to_pandas() + + modified_pd_df = scalars_pandas_df.drop(columns=unsupported_columns) + pd_result = modified_pd_df.describe() + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + pd_result = pd_result.astype("Float64") + + # Drop quartiles, as they are approximate + bf_min = bf_result.loc["min", :] + bf_p25 = bf_result.loc["25%", :] + bf_p50 = bf_result.loc["50%", :] + bf_p75 = bf_result.loc["75%", :] + bf_max = bf_result.loc["max", :] + + bf_result = bf_result.drop(labels=["25%", "50%", "75%"]) + pd_result = pd_result.drop(labels=["25%", "50%", "75%"]) + + pandas.testing.assert_frame_equal(pd_result, bf_result, check_index_type=False) + + # Double-check that quantiles are at least plausible. + assert ( + (bf_min <= bf_p25) + & (bf_p25 <= bf_p50) + & (bf_p50 <= bf_p50) + & (bf_p75 <= bf_max) + ).all() + + +@pytest.mark.parametrize("include", [None, "all"]) +def test_df_describe_non_numeric(scalars_dfs, include): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + # Excluding "date_col" here because in BigFrames it is used as PyArrow[date32()], which is + # considered numerical in Pandas + target_columns = ["string_col", "bytes_col", "bool_col", "time_col"] + + modified_bf = scalars_df[target_columns] + bf_result = modified_bf.describe(include=include).to_pandas() + + modified_pd_df = scalars_pandas_df[target_columns] + pd_result = modified_pd_df.describe(include=include) + + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex(["count", "nunique"]) + pd_result = pd_result.reindex( + ["count", "unique"] + # BF counter part of "unique" is called "nunique" + ).rename(index={"unique": "nunique"}) + + pandas.testing.assert_frame_equal( + pd_result.astype("Int64"), + bf_result, + check_index_type=False, + ) + + +def test_df_describe_temporal(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + temporal_columns = ["datetime_col", "timestamp_col", "time_col", "date_col"] + + modified_bf = scalars_df[temporal_columns] + bf_result = modified_bf.describe(include="all").to_pandas() + + modified_pd_df = scalars_pandas_df[temporal_columns] + pd_result = modified_pd_df.describe(include="all") + + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex(["count", "nunique"]) + pd_result = pd_result.reindex( + ["count", "unique"] + # BF counter part of "unique" is called "nunique" + ).rename(index={"unique": "nunique"}) + + pandas.testing.assert_frame_equal( + pd_result.astype("Float64"), + bf_result.astype("Float64"), + check_index_type=False, + ) + + +def test_df_describe_mixed_types_include_all(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + numeric_columns = [ + "int64_col", + "float64_col", + ] + non_numeric_columns = ["string_col"] + supported_columns = numeric_columns + non_numeric_columns + + modified_bf = scalars_df[supported_columns] + bf_result = modified_bf.describe(include="all").to_pandas() + + modified_pd_df = scalars_pandas_df[supported_columns] + pd_result = modified_pd_df.describe(include="all") + + # Drop quartiles, as they are approximate + bf_min = bf_result.loc["min", :] + bf_p25 = bf_result.loc["25%", :] + bf_p50 = bf_result.loc["50%", :] + bf_p75 = bf_result.loc["75%", :] + bf_max = bf_result.loc["max", :] + + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex(["count", "nunique", "mean", "std", "min", "max"]) + pd_result = pd_result.reindex( + ["count", "unique", "mean", "std", "min", "max"] + # BF counter part of "unique" is called "nunique" + ).rename(index={"unique": "nunique"}) + + pandas.testing.assert_frame_equal( + pd_result[numeric_columns].astype("Float64"), + bf_result[numeric_columns], + check_index_type=False, + ) + + pandas.testing.assert_frame_equal( + pd_result[non_numeric_columns].astype("Int64"), + bf_result[non_numeric_columns], + check_index_type=False, + ) + + # Double-check that quantiles are at least plausible. + assert ( + (bf_min <= bf_p25) + & (bf_p25 <= bf_p50) + & (bf_p50 <= bf_p50) + & (bf_p75 <= bf_max) + ).all() + + +def test_series_describe_numeric(scalars_dfs): + target_col = "int64_col" + bf_df, pd_df = scalars_dfs + bf_s, pd_s = bf_df[target_col], pd_df[target_col] + + bf_result = ( + bf_s.describe() + .to_pandas() + .reindex(["count", "nunique", "mean", "std", "min", "max"]) + ) + pd_result = ( + pd_s.describe() + .reindex(["count", "unique", "mean", "std", "min", "max"]) + .rename(index={"unique": "nunique"}) + ) + + pandas.testing.assert_series_equal( + bf_result, + pd_result, + check_dtype=False, + check_index_type=False, + ) + + +def test_series_describe_non_numeric(scalars_dfs): + target_col = "string_col" + bf_df, pd_df = scalars_dfs + bf_s, pd_s = bf_df[target_col], pd_df[target_col] + + bf_result = bf_s.describe().to_pandas().reindex(["count", "nunique"]) + pd_result = ( + pd_s.describe().reindex(["count", "unique"]).rename(index={"unique": "nunique"}) + ) + + pandas.testing.assert_series_equal( + bf_result, + pd_result, + check_dtype=False, + check_index_type=False, + ) + + +def test_series_describe_temporal(scalars_dfs): + # Pandas returns for unique timestamps only after 2.1.0 + pytest.importorskip("pandas", minversion="2.1.0") + target_col = "timestamp_col" + bf_df, pd_df = scalars_dfs + bf_s, pd_s = bf_df[target_col], pd_df[target_col] + + bf_result = bf_s.describe().to_pandas().reindex(["count", "nunique"]) + pd_result = ( + pd_s.describe().reindex(["count", "unique"]).rename(index={"unique": "nunique"}) + ) + + pandas.testing.assert_series_equal( + bf_result, + pd_result, + check_dtype=False, + check_index_type=False, + ) + + +def test_df_groupby_describe(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + numeric_columns = [ + "int64_col", + "float64_col", + ] + non_numeric_columns = ["string_col"] + supported_columns = numeric_columns + non_numeric_columns + + bf_full_result = ( + scalars_df.groupby("bool_col")[supported_columns] + .describe(include="all") + .to_pandas() + ) + + pd_full_result = scalars_pandas_df.groupby("bool_col")[supported_columns].describe( + include="all" + ) + + for col in supported_columns: + pd_result = pd_full_result[col] + bf_result = bf_full_result[col] + + if col in numeric_columns: + # Drop quartiles, as they are approximate + bf_min = bf_result["min"] + bf_p25 = bf_result["25%"] + bf_p50 = bf_result["50%"] + bf_p75 = bf_result["75%"] + bf_max = bf_result["max"] + + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex( + columns=["count", "mean", "std", "min", "max"] + ) + pd_result = pd_result.reindex( + columns=["count", "mean", "std", "min", "max"] + ) + + # Double-check that quantiles are at least plausible. + assert ( + (bf_min <= bf_p25) + & (bf_p25 <= bf_p50) + & (bf_p50 <= bf_p50) + & (bf_p75 <= bf_max) + ).all() + else: + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex(columns=["count", "nunique"]) + pd_result = pd_result.reindex(columns=["count", "unique"]) + pandas.testing.assert_frame_equal( + # BF counter part of "unique" is called "nunique" + pd_result.astype("Float64").rename(columns={"unique": "nunique"}), + bf_result, + check_dtype=False, + check_index_type=False, + ) + + +def test_series_groupby_describe(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + numeric_columns = [ + "int64_col", + "float64_col", + ] + non_numeric_columns = ["string_col"] + supported_columns = numeric_columns + non_numeric_columns + + bf_df = scalars_df.groupby("bool_col") + + pd_df = scalars_pandas_df.groupby("bool_col") + + for col in supported_columns: + pd_result = pd_df[col].describe(include="all") + bf_result = bf_df[col].describe(include="all").to_pandas() + + if col in numeric_columns: + # Drop quartiles, as they are approximate + bf_min = bf_result["min"] + bf_p25 = bf_result["25%"] + bf_p50 = bf_result["50%"] + bf_p75 = bf_result["75%"] + bf_max = bf_result["max"] + + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex( + columns=["count", "mean", "std", "min", "max"] + ) + pd_result = pd_result.reindex( + columns=["count", "mean", "std", "min", "max"] + ) + + # Double-check that quantiles are at least plausible. + assert ( + (bf_min <= bf_p25) + & (bf_p25 <= bf_p50) + & (bf_p50 <= bf_p50) + & (bf_p75 <= bf_max) + ).all() + else: + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex(columns=["count", "nunique"]) + pd_result = pd_result.reindex(columns=["count", "unique"]) + pandas.testing.assert_frame_equal( + # BF counter part of "unique" is called "nunique" + pd_result.astype("Float64").rename(columns={"unique": "nunique"}), + bf_result, + check_dtype=False, + check_index_type=False, + ) diff --git a/tests/system/small/pandas/test_read_gbq_colab.py b/tests/system/small/pandas/test_read_gbq_colab.py new file mode 100644 index 0000000000..6e848ed9ea --- /dev/null +++ b/tests/system/small/pandas/test_read_gbq_colab.py @@ -0,0 +1,329 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import datetime +import decimal + +import db_dtypes # type: ignore +import geopandas # type: ignore +import numpy +import pandas +import pyarrow +import pytest +import shapely.geometry # type: ignore + +from bigframes.pandas.io import api as module_under_test + + +@pytest.mark.parametrize( + ("df_pd",), + ( + # Regression tests for b/428190014. + # + # Test every BigQuery type we support, especially those where the legacy + # SQL type name differs from the GoogleSQL type name. + # + # See: + # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types + # and compare to the legacy types at + # https://cloud.google.com/bigquery/docs/data-types + pytest.param( + pandas.DataFrame( + { + "ints": pandas.Series( + [[1], [2], [3]], + dtype=pandas.ArrowDtype(pyarrow.list_(pyarrow.int64())), + ), + "floats": pandas.Series( + [[1.0], [2.0], [3.0]], + dtype=pandas.ArrowDtype(pyarrow.list_(pyarrow.float64())), + ), + } + ), + id="arrays", + ), + pytest.param( + pandas.DataFrame( + { + "bool": pandas.Series([True, False, True], dtype="bool"), + "boolean": pandas.Series([True, None, True], dtype="boolean"), + "object": pandas.Series([True, None, True], dtype="object"), + "arrow": pandas.Series( + [True, None, True], dtype=pandas.ArrowDtype(pyarrow.bool_()) + ), + } + ), + id="bools", + ), + pytest.param( + pandas.DataFrame( + { + "bytes": pandas.Series([b"a", b"b", b"c"], dtype=numpy.bytes_), + "object": pandas.Series([b"a", None, b"c"], dtype="object"), + "arrow": pandas.Series( + [b"a", None, b"c"], dtype=pandas.ArrowDtype(pyarrow.binary()) + ), + } + ), + id="bytes", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + datetime.date(2023, 11, 23), + None, + datetime.date(1970, 1, 1), + ], + dtype="object", + ), + "arrow": pandas.Series( + [ + datetime.date(2023, 11, 23), + None, + datetime.date(1970, 1, 1), + ], + dtype=pandas.ArrowDtype(pyarrow.date32()), + ), + } + ), + id="dates", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + datetime.datetime(2023, 11, 23, 13, 14, 15), + None, + datetime.datetime(1970, 1, 1, 0, 0, 0), + ], + dtype="object", + ), + "datetime64": pandas.Series( + [ + datetime.datetime(2023, 11, 23, 13, 14, 15), + None, + datetime.datetime(1970, 1, 1, 0, 0, 0), + ], + dtype="datetime64[us]", + ), + "arrow": pandas.Series( + [ + datetime.datetime(2023, 11, 23, 13, 14, 15), + None, + datetime.datetime(1970, 1, 1, 0, 0, 0), + ], + dtype=pandas.ArrowDtype(pyarrow.timestamp("us")), + ), + } + ), + id="datetimes", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + shapely.geometry.Point(145.0, -37.8), + None, + shapely.geometry.Point(-122.3, 47.6), + ], + dtype="object", + ), + "geopandas": geopandas.GeoSeries( + [ + shapely.geometry.Point(145.0, -37.8), + None, + shapely.geometry.Point(-122.3, 47.6), + ] + ), + } + ), + id="geographys", + ), + # TODO(tswast): Add INTERVAL once BigFrames supports it. + pytest.param( + pandas.DataFrame( + { + # TODO(tswast): Is there an equivalent object type we can use here? + # TODO(tswast): Add built-in Arrow extension type + "db_dtypes": pandas.Series( + ["{}", None, "123"], + dtype=pandas.ArrowDtype(db_dtypes.JSONArrowType()), + ), + } + ), + id="jsons", + ), + pytest.param( + pandas.DataFrame( + { + "int64": pandas.Series([1, 2, 3], dtype="int64"), + "Int64": pandas.Series([1, None, 3], dtype="Int64"), + "object": pandas.Series([1, None, 3], dtype="object"), + "arrow": pandas.Series( + [1, None, 3], dtype=pandas.ArrowDtype(pyarrow.int64()) + ), + } + ), + id="ints", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [decimal.Decimal("1.23"), None, decimal.Decimal("4.56")], + dtype="object", + ), + "arrow": pandas.Series( + [decimal.Decimal("1.23"), None, decimal.Decimal("4.56")], + dtype=pandas.ArrowDtype(pyarrow.decimal128(38, 9)), + ), + } + ), + id="numerics", + ), + pytest.param( + pandas.DataFrame( + { + # TODO(tswast): Add object type for BIGNUMERIC. Can bigframes disambiguate? + "arrow": pandas.Series( + [decimal.Decimal("1.23"), None, decimal.Decimal("4.56")], + dtype=pandas.ArrowDtype(pyarrow.decimal256(76, 38)), + ), + } + ), + id="bignumerics", + ), + pytest.param( + pandas.DataFrame( + { + "float64": pandas.Series([1.23, None, 4.56], dtype="float64"), + "Float64": pandas.Series([1.23, None, 4.56], dtype="Float64"), + "object": pandas.Series([1.23, None, 4.56], dtype="object"), + "arrow": pandas.Series( + [1.23, None, 4.56], dtype=pandas.ArrowDtype(pyarrow.float64()) + ), + } + ), + id="floats", + ), + # TODO(tswast): Add RANGE once BigFrames supports it. + pytest.param( + pandas.DataFrame( + { + "string": pandas.Series(["a", "b", "c"], dtype="string[python]"), + "object": pandas.Series(["a", None, "c"], dtype="object"), + "arrow": pandas.Series(["a", None, "c"], dtype="string[pyarrow]"), + } + ), + id="strings", + ), + pytest.param( + pandas.DataFrame( + { + # TODO(tswast): Add object type for STRUCT? How to tell apart from JSON? + "arrow": pandas.Series( + [{"a": 1, "b": 1.0, "c": "c"}], + dtype=pandas.ArrowDtype( + pyarrow.struct( + [ + ("a", pyarrow.int64()), + ("b", pyarrow.float64()), + ("c", pyarrow.string()), + ] + ) + ), + ), + } + ), + id="structs", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + datetime.time(0, 0, 0), + None, + datetime.time(13, 7, 11), + ], + dtype="object", + ), + "arrow": pandas.Series( + [ + datetime.time(0, 0, 0), + None, + datetime.time(13, 7, 11), + ], + dtype=pandas.ArrowDtype(pyarrow.time64("us")), + ), + } + ), + id="times", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + datetime.datetime( + 2023, 11, 23, 13, 14, 15, tzinfo=datetime.timezone.utc + ), + None, + datetime.datetime( + 1970, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ), + ], + dtype="object", + ), + "datetime64": pandas.Series( + [ + datetime.datetime(2023, 11, 23, 13, 14, 15), + None, + datetime.datetime(1970, 1, 1, 0, 0, 0), + ], + dtype="datetime64[us]", + ).dt.tz_localize("UTC"), + "arrow": pandas.Series( + [ + datetime.datetime( + 2023, 11, 23, 13, 14, 15, tzinfo=datetime.timezone.utc + ), + None, + datetime.datetime( + 1970, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ), + ], + dtype=pandas.ArrowDtype(pyarrow.timestamp("us", "UTC")), + ), + } + ), + id="timestamps", + ), + ), +) +def test_read_gbq_colab_sessionless_dry_run_generates_valid_sql_for_local_dataframe( + df_pd: pandas.DataFrame, +): + # This method will fail with an exception if it receives invalid SQL. + result = module_under_test._run_read_gbq_colab_sessionless_dry_run( + query="SELECT * FROM {df_pd}", + pyformat_args={"df_pd": df_pd}, + ) + assert isinstance(result, pandas.Series) diff --git a/tests/system/small/pandas/test_read_gbq_information_schema.py b/tests/system/small/pandas/test_read_gbq_information_schema.py new file mode 100644 index 0000000000..32e2dc4712 --- /dev/null +++ b/tests/system/small/pandas/test_read_gbq_information_schema.py @@ -0,0 +1,50 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + + +@pytest.mark.parametrize("include_project", [True, False]) +@pytest.mark.parametrize( + "view_id", + [ + # https://cloud.google.com/bigquery/docs/information-schema-intro + "region-US.INFORMATION_SCHEMA.SESSIONS_BY_USER", + "region-US.INFORMATION_SCHEMA.SCHEMATA", + ], +) +def test_read_gbq_jobs_by_user_returns_schema( + unordered_session, view_id: str, include_project: bool +): + if include_project: + table_id = unordered_session.bqclient.project + "." + view_id + else: + table_id = view_id + + df = unordered_session.read_gbq(table_id, max_results=10) + assert df.dtypes is not None + + +def test_read_gbq_schemata_can_be_peeked(unordered_session): + df = unordered_session.read_gbq("region-US.INFORMATION_SCHEMA.SCHEMATA") + result = df.peek() + assert result is not None + + +def test_read_gbq_schemata_four_parts_can_be_peeked(unordered_session): + df = unordered_session.read_gbq( + f"{unordered_session.bqclient.project}.region-US.INFORMATION_SCHEMA.SCHEMATA" + ) + result = df.peek() + assert result is not None diff --git a/tests/system/small/regression/test_issue355_merge_after_filter.py b/tests/system/small/regression/test_issue355_merge_after_filter.py index 24ee01cb7f..d3486810f7 100644 --- a/tests/system/small/regression/test_issue355_merge_after_filter.py +++ b/tests/system/small/regression/test_issue355_merge_after_filter.py @@ -15,7 +15,7 @@ import pandas as pd import pytest -from tests.system.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal @pytest.mark.parametrize( @@ -67,4 +67,4 @@ def test_merge_after_filter(baseball_schedules_df, merge_how): sort=True, ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) diff --git a/tests/system/small/session/__init__.py b/tests/system/small/session/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/tests/system/small/session/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/system/small/session/test_read_gbq_colab.py b/tests/system/small/session/test_read_gbq_colab.py new file mode 100644 index 0000000000..65f47fe4e3 --- /dev/null +++ b/tests/system/small/session/test_read_gbq_colab.py @@ -0,0 +1,337 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""System tests for read_gbq_colab helper functions.""" + +import numpy +import pandas +import pandas.testing +import pytest + +import bigframes +import bigframes.pandas + +pytest.importorskip("polars") + + +def test_read_gbq_colab_to_pandas_batches_preserves_order_by(maybe_ordered_session): + # This query should return enough results to be too big to fit in a single + # page from jobs.query. + executions_before_sql = maybe_ordered_session._metrics.execution_count + df = maybe_ordered_session._read_gbq_colab( + """ + SELECT + name, + state, + gender, + year, + SUM(number) AS total + FROM + `bigquery-public-data.usa_names.usa_1910_2013` + WHERE state LIKE 'W%' + GROUP BY name, state, gender, year + ORDER BY total DESC + """ + ) + executions_before_python = maybe_ordered_session._metrics.execution_count + batches = df.to_pandas_batches( + page_size=100, + ) + assert batches.total_rows > 0 + assert batches.total_bytes_processed is None # No additional query. + + executions_after = maybe_ordered_session._metrics.execution_count + + num_batches = 0 + for batch in batches: + assert batch["total"].is_monotonic_decreasing + assert len(batch.index) == 100 + num_batches += 1 + + # Only test the first few pages to avoid downloading unnecessary data + # and so we can confirm we have full pages in each batch. + if num_batches >= 3: + break + + assert executions_after == executions_before_python == executions_before_sql + 1 + + +def test_read_gbq_colab_fresh_session_is_hybrid(): + bigframes.close_session() + df = bigframes.pandas._read_gbq_colab( + """ + SELECT + name, + SUM(number) AS total + FROM + `bigquery-public-data.usa_names.usa_1910_2013` + WHERE state LIKE 'W%' + GROUP BY name + ORDER BY total DESC + LIMIT 300 + """ + ) + session = df._session + executions_before_python = session._metrics.execution_count + result = df.sort_values("name").peek(100) + executions_after = session._metrics.execution_count + + assert len(result) == 100 + assert session._executor._enable_polars_execution is True # type: ignore + assert executions_after == executions_before_python == 1 + + +def test_read_gbq_colab_peek_avoids_requery(maybe_ordered_session): + executions_before_sql = maybe_ordered_session._metrics.execution_count + df = maybe_ordered_session._read_gbq_colab( + """ + SELECT + name, + SUM(number) AS total + FROM + `bigquery-public-data.usa_names.usa_1910_2013` + WHERE state LIKE 'W%' + GROUP BY name + ORDER BY total DESC + LIMIT 300 + """ + ) + executions_before_python = maybe_ordered_session._metrics.execution_count + result = df.peek(100) + executions_after = maybe_ordered_session._metrics.execution_count + + # Ok, this isn't guaranteed by peek, but should happen with read api based impl + # if starts failing, maybe stopped using read api? + assert result["total"].is_monotonic_decreasing + + assert len(result) == 100 + assert executions_after == executions_before_python == executions_before_sql + 1 + + +def test_read_gbq_colab_repr_avoids_requery(maybe_ordered_session): + executions_before_sql = maybe_ordered_session._metrics.execution_count + df = maybe_ordered_session._read_gbq_colab( + """ + SELECT + name, + SUM(number) AS total + FROM + `bigquery-public-data.usa_names.usa_1910_2013` + WHERE state LIKE 'W%' + GROUP BY name + ORDER BY total DESC + LIMIT 300 + """ + ) + executions_before_python = maybe_ordered_session._metrics.execution_count + _ = repr(df) + executions_after = maybe_ordered_session._metrics.execution_count + assert executions_after == executions_before_python == executions_before_sql + 1 + + +def test_read_gbq_colab_includes_formatted_scalars(session): + pyformat_args = { + "some_integer": 123, + "some_string": "This could be dangerous.", + # This is not a supported type, but ignored if not referenced. + "some_object": object(), + } + + # This query should return few enough results to be small enough to fit in a + # single page from jobs.query. + df = session._read_gbq_colab( + """ + SELECT {some_integer} as some_integer, + '{some_string}' as some_string, + '{{escaped}}' as escaped + """, + pyformat_args=pyformat_args, + ) + result = df.to_pandas() + pandas.testing.assert_frame_equal( + result, + pandas.DataFrame( + { + "some_integer": pandas.Series([123], dtype=pandas.Int64Dtype()), + "some_string": pandas.Series( + ["This could be dangerous."], + dtype="string[pyarrow]", + ), + "escaped": pandas.Series(["{escaped}"], dtype="string[pyarrow]"), + } + ), + check_index_type=False, # int64 vs Int64 + ) + + +@pytest.mark.skipif( + pandas.__version__.startswith("1."), reason="bad left join in pandas 1.x" +) +def test_read_gbq_colab_includes_formatted_dataframes( + session, scalars_df_index, scalars_pandas_df_index +): + pd_df = pandas.DataFrame( + { + "rowindex": [0, 1, 2, 3, 4, 5], + "value": [0, 100, 200, 300, 400, 500], + } + ) + + # Make sure we test with some data that is too large to inline as SQL. + pd_df_large = pandas.DataFrame( + { + "rowindex": numpy.arange(100_000), + "large_value": numpy.arange(100_000), + } + ) + + pyformat_args = { + # Apply some operations to make sure the columns aren't renamed. + "bf_df": scalars_df_index[scalars_df_index["int64_col"] > 0].assign( + int64_col=scalars_df_index["int64_too"] + ), + "pd_df": pd_df, + "pd_df_large": pd_df_large, + # This is not a supported type, but ignored if not referenced. + "some_object": object(), + } + sql = """ + SELECT bf_df.int64_col + pd_df.value + pd_df_large.large_value AS int64_col, + COALESCE(bf_df.rowindex, pd_df.rowindex, pd_df_large.rowindex) AS rowindex + FROM {bf_df} AS bf_df + FULL OUTER JOIN {pd_df} AS pd_df + ON bf_df.rowindex = pd_df.rowindex + LEFT JOIN {pd_df_large} AS pd_df_large + ON bf_df.rowindex = pd_df_large.rowindex + ORDER BY rowindex ASC + """ + + # Do the dry run first so that we don't re-use the uploaded data from the + # real query. + dry_run_output = session._read_gbq_colab( + sql, + pyformat_args=pyformat_args, + dry_run=True, + ) + + df = session._read_gbq_colab( + sql, + pyformat_args=pyformat_args, + ) + + # Confirm that dry_run was accurate. + pandas.testing.assert_series_equal( + pandas.Series(dry_run_output["columnDtypes"]), + df.dtypes, + ) + + result = df.to_pandas() + expected = ( + scalars_pandas_df_index[scalars_pandas_df_index["int64_col"] > 0] + .assign(int64_col=scalars_pandas_df_index["int64_too"]) + .reset_index(drop=False)[["int64_col", "rowindex"]] + .merge( + pd_df, + on="rowindex", + how="outer", + ) + .merge( + pd_df_large, + on="rowindex", + how="left", + ) + .assign( + int64_col=lambda df: ( + df["int64_col"] + df["value"] + df["large_value"] + ).astype("Int64") + ) + .drop(columns=["value", "large_value"]) + .sort_values(by="rowindex") + .reset_index(drop=True) + ) + pandas.testing.assert_frame_equal( + result, + expected, + check_index_type=False, # int64 vs Int64 + ) + + +@pytest.mark.parametrize( + ("pd_df",), + ( + pytest.param( + pandas.DataFrame( + { + "rowindex": [0, 1, 2, 3, 4, 5], + "value": [0, 100, 200, 300, 400, 500], + "value2": [-1, -2, -3, -4, -5, -6], + } + ), + id="inline-df", + ), + pytest.param( + pandas.DataFrame( + { + # Make sure we test with some data that is too large to + # inline as SQL. + "rowindex": numpy.arange(100_000), + "value": numpy.arange(100_000), + "value2": numpy.arange(100_000), + } + ), + id="large-df", + ), + ), +) +def test_read_gbq_colab_with_formatted_dataframe_deduplicates_column_names_just_like_to_gbq( + session, + pd_df, +): + # Create duplicate column names. + pd_df.columns = ["rowindex", "value", "value"] + + pyformat_args = { + "pd_df": pd_df, + } + sql = """ + SELECT rowindex, value, value_1 + FROM {pd_df} + """ + + # Do the dry run first so that we don't re-use the uploaded data from the + # real query. + dry_run_output = session._read_gbq_colab( + sql, + pyformat_args=pyformat_args, + dry_run=True, + ) + + df = session._read_gbq_colab( + sql, + pyformat_args=pyformat_args, + ) + + # Confirm that dry_run was accurate. + pandas.testing.assert_series_equal( + pandas.Series(dry_run_output["columnDtypes"]), + df.dtypes, + ) + + # Make sure the query doesn't fail. + df.to_pandas_batches() + + # Make sure the + table_id = session.read_pandas(pd_df).to_gbq() + table = session.bqclient.get_table(table_id) + assert [field.name for field in table.schema] == ["rowindex", "value", "value_1"] diff --git a/tests/system/small/session/test_read_gbq_query.py b/tests/system/small/session/test_read_gbq_query.py new file mode 100644 index 0000000000..bb9026dc70 --- /dev/null +++ b/tests/system/small/session/test_read_gbq_query.py @@ -0,0 +1,113 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +import pytest + +import bigframes +import bigframes.core.nodes as nodes + + +def test_read_gbq_query_w_allow_large_results(session: bigframes.Session): + if not hasattr(session.bqclient, "default_job_creation_mode"): + pytest.skip("Jobless query only available on newer google-cloud-bigquery.") + + query = "SELECT 1" + + # Make sure we don't get a cached table. + configuration = {"query": {"useQueryCache": False}} + + # Very small results should wrap a local node. + df_false = session.read_gbq( + query, + configuration=configuration, + allow_large_results=False, + ) + assert df_false.shape == (1, 1) + nodes_false = df_false._get_block().expr.node.unique_nodes() + assert any(isinstance(node, nodes.ReadLocalNode) for node in nodes_false) + assert not any(isinstance(node, nodes.ReadTableNode) for node in nodes_false) + + # Large results allowed should wrap a table. + df_true = session.read_gbq( + query, + configuration=configuration, + allow_large_results=True, + ) + assert df_true.shape == (1, 1) + nodes_true = df_true._get_block().expr.node.unique_nodes() + assert any(isinstance(node, nodes.ReadTableNode) for node in nodes_true) + + +def test_read_gbq_query_w_columns(session: bigframes.Session): + query = """ + SELECT 1 as int_col, + 'a' as str_col, + TIMESTAMP('2025-08-21 10:41:32.123456') as timestamp_col + """ + + result = session.read_gbq( + query, + columns=["timestamp_col", "int_col"], + ) + assert list(result.columns) == ["timestamp_col", "int_col"] + assert result.to_dict(orient="records") == [ + { + "timestamp_col": datetime.datetime( + 2025, 8, 21, 10, 41, 32, 123456, tzinfo=datetime.timezone.utc + ), + "int_col": 1, + } + ] + + +@pytest.mark.parametrize( + ("index_col", "expected_index_names"), + ( + pytest.param( + "my_custom_index", + ("my_custom_index",), + id="string", + ), + pytest.param( + ("my_custom_index",), + ("my_custom_index",), + id="iterable", + ), + pytest.param( + ("my_custom_index", "int_col"), + ("my_custom_index", "int_col"), + id="multiindex", + ), + ), +) +def test_read_gbq_query_w_index_col( + session: bigframes.Session, index_col, expected_index_names +): + query = """ + SELECT 1 as int_col, + 'a' as str_col, + 0 as my_custom_index, + TIMESTAMP('2025-08-21 10:41:32.123456') as timestamp_col + """ + + result = session.read_gbq( + query, + index_col=index_col, + ) + assert tuple(result.index.names) == expected_index_names + assert frozenset(result.columns) == frozenset( + {"int_col", "str_col", "my_custom_index", "timestamp_col"} + ) - frozenset(expected_index_names) diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py new file mode 100644 index 0000000000..8d4fcc8e89 --- /dev/null +++ b/tests/system/small/test_anywidget.py @@ -0,0 +1,1175 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""System tests for the anywidget-based table widget.""" + +from typing import Any +from unittest import mock + +import pandas as pd +import pytest + +import bigframes as bf +import bigframes.core.blocks +import bigframes.dataframe +import bigframes.display + +pytest.importorskip("anywidget") + +# Test constants to avoid change detector tests +EXPECTED_ROW_COUNT = 6 +EXPECTED_PAGE_SIZE = 2 +EXPECTED_TOTAL_PAGES = 3 + + +@pytest.fixture(scope="module") +def paginated_pandas_df() -> pd.DataFrame: + """Create a minimal test DataFrame with exactly 3 pages of 2 rows each.""" + test_data = pd.DataFrame( + { + "id": [5, 4, 3, 2, 1, 0], + "page_indicator": [ + "page_3_row_2", + "page_3_row_1", + "page_2_row_2", + "page_2_row_1", + "page_1_row_2", + "page_1_row_1", + ], + "value": [5, 4, 3, 2, 1, 0], + } + ) + return test_data + + +@pytest.fixture(scope="module") +def paginated_bf_df( + session: bf.Session, paginated_pandas_df: pd.DataFrame +) -> bigframes.dataframe.DataFrame: + return session.read_pandas(paginated_pandas_df) + + +@pytest.fixture +def table_widget(paginated_bf_df: bigframes.dataframe.DataFrame): + """ + Helper fixture to create a TableWidget instance with a fixed page size. + This reduces duplication across tests that use the same widget configuration. + """ + + from bigframes.display import TableWidget + + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): + # Delay context manager cleanup of `max_rows` until after tests finish. + yield TableWidget(paginated_bf_df) + + +@pytest.fixture(scope="module") +def small_pandas_df() -> pd.DataFrame: + """Create a DataFrame smaller than the page size for edge case testing.""" + return pd.DataFrame( + { + "id": [0, 1], + "page_indicator": ["small_row_1", "small_row_2"], + "value": [0, 1], + } + ) + + +@pytest.fixture(scope="module") +def small_bf_df( + session: bf.Session, small_pandas_df: pd.DataFrame +) -> bf.dataframe.DataFrame: + return session.read_pandas(small_pandas_df) + + +@pytest.fixture +def small_widget(small_bf_df): + """Helper fixture for tests using a DataFrame smaller than the page size.""" + from bigframes.display import TableWidget + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 5): + yield TableWidget(small_bf_df) + + +@pytest.fixture +def unknown_row_count_widget(session): + """Fixture to create a TableWidget with an unknown row count.""" + from bigframes.core import blocks + from bigframes.display import TableWidget + + # Create a small DataFrame with known content + test_data = pd.DataFrame( + { + "id": [0, 1, 2, 3, 4], + "value": ["row_0", "row_1", "row_2", "row_3", "row_4"], + } + ) + bf_df = session.read_pandas(test_data) + + # Simulate a scenario where total_rows is not available from the iterator + with mock.patch.object(bf_df, "_to_pandas_batches") as mock_batches: + # We need to provide an iterator of DataFrames, not Series + batches_iterator = iter([test_data]) + mock_batches.return_value = blocks.PandasBatches( + batches_iterator, total_rows=None + ) + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(bf_df) + yield widget + + +@pytest.fixture(scope="module") +def empty_pandas_df() -> pd.DataFrame: + """Create an empty DataFrame for edge case testing.""" + return pd.DataFrame(columns=["id", "page_indicator", "value"]) + + +@pytest.fixture(scope="module") +def empty_bf_df( + session: bf.Session, empty_pandas_df: pd.DataFrame +) -> bf.dataframe.DataFrame: + return session.read_pandas(empty_pandas_df) + + +def mock_execute_result_with_params( + self, schema, total_rows_val, arrow_batches_val, *args, **kwargs +): + """ + Mocks an execution result with configurable total_rows and arrow_batches. + """ + from bigframes.session.executor import ( + ExecuteResult, + ExecutionMetadata, + ResultsIterator, + ) + + class MockExecuteResult(ExecuteResult): + @property + def execution_metadata(self) -> ExecutionMetadata: + return ExecutionMetadata() + + @property + def schema(self) -> Any: + return schema + + def batches(self) -> ResultsIterator: + return ResultsIterator( + arrow_batches_val, + self.schema, + total_rows_val, + None, + ) + + return MockExecuteResult() + + +def _assert_html_matches_pandas_slice( + table_html: str, + expected_pd_slice: pd.DataFrame, + full_pd_df: pd.DataFrame, +): + """ + Assertion helper to verify that the rendered HTML contains exactly the + rows from the expected pandas DataFrame slice and no others. This is + inspired by the pattern of comparing BigFrames output to pandas output. + """ + # Check that the unique indicator from each expected row is present. + for _, row in expected_pd_slice.iterrows(): + assert row["page_indicator"] in table_html + + # Create a DataFrame of all rows that should NOT be present. + unexpected_pd_df = full_pd_df.drop(expected_pd_slice.index) + + # Check that no unique indicators from unexpected rows are present. + for _, row in unexpected_pd_df.iterrows(): + assert row["page_indicator"] not in table_html + + +def test_widget_initialization_should_calculate_total_row_count( + paginated_bf_df: bf.dataframe.DataFrame, +): + """Test that a TableWidget calculates the total row count on creation.""" + """A TableWidget should correctly calculate the total row count on creation.""" + from bigframes.display import TableWidget + + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): + widget = TableWidget(paginated_bf_df) + + assert widget.row_count == EXPECTED_ROW_COUNT + + +def test_widget_initialization_should_default_to_page_zero( + table_widget, +): + """ + Given a new TableWidget, when it is initialized, + then its page number should default to 0. + """ + # The `table_widget` fixture already creates the widget. + # Assert its state. + assert table_widget.page == 0 + assert table_widget.page_size == EXPECTED_PAGE_SIZE + + +def test_widget_display_should_show_first_page_on_load( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a widget, when it is first loaded, then it should display + the first page of data. + """ + expected_slice = paginated_pandas_df.iloc[0:2] + + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +@pytest.mark.parametrize( + "page_number, start_row, end_row", + [ + (1, 2, 4), # Second page + (2, 4, 6), # Last page + ], + ids=["second_page", "last_page"], +) +def test_widget_navigation_should_display_correct_page( + table_widget, + paginated_pandas_df: pd.DataFrame, + page_number: int, + start_row: int, + end_row: int, +): + """ + Given a widget, when the page is set, then it should display the correct + slice of data. + """ + expected_slice = paginated_pandas_df.iloc[start_row:end_row] + + table_widget.page = page_number + html = table_widget.table_html + + assert table_widget.page == page_number + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_setting_negative_page_should_raise_error( + table_widget, +): + """ + Given a widget, when a negative page number is set, + then a ValueError should be raised. + """ + with pytest.raises(ValueError, match="Page number cannot be negative."): + table_widget.page = -1 + + +def test_setting_page_beyond_max_should_clamp_to_last_page( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a widget, + when a page number greater than the max is set, + then the page number should be clamped to the last valid page. + """ + expected_slice = paginated_pandas_df.iloc[4:6] # Last page data + + table_widget.page = 100 # Set page far beyond the total of 3 pages + html = table_widget.table_html + + assert table_widget.page == 2 # Page is clamped to the last valid page (0-indexed) + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +@pytest.mark.parametrize( + "page, start_row, end_row", + [ + (0, 0, 3), # Page 0: rows 0-2 + (1, 3, 6), # Page 1: rows 3-5 + ], + ids=[ + "Page 0 (Rows 0-2)", + "Page 1 (Rows 3-5)", + ], +) +def test_widget_pagination_should_work_with_custom_page_size( + paginated_bf_df: bf.dataframe.DataFrame, + paginated_pandas_df: pd.DataFrame, + page: int, + start_row: int, + end_row: int, +): + """Test that a widget paginates correctly with a custom page size.""" + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 3 + ): + from bigframes.display import TableWidget + + widget = TableWidget(paginated_bf_df) + assert widget.page_size == 3 + + expected_slice = paginated_pandas_df.iloc[start_row:end_row] + + widget.page = page + html = widget.table_html + + assert widget.page == page + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_widget_with_few_rows_should_display_all_rows(small_widget, small_pandas_df): + """ + Given a DataFrame smaller than the page size, the widget should + display all rows on the first page. + """ + html = small_widget.table_html + + _assert_html_matches_pandas_slice(html, small_pandas_df, small_pandas_df) + + +def test_navigation_beyond_last_page_should_be_clamped(small_widget): + """ + Given a DataFrame smaller than the page size, + when navigating beyond the last page, + then the page should be clamped to the last valid page (page 0). + """ + # For a DataFrame with 2 rows and page_size 5 (from small_widget fixture), + # the frontend should calculate 1 total page. + assert small_widget.row_count == 2 + + # The widget should always be on page 0 for a single-page dataset. + assert small_widget.page == 0 + + # Attempting to navigate to page 1 should be clamped back to page 0, + # confirming that only one page is recognized by the backend. + small_widget.page = 1 + assert small_widget.page == 0 + + +def test_global_options_change_should_not_affect_existing_widget_page_size( + paginated_bf_df: bf.dataframe.DataFrame, +): + """ + Given an existing widget, + when global display options are changed, + then the widget's page size should remain unchanged. + """ + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): + from bigframes.display import TableWidget + + widget = TableWidget(paginated_bf_df) + initial_page_size = widget.page_size + assert initial_page_size == 2 + widget.page = 1 # a non-default state + assert widget.page == 1 + + bf.options.display.max_rows = 10 # Change global setting + + assert widget.page_size == initial_page_size # Should remain unchanged + assert widget.page == 1 # Page should not be reset + + +def test_widget_with_empty_dataframe_should_have_zero_row_count( + empty_bf_df: bf.dataframe.DataFrame, +): + """ + Given an empty DataFrame, + when a widget is created from it, + then its row_count should be 0. + """ + + with bigframes.option_context("display.repr_mode", "anywidget"): + from bigframes.display import TableWidget + + widget = TableWidget(empty_bf_df) + + assert widget.row_count == 0 + + +def test_widget_with_empty_dataframe_should_render_table_headers( + empty_bf_df: bf.dataframe.DataFrame, +): + + """ + + + Given an empty DataFrame, + + + when a widget is created from it, + + + then its HTML representation should still render the table headers. + + + """ + + with bigframes.option_context("display.repr_mode", "anywidget"): + + from bigframes.display import TableWidget + + widget = TableWidget(empty_bf_df) + + html = widget.table_html + + assert " 0 + assert ".bigframes-widget .footer" in css_content + + +def test_widget_row_count_should_be_immutable_after_creation( + paginated_bf_df: bf.dataframe.DataFrame, +): + """ + Given a widget created with a specific configuration when global display + options are changed later, the widget's original row_count should remain + unchanged. + """ + from bigframes.display import TableWidget + + # Use a context manager to ensure the option is reset + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): + widget = TableWidget(paginated_bf_df) + initial_row_count = widget.row_count + + # Change a global option that could influence row count + bf.options.display.max_rows = 10 + + # Verify the row count remains immutable. + assert widget.row_count == initial_row_count + + +class FaultyIterator: + def __iter__(self): + return self + + def __next__(self): + raise ValueError("Simulated read error") + + +def test_widget_should_show_error_on_batch_failure( + paginated_bf_df: bf.dataframe.DataFrame, + monkeypatch: pytest.MonkeyPatch, +): + """ + Given that the internal call to `_to_pandas_batches` fails and returns None, + when the TableWidget is created, its `error_message` should be set and displayed. + """ + # Patch the DataFrame's batch creation method to simulate a failure. + monkeypatch.setattr( + "bigframes.dataframe.DataFrame._to_pandas_batches", + lambda self, *args, **kwargs: None, + ) + + # Create the TableWidget under the error condition. + with bigframes.option_context("display.repr_mode", "anywidget"): + from bigframes.display import TableWidget + + # The widget should handle the faulty data from the mock without crashing. + widget = TableWidget(paginated_bf_df) + + # The widget should have an error message and display it in the HTML. + assert widget.row_count is None + assert widget._error_message is not None + assert "Could not retrieve data batches" in widget._error_message + assert widget._error_message in widget.table_html + + +def test_widget_row_count_reflects_actual_data_available( + paginated_bf_df: bf.dataframe.DataFrame, +): + """ + Test that widget row_count reflects the actual data available, + regardless of theoretical limits. + """ + from bigframes.display import TableWidget + + # Set up display options that define a page size. + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): + widget = TableWidget(paginated_bf_df) + + # The widget should report the total rows in the DataFrame, + # not limited by page_size (which only affects pagination) + assert widget.row_count == EXPECTED_ROW_COUNT + assert widget.page_size == 2 # Respects the display option + + +def test_widget_with_unknown_row_count_should_auto_navigate_to_last_page( + session: bf.Session, +): + """ + Given a widget with unknown row count (row_count=None), when a user + navigates beyond the available data and all data is loaded, then the + widget should automatically navigate back to the last valid page. + """ + from bigframes.display import TableWidget + + # Create a small DataFrame with known content + test_data = pd.DataFrame( + { + "id": [0, 1, 2, 3, 4], + "value": ["row_0", "row_1", "row_2", "row_3", "row_4"], + } + ) + bf_df = session.read_pandas(test_data) + + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): + widget = TableWidget(bf_df) + + # Manually set row_count to None to simulate unknown total + widget.row_count = None + + # Navigate to a page beyond available data (page 10) + # With page_size=2 and 5 rows, valid pages are 0, 1, 2 + widget.page = 10 + + # Force data loading by accessing table_html + _ = widget.table_html + + # After all data is loaded, widget should auto-navigate to last valid page + # Last valid page = ceil(5 / 2) - 1 = 2 + assert widget.page == 2 + + # Verify the displayed content is the last page + html = widget.table_html + assert "row_4" in html # Last row should be visible + assert "row_0" not in html # First row should not be visible + + +def test_widget_with_unknown_row_count_should_set_none_state_for_frontend( + session: bf.Session, +): + """ + Given a widget with unknown row count, its `row_count` traitlet should be + `None`, which signals the frontend to display 'Page X of many'. + """ + from bigframes.display import TableWidget + + test_data = pd.DataFrame( + { + "id": [0, 1, 2], + "value": ["a", "b", "c"], + } + ) + bf_df = session.read_pandas(test_data) + + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): + widget = TableWidget(bf_df) + + # Set row_count to None + widget.row_count = None + + # Verify row_count is None (not 0) + assert widget.row_count is None + + # The widget should still function normally + assert widget.page == 0 + assert widget.page_size == 2 + + # Force data loading by accessing table_html. This also ensures that + # rendering does not raise an exception. + _ = widget.table_html + + +def test_widget_with_unknown_row_count_should_allow_forward_navigation( + session: bf.Session, +): + """ + Given a widget with unknown row count, users should be able to navigate + forward until they reach the end of available data. + """ + from bigframes.display import TableWidget + + test_data = pd.DataFrame( + { + "id": [0, 1, 2, 3, 4, 5], + "value": ["p0_r0", "p0_r1", "p1_r0", "p1_r1", "p2_r0", "p2_r1"], + } + ) + bf_df = session.read_pandas(test_data) + + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): + widget = TableWidget(bf_df) + widget.row_count = None + + # Navigate to page 1 + widget.page = 1 + html = widget.table_html + assert "p1_r0" in html + assert "p1_r1" in html + + # Navigate to page 2 + widget.page = 2 + html = widget.table_html + assert "p2_r0" in html + assert "p2_r1" in html + + # Navigate beyond available data (page 5) + widget.page = 5 + _ = widget.table_html + + # Should auto-navigate back to last valid page (page 2) + assert widget.page == 2 + + +def test_widget_with_unknown_row_count_empty_dataframe( + session: bf.Session, +): + """ + Given an empty DataFrame with unknown row count, the widget should + stay on page 0 and display empty content. + """ + from bigframes.display import TableWidget + + empty_data = pd.DataFrame(columns=["id", "value"]) + bf_df = session.read_pandas(empty_data) + + with bigframes.option_context("display.repr_mode", "anywidget"): + widget = TableWidget(bf_df) + widget.row_count = None + + # Attempt to navigate to page 5 + widget.page = 5 + _ = widget.table_html + + # Should stay on page 0 for empty DataFrame + assert widget.page == 0 + + +def test_widget_sort_should_sort_ascending_on_first_click( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a widget, when a column header is clicked for the first time, + then the data should be sorted by that column in ascending order. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + expected_slice = paginated_pandas_df.sort_values("id", ascending=True).iloc[0:2] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_widget_sort_should_sort_descending_on_second_click( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a widget sorted by a column, when the same column header is clicked again, + then the data should be sorted by that column in descending order. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + # Second click + table_widget.sort_ascending = False + + expected_slice = paginated_pandas_df.sort_values("id", ascending=False).iloc[0:2] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_widget_sort_should_switch_column_and_sort_ascending( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a widget sorted by a column, when a different column header is clicked, + then the data should be sorted by the new column in ascending order. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + # Click on a different column + table_widget.sort_column = "value" + table_widget.sort_ascending = True + + expected_slice = paginated_pandas_df.sort_values("value", ascending=True).iloc[0:2] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_widget_sort_should_be_maintained_after_pagination( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a sorted widget, when the user navigates to the next page, + then the sorting should be maintained. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + # Go to the second page + table_widget.page = 1 + + expected_slice = paginated_pandas_df.sort_values("id", ascending=True).iloc[2:4] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_widget_sort_should_reset_on_page_size_change( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a sorted widget, when the page size is changed, + then the sorting should be reset. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + table_widget.page_size = 3 + + # Sorting is not reset in the backend, but the view should be of the unsorted df + expected_slice = paginated_pandas_df.iloc[0:3] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +@pytest.fixture(scope="module") +def integer_column_df(session): + """Create a DataFrame with integer column labels.""" + pandas_df = pd.DataFrame([[0, 1], [2, 3]], columns=pd.Index([1, 2])) + return session.read_pandas(pandas_df) + + +@pytest.fixture(scope="module") +def multiindex_column_df(session): + """Create a DataFrame with MultiIndex column labels.""" + pandas_df = pd.DataFrame( + { + "foo": ["one", "one", "one", "two", "two", "two"], + "bar": ["A", "B", "C", "A", "B", "C"], + "baz": [1, 2, 3, 4, 5, 6], + "zoo": ["x", "y", "z", "q", "w", "t"], + } + ) + df = session.read_pandas(pandas_df) + # The session is attached to `df` through the constructor. + # We can pass it to the pivoted DataFrame. + pdf = df.pivot(index="foo", columns="bar", values=["baz", "zoo"]) + return pdf + + +def test_table_widget_integer_columns_disables_sorting(integer_column_df): + """ + Given a DataFrame with integer column labels, the widget should + disable sorting. + """ + from bigframes.display import TableWidget + + widget = TableWidget(integer_column_df) + assert widget.orderable_columns == [] + + +def test_table_widget_multiindex_columns_disables_sorting(multiindex_column_df): + """ + Given a DataFrame with a MultiIndex for columns, the widget should + disable sorting. + """ + from bigframes.display import TableWidget + + widget = TableWidget(multiindex_column_df) + assert widget.orderable_columns == [] + + +def test_repr_mimebundle_should_fallback_to_html_if_anywidget_is_unavailable( + paginated_bf_df: bf.dataframe.DataFrame, +): + """ + Test that _repr_mimebundle_ falls back to static html when anywidget is not available. + """ + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): + # Mock the ANYWIDGET_INSTALLED flag to simulate absence of anywidget + with mock.patch("bigframes.display.anywidget._ANYWIDGET_INSTALLED", False): + bundle = paginated_bf_df._repr_mimebundle_() + assert "application/vnd.jupyter.widget-view+json" not in bundle + assert "text/html" in bundle + html = bundle["text/html"] + assert "page_3_row_2" in html + assert "page_3_row_1" in html + assert "page_1_row_1" not in html + + +def test_repr_mimebundle_should_return_widget_view_if_anywidget_is_available( + paginated_bf_df: bf.dataframe.DataFrame, +): + """ + Test that _repr_mimebundle_ returns a widget view when anywidget is available. + """ + with bigframes.option_context("display.repr_mode", "anywidget"): + bundle = paginated_bf_df._repr_mimebundle_() + assert isinstance(bundle, tuple) + data, metadata = bundle + assert "application/vnd.jupyter.widget-view+json" in data + assert "text/html" in data + assert "text/plain" in data + + +def test_repr_in_anywidget_mode_should_not_be_deferred( + paginated_bf_df: bf.dataframe.DataFrame, +): + """ + Test that repr(df) is not deferred in anywidget mode. + This is to ensure that print(df) works as expected. + """ + with bigframes.option_context("display.repr_mode", "anywidget"): + representation = repr(paginated_bf_df) + assert "Computation deferred" not in representation + assert "page_1_row_1" in representation + + +def test_dataframe_repr_mimebundle_should_return_widget_with_metadata_in_anywidget_mode( + monkeypatch: pytest.MonkeyPatch, + session: bigframes.Session, # Add session as a fixture +): + """Test that _repr_mimebundle_ returns a widget view with metadata when anywidget is available.""" + with bigframes.option_context("display.repr_mode", "anywidget"): + # Create a real DataFrame object (or a mock that behaves like one minimally) + # for _repr_mimebundle_ to operate on. + test_df = bigframes.dataframe.DataFrame( + pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}), session=session + ) + + mock_get_anywidget_bundle_return_value: tuple[ + dict[str, Any], dict[str, Any] + ] = ( + { + "application/vnd.jupyter.widget-view+json": {"model_id": "123"}, + "text/html": "
My Table HTML
", + "text/plain": "My Table Plain Text", + }, + { + "application/vnd.jupyter.widget-view+json": { + "colab": {"custom_widget_manager": {}} + } + }, + ) + + # Patch the class method directly + with mock.patch( + "bigframes.display.html.get_anywidget_bundle", + return_value=mock_get_anywidget_bundle_return_value, + ): + result = test_df._repr_mimebundle_() + + assert isinstance(result, tuple) + data, metadata = result + assert "application/vnd.jupyter.widget-view+json" in data + assert "text/html" in data + assert "text/plain" in data + assert "application/vnd.jupyter.widget-view+json" in metadata + assert "colab" in metadata["application/vnd.jupyter.widget-view+json"] + + +@pytest.fixture(scope="module") +def custom_index_pandas_df() -> pd.DataFrame: + """Create a DataFrame with a custom named index for testing.""" + test_data = pd.DataFrame( + { + "value_a": [10, 20, 30, 40, 50, 60], + "value_b": ["a", "b", "c", "d", "e", "f"], + } + ) + test_data.index = pd.Index( + ["row_1", "row_2", "row_3", "row_4", "row_5", "row_6"], name="custom_idx" + ) + return test_data + + +@pytest.fixture(scope="module") +def custom_index_bf_df( + session: bf.Session, custom_index_pandas_df: pd.DataFrame +) -> bf.dataframe.DataFrame: + return session.read_pandas(custom_index_pandas_df) + + +@pytest.fixture(scope="module") +def multiindex_pandas_df() -> pd.DataFrame: + """Create a DataFrame with MultiIndex for testing.""" + test_data = pd.DataFrame( + { + "value": [100, 200, 300, 400, 500, 600], + "category": ["X", "Y", "Z", "X", "Y", "Z"], + } + ) + test_data.index = pd.MultiIndex.from_arrays( + [ + ["group_A", "group_A", "group_A", "group_B", "group_B", "group_B"], + [1, 2, 3, 1, 2, 3], + ], + names=["group", "item"], + ) + return test_data + + +@pytest.fixture(scope="module") +def multiindex_bf_df( + session: bf.Session, multiindex_pandas_df: pd.DataFrame +) -> bf.dataframe.DataFrame: + return session.read_pandas(multiindex_pandas_df) + + +def test_widget_with_default_index_should_display_index_column_with_empty_header( + paginated_bf_df: bf.dataframe.DataFrame, +): + """ + Given a DataFrame with a default index, when the TableWidget is rendered, + then an index column should be visible with an empty header. + """ + import re + + from bigframes.display.anywidget import TableWidget + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(paginated_bf_df) + html = widget.table_html + + # The header for the index should be present but empty, matching the + # internal rendering logic. + thead = html.split("")[1].split("")[0] + # Find the first header cell and check that its content div is empty. + match = re.search(r"]*>]*>([^<]*)", thead) + assert match is not None, "Could not find table header cell in output." + assert ( + match.group(1) == "" + ), f"Expected empty index header, but found: {match.group(1)}" + + +def test_widget_with_custom_index_should_display_index_column( + custom_index_bf_df: bf.dataframe.DataFrame, +): + """ + Given a DataFrame with a custom named index, when rendered, + then the index column and first page of rows should be visible. + """ + from bigframes.display.anywidget import TableWidget + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(custom_index_bf_df) + html = widget.table_html + + assert "custom_idx" in html + assert "row_1" in html + assert "row_2" in html + assert "row_3" not in html # Verify pagination is working + assert "row_4" not in html + + +def test_widget_with_custom_index_pagination_preserves_index( + custom_index_bf_df: bf.dataframe.DataFrame, +): + """ + Given a DataFrame with a custom index, when navigating to the second page, + then the second page's index values should be visible. + """ + from bigframes.display.anywidget import TableWidget + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(custom_index_bf_df) + + widget.page = 1 # Navigate to page 2 + html = widget.table_html + + assert "row_3" in html + assert "row_4" in html + assert "row_1" not in html # Verify page 1 content is gone + assert "row_2" not in html + + +def test_widget_with_custom_index_matches_pandas_output( + custom_index_bf_df: bf.dataframe.DataFrame, +): + """ + Given a DataFrame with a custom index and max_rows=3, the widget's HTML + output should contain the first three index values. + """ + from bigframes.display.anywidget import TableWidget + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 3): + widget = TableWidget(custom_index_bf_df) + html = widget.table_html + + assert "row_1" in html + assert "row_2" in html + assert "row_3" in html + assert "row_4" not in html # Verify it respects max_rows + + +# TODO(b/438181139): Add tests for custom multiindex +# This may not be necessary for the SQL Cell use case but should be +# considered for completeness. + + +def test_series_anywidget_integration_with_notebook_display( + paginated_bf_df: bf.dataframe.DataFrame, +): + """Test Series display integration in Jupyter-like environment.""" + pytest.importorskip("anywidget") + + with bf.option_context("display.repr_mode", "anywidget"): + series = paginated_bf_df["value"] + + # Test the full display pipeline + from IPython.display import display as ipython_display + + # This should work without errors + ipython_display(series) + + +def test_series_different_data_types_anywidget(session: bf.Session): + """Test Series with different data types in anywidget mode.""" + pytest.importorskip("anywidget") + + # Create Series with different types + test_data = pd.DataFrame( + { + "string_col": ["a", "b", "c"], + "int_col": [1, 2, 3], + "float_col": [1.1, 2.2, 3.3], + "bool_col": [True, False, True], + } + ) + bf_df = session.read_pandas(test_data) + + with bf.option_context("display.repr_mode", "anywidget"): + for col_name in test_data.columns: + series = bf_df[col_name] + widget = bigframes.display.TableWidget(series.to_frame()) + assert widget.row_count == 3 diff --git a/tests/system/small/test_bq_sessions.py b/tests/system/small/test_bq_sessions.py new file mode 100644 index 0000000000..801346600d --- /dev/null +++ b/tests/system/small/test_bq_sessions.py @@ -0,0 +1,87 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from concurrent.futures import ThreadPoolExecutor +import time + +import google +import google.api_core.exceptions +from google.cloud import bigquery +import pytest + +import bigframes.core.events +from bigframes.session import bigquery_session + +TEST_SCHEMA = [ + bigquery.SchemaField("bool field", "BOOLEAN"), + bigquery.SchemaField("string field", "STRING"), + bigquery.SchemaField("float array_field", "FLOAT", mode="REPEATED"), + bigquery.SchemaField( + "struct field", + "RECORD", + fields=(bigquery.SchemaField("int subfield", "INTEGER"),), + ), +] + + +@pytest.fixture +def session_resource_manager( + bigquery_client, +) -> bigquery_session.SessionResourceManager: + return bigquery_session.SessionResourceManager( + bigquery_client, "US", publisher=bigframes.core.events.Publisher() + ) + + +def test_bq_session_create_temp_table_clustered(bigquery_client: bigquery.Client): + session_resource_manager = bigquery_session.SessionResourceManager( + bigquery_client, "US", publisher=bigframes.core.events.Publisher() + ) + cluster_cols = ["string field", "bool field"] + + session_table_ref = session_resource_manager.create_temp_table( + TEST_SCHEMA, cluster_cols=cluster_cols + ) + session_resource_manager._keep_session_alive() + + result_table = bigquery_client.get_table(session_table_ref) + assert result_table.schema == TEST_SCHEMA + assert result_table.clustering_fields == cluster_cols + + session_resource_manager.close() + with pytest.raises(google.api_core.exceptions.NotFound): + # It may take time for the underlying tables to get cleaned up after + # closing the session, so wait at least 1 minute to check. + for _ in range(6): + bigquery_client.get_table(session_table_ref) + time.sleep(10) + + +def test_bq_session_create_multi_temp_tables(bigquery_client: bigquery.Client): + session_resource_manager = bigquery_session.SessionResourceManager( + bigquery_client, "US", publisher=bigframes.core.events.Publisher() + ) + + def create_table(): + return session_resource_manager.create_temp_table(TEST_SCHEMA) + + with ThreadPoolExecutor() as executor: + results = [executor.submit(create_table) for i in range(10)] + + for future in results: + table = future.result() + result_table = bigquery_client.get_table(table) + assert result_table.schema == TEST_SCHEMA + + session_resource_manager.close() diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 26b941a596..d2a157b131 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -30,14 +30,14 @@ import bigframes._config.display_options as display_options import bigframes.core.indexes as bf_indexes import bigframes.dataframe as dataframe +import bigframes.dtypes as dtypes import bigframes.pandas as bpd import bigframes.series as series -from tests.system.utils import ( +from bigframes.testing.utils import ( assert_dfs_equivalent, - assert_pandas_df_equal, + assert_frame_equal, assert_series_equal, assert_series_equivalent, - skip_legacy_pandas, ) @@ -77,6 +77,24 @@ def test_df_construct_pandas_default(scalars_dfs): pandas.testing.assert_frame_equal(bf_result, pd_result) +@pytest.mark.parametrize( + ("write_engine"), + [ + ("bigquery_inline"), + ("bigquery_load"), + ("bigquery_streaming"), + ("bigquery_write"), + ], +) +def test_read_pandas_all_nice_types( + session: bigframes.Session, scalars_pandas_df_index: pd.DataFrame, write_engine +): + bf_result = session.read_pandas( + scalars_pandas_df_index, write_engine=write_engine + ).to_pandas() + pandas.testing.assert_frame_equal(bf_result, scalars_pandas_df_index) + + def test_df_construct_large_strings(): data = [["hello", "w" + "o" * 50000 + "rld"]] bf_result = dataframe.DataFrame(data).to_pandas() @@ -120,6 +138,16 @@ def test_df_construct_structs(session): ) +def test_df_construct_local_concat_pd(scalars_pandas_df_index, session): + pd_df = pd.concat([scalars_pandas_df_index, scalars_pandas_df_index]) + + bf_df = session.read_pandas(pd_df) + + pd.testing.assert_frame_equal( + bf_df.to_pandas(), pd_df, check_index_type=False, check_dtype=False + ) + + def test_df_construct_pandas_set_dtype(scalars_dfs): columns = [ "int64_too", @@ -162,11 +190,31 @@ def test_df_construct_from_dict(): ) -def test_df_construct_inline_respects_location(): +@pytest.mark.parametrize( + ("json_type"), + [ + pytest.param(dtypes.JSON_DTYPE), + pytest.param("json"), + ], +) +def test_df_construct_w_json_dtype(json_type): + data = [ + "1", + "false", + '["a", {"b": 1}, null]', + None, + ] + df = dataframe.DataFrame({"json_col": data}, dtype=json_type) + + assert df["json_col"].dtype == dtypes.JSON_DTYPE + assert df["json_col"][1] == "false" + + +def test_df_construct_inline_respects_location(reset_default_session_and_location): # Note: This starts a thread-local session. with bpd.option_context("bigquery.location", "europe-west1"): df = bpd.DataFrame([[1, 2, 3], [4, 5, 6]]) - repr(df) + df.to_gbq() assert df.query_job is not None table = bpd.get_global_session().bqclient.get_table(df.query_job.destination) @@ -203,6 +251,21 @@ def test_get_column_nonstring(scalars_dfs): assert_series_equal(bf_result, pd_result) +@pytest.mark.parametrize( + "row_slice", + [ + (slice(1, 7, 2)), + (slice(1, 7, None)), + (slice(None, -3, None)), + ], +) +def test_get_rows_with_slice(scalars_dfs, row_slice): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[row_slice].to_pandas() + pd_result = scalars_pandas_df[row_slice] + assert_frame_equal(bf_result, pd_result) + + def test_hasattr(scalars_dfs): scalars_df, _ = scalars_dfs assert hasattr(scalars_df, "int64_col") @@ -227,7 +290,7 @@ def test_head_with_custom_column_labels( bf_df = scalars_df_index.rename(columns=rename_mapping).head(3) bf_result = bf_df.to_pandas(ordered=ordered) pd_result = scalars_pandas_df_index.rename(columns=rename_mapping).head(3) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) def test_tail_with_custom_column_labels(scalars_df_index, scalars_pandas_df_index): @@ -342,15 +405,6 @@ def test_insert(scalars_dfs, loc, column, value, allow_duplicates): pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df, check_dtype=False) -def test_where_series_cond(scalars_df_index, scalars_pandas_df_index): - # Condition is dataframe, other is None (as default). - cond_bf = scalars_df_index["int64_col"] > 0 - cond_pd = scalars_pandas_df_index["int64_col"] > 0 - bf_result = scalars_df_index.where(cond_bf).to_pandas() - pd_result = scalars_pandas_df_index.where(cond_pd) - pandas.testing.assert_frame_equal(bf_result, pd_result) - - def test_mask_series_cond(scalars_df_index, scalars_pandas_df_index): cond_bf = scalars_df_index["int64_col"] > 0 cond_pd = scalars_pandas_df_index["int64_col"] > 0 @@ -362,8 +416,20 @@ def test_mask_series_cond(scalars_df_index, scalars_pandas_df_index): pandas.testing.assert_frame_equal(bf_result, pd_result) -def test_where_series_multi_index(scalars_df_index, scalars_pandas_df_index): - # Test when a dataframe has multi-index or multi-columns. +def test_mask_callable(scalars_df_index, scalars_pandas_df_index): + def is_positive(x): + return x > 0 + + bf_df = scalars_df_index[["int64_too", "int64_col", "float64_col"]] + pd_df = scalars_pandas_df_index[["int64_too", "int64_col", "float64_col"]] + bf_result = bf_df.mask(cond=is_positive, other=lambda x: x + 1).to_pandas() + pd_result = pd_df.mask(cond=is_positive, other=lambda x: x + 1) + + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_where_multi_column(scalars_df_index, scalars_pandas_df_index): + # Test when a dataframe has multi-columns. columns = ["int64_col", "float64_col"] dataframe_bf = scalars_df_index[columns] @@ -376,10 +442,19 @@ def test_where_series_multi_index(scalars_df_index, scalars_pandas_df_index): dataframe_bf.where(cond_bf).to_pandas() assert ( str(context.value) - == "The dataframe.where() method does not support multi-index and/or multi-column." + == "The dataframe.where() method does not support multi-column." ) +def test_where_series_cond(scalars_df_index, scalars_pandas_df_index): + # Condition is dataframe, other is None (as default). + cond_bf = scalars_df_index["int64_col"] > 0 + cond_pd = scalars_pandas_df_index["int64_col"] > 0 + bf_result = scalars_df_index.where(cond_bf).to_pandas() + pd_result = scalars_pandas_df_index.where(cond_pd) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + def test_where_series_cond_const_other(scalars_df_index, scalars_pandas_df_index): # Condition is a series, other is a constant. columns = ["int64_col", "float64_col"] @@ -461,6 +536,62 @@ def test_where_dataframe_cond_dataframe_other( pandas.testing.assert_frame_equal(bf_result, pd_result) +def test_where_callable_cond_constant_other(scalars_df_index, scalars_pandas_df_index): + # Condition is callable, other is a constant. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + dataframe_pd = scalars_pandas_df_index[columns] + + other = 10 + + bf_result = dataframe_bf.where(lambda x: x > 0, other).to_pandas() + pd_result = dataframe_pd.where(lambda x: x > 0, other) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_where_dataframe_cond_callable_other(scalars_df_index, scalars_pandas_df_index): + # Condition is a dataframe, other is callable. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + dataframe_pd = scalars_pandas_df_index[columns] + + cond_bf = dataframe_bf > 0 + cond_pd = dataframe_pd > 0 + + def func(x): + return x * 2 + + bf_result = dataframe_bf.where(cond_bf, func).to_pandas() + pd_result = dataframe_pd.where(cond_pd, func) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_where_callable_cond_callable_other(scalars_df_index, scalars_pandas_df_index): + # Condition is callable, other is callable too. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + dataframe_pd = scalars_pandas_df_index[columns] + + def func(x): + return x["int64_col"] > 0 + + bf_result = dataframe_bf.where(func, lambda x: x * 2).to_pandas() + pd_result = dataframe_pd.where(func, lambda x: x * 2) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_where_series_other(scalars_df_index): + # When other is a series, throw an error. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + + with pytest.raises( + ValueError, + match="Seires is not a supported replacement type!", + ): + dataframe_bf.where(dataframe_bf > 0, dataframe_bf["int64_col"]) + + def test_drop_column(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs col_name = "int64_col" @@ -504,7 +635,7 @@ def test_drop_with_custom_column_labels(scalars_dfs): pd_result = scalars_pandas_df.rename(columns=rename_mapping).drop( columns=dropped_columns ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_df_memory_usage(scalars_dfs): @@ -520,7 +651,7 @@ def test_df_info(scalars_dfs): expected = ( "\n" "Index: 9 entries, 0 to 8\n" - "Data columns (total 13 columns):\n" + "Data columns (total 14 columns):\n" " # Column Non-Null Count Dtype\n" "--- ------------- ---------------- ------------------------------\n" " 0 bool_col 8 non-null boolean\n" @@ -536,18 +667,65 @@ def test_df_info(scalars_dfs): " 10 string_col 8 non-null string\n" " 11 time_col 6 non-null time64[us][pyarrow]\n" " 12 timestamp_col 6 non-null timestamp[us, tz=UTC][pyarrow]\n" - "dtypes: Float64(1), Int64(3), binary[pyarrow](1), boolean(1), date32[day][pyarrow](1), decimal128(38, 9)[pyarrow](1), geometry(1), string(1), time64[us][pyarrow](1), timestamp[us, tz=UTC][pyarrow](1), timestamp[us][pyarrow](1)\n" - "memory usage: 1269 bytes\n" + " 13 duration_col 7 non-null duration[us][pyarrow]\n" + "dtypes: Float64(1), Int64(3), binary[pyarrow](1), boolean(1), date32[day][pyarrow](1), decimal128(38, 9)[pyarrow](1), duration[us][pyarrow](1), geometry(1), string(1), time64[us][pyarrow](1), timestamp[us, tz=UTC][pyarrow](1), timestamp[us][pyarrow](1)\n" + "memory usage: 1341 bytes\n" ) - scalars_df, _ = scalars_dfs - bf_result = io.StringIO() + bf_result = io.StringIO() scalars_df.info(buf=bf_result) assert expected == bf_result.getvalue() +def test_df_info_no_rows(session): + expected = ( + "\n" + "Index: 0 entries\n" + "Data columns (total 1 columns):\n" + " # Column Non-Null Count Dtype\n" + "--- -------- ---------------- -------\n" + " 0 col 0 non-null Float64\n" + "dtypes: Float64(1)\n" + "memory usage: 0 bytes\n" + ) + df = session.DataFrame({"col": []}) + + bf_result = io.StringIO() + df.info(buf=bf_result) + + assert expected == bf_result.getvalue() + + +def test_df_info_no_cols(session): + expected = ( + "\n" + "Index: 3 entries, 1 to 3\n" + "Empty DataFrame\n" + ) + df = session.DataFrame({}, index=[1, 2, 3]) + + bf_result = io.StringIO() + df.info(buf=bf_result) + + assert expected == bf_result.getvalue() + + +def test_df_info_no_cols_no_rows(session): + expected = ( + "\n" + "Index: 0 entries\n" + "Empty DataFrame\n" + ) + df = session.DataFrame({}) + + bf_result = io.StringIO() + df.info(buf=bf_result) + + assert expected == bf_result.getvalue() + + @pytest.mark.parametrize( ("include", "exclude"), [ @@ -613,8 +791,9 @@ def test_drop_bigframes_index_with_na(scalars_dfs): pd.testing.assert_frame_equal(pd_result, bf_result) -@skip_legacy_pandas def test_drop_bigframes_multiindex(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs scalars_df = scalars_df.copy() scalars_pandas_df = scalars_pandas_df.copy() @@ -662,7 +841,18 @@ def test_rename(scalars_dfs): def test_df_peek(scalars_dfs_maybe_ordered): scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered - peek_result = scalars_df.peek(n=3, force=False) + + peek_result = scalars_df.peek(n=3, force=False, allow_large_results=True) + + pd.testing.assert_index_equal(scalars_pandas_df.columns, peek_result.columns) + assert len(peek_result) == 3 + + +def test_df_peek_with_large_results_not_allowed(scalars_dfs_maybe_ordered): + scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered + + peek_result = scalars_df.peek(n=3, force=False, allow_large_results=False) + pd.testing.assert_index_equal(scalars_pandas_df.columns, peek_result.columns) assert len(peek_result) == 3 @@ -744,7 +934,31 @@ def test_join_repr(scalars_dfs_maybe_ordered): assert actual == expected -def test_repr_html_w_all_rows(scalars_dfs, session): +def test_repr_w_display_options(scalars_dfs, session): + metrics = session._metrics + scalars_df, _ = scalars_dfs + # get a pandas df of the expected format + df, _ = scalars_df._block.to_pandas() + pandas_df = df.set_axis(scalars_df._block.column_labels, axis=1) + pandas_df.index.name = scalars_df.index.name + + executions_pre = metrics.execution_count + with bigframes.option_context( + "display.max_rows", 10, "display.max_columns", 5, "display.max_colwidth", 10 + ): + + # When there are 10 or fewer rows, the outputs should be identical except for the extra note. + actual = scalars_df.head(10).__repr__() + executions_post = metrics.execution_count + + with display_options.pandas_repr(bigframes.options.display): + pandas_repr = pandas_df.head(10).__repr__() + + assert actual == pandas_repr + assert (executions_post - executions_pre) <= 3 + + +def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): metrics = session._metrics scalars_df, _ = scalars_dfs # get a pandas df of the expected format @@ -754,7 +968,8 @@ def test_repr_html_w_all_rows(scalars_dfs, session): executions_pre = metrics.execution_count # When there are 10 or fewer rows, the outputs should be identical except for the extra note. - actual = scalars_df.head(10)._repr_html_() + bundle = scalars_df.head(10)._repr_mimebundle_() + actual = bundle["text/html"] executions_post = metrics.execution_count with display_options.pandas_repr(bigframes.options.display): @@ -765,7 +980,7 @@ def test_repr_html_w_all_rows(scalars_dfs, session): + f"[{len(pandas_df.index)} rows x {len(pandas_df.columns)} columns in total]" ) assert actual == expected - assert (executions_post - executions_pre) <= 2 + assert (executions_post - executions_pre) <= 3 def test_df_column_name_with_space(scalars_dfs): @@ -795,6 +1010,24 @@ def test_get_df_column_name_duplicate(scalars_dfs): pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) +@pytest.mark.parametrize( + ("indices", "axis"), + [ + ([1, 3, 5], 0), + ([2, 4, 6], 1), + ([1, -3, -5, -6], "index"), + ([-2, -4, -6], "columns"), + ], +) +def test_take_df(scalars_dfs, indices, axis): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df.take(indices, axis=axis).to_pandas() + pd_result = scalars_pandas_df.take(indices, axis=axis) + + assert_frame_equal(bf_result, pd_result) + + def test_filter_df(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs @@ -804,20 +1037,79 @@ def test_filter_df(scalars_dfs): pd_bool_series = scalars_pandas_df["bool_col"] pd_result = scalars_pandas_df[pd_bool_series] - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) + + +def test_read_gbq_direct_to_batches_row_count(unordered_session): + df = unordered_session.read_gbq("bigquery-public-data.usa_names.usa_1910_2013") + iter = df.to_pandas_batches() + assert iter.total_rows == 5552452 -def test_assign_new_column(scalars_dfs): +def test_df_to_pandas_batches(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs - kwargs = {"new_col": 2} - df = scalars_df.assign(**kwargs) + + capped_unfiltered_batches = scalars_df.to_pandas_batches(page_size=2, max_results=6) + bf_bool_series = scalars_df["bool_col"] + filtered_batches = scalars_df[bf_bool_series].to_pandas_batches() + + pd_bool_series = scalars_pandas_df["bool_col"] + pd_result = scalars_pandas_df[pd_bool_series] + + assert 6 == capped_unfiltered_batches.total_rows + assert len(pd_result) == filtered_batches.total_rows + assert_frame_equal(pd.concat(filtered_batches), pd_result) + + +@pytest.mark.parametrize( + ("literal", "expected_dtype"), + ( + pytest.param( + 2, + dtypes.INT_DTYPE, + id="INT64", + ), + # ==================================================================== + # NULL values + # + # These are regression tests for b/428999884. It needs to be possible to + # set a column to NULL with a desired type (not just the pandas default + # of float64). + # ==================================================================== + pytest.param(None, dtypes.FLOAT_DTYPE, id="NULL-None"), + pytest.param( + pa.scalar(None, type=pa.int64()), + dtypes.INT_DTYPE, + id="NULL-pyarrow-TIMESTAMP", + ), + pytest.param( + pa.scalar(None, type=pa.timestamp("us", tz="UTC")), + dtypes.TIMESTAMP_DTYPE, + id="NULL-pyarrow-TIMESTAMP", + ), + pytest.param( + pa.scalar(None, type=pa.timestamp("us")), + dtypes.DATETIME_DTYPE, + id="NULL-pyarrow-DATETIME", + ), + ), +) +def test_assign_new_column_w_literal(scalars_dfs, literal, expected_dtype): + scalars_df, scalars_pandas_df = scalars_dfs + df = scalars_df.assign(new_col=literal) bf_result = df.to_pandas() - pd_result = scalars_pandas_df.assign(**kwargs) - # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. - pd_result["new_col"] = pd_result["new_col"].astype("Int64") + new_col_pd = literal + if isinstance(literal, pa.Scalar): + # PyArrow integer scalars aren't yet supported in pandas Int64Dtype. + new_col_pd = literal.as_py() + + # Pandas might not pick the same dtype as BigFrames, but it should at least + # be castable to it. + pd_result = scalars_pandas_df.assign(new_col=new_col_pd) + pd_result["new_col"] = pd_result["new_col"].astype(expected_dtype) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_new_column_w_loc(scalars_dfs): @@ -945,6 +1237,72 @@ def test_assign_new_column_w_setitem_list_error(scalars_dfs): bf_df["new_col"] = [1, 2, 3] +@pytest.mark.parametrize( + ("key", "value"), + [ + pytest.param(["int64_col", "int64_too"], 1, id="scalar_to_existing_column"), + pytest.param( + ["int64_col", "int64_too"], [1, 2], id="sequence_to_existing_column" + ), + pytest.param( + ["int64_col", "new_col"], [1, 2], id="sequence_to_partial_new_column" + ), + pytest.param( + ["new_col", "new_col_too"], [1, 2], id="sequence_to_full_new_column" + ), + pytest.param( + pd.Index(("new_col", "new_col_too")), + [1, 2], + id="sequence_to_full_new_column_as_index", + ), + ], +) +def test_setitem_multicolumn_with_literals(scalars_dfs, key, value): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df.copy() + pd_result = scalars_pandas_df.copy() + + bf_result[key] = value + pd_result[key] = value + + pd.testing.assert_frame_equal(pd_result, bf_result.to_pandas(), check_dtype=False) + + +def test_setitem_multicolumn_with_literals_different_lengths_raise_error(scalars_dfs): + scalars_df, _ = scalars_dfs + bf_result = scalars_df.copy() + + with pytest.raises(ValueError): + bf_result[["int64_col", "int64_too"]] = [1] + + +def test_setitem_multicolumn_with_dataframes(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df.copy() + pd_result = scalars_pandas_df.copy() + + bf_result[["int64_col", "int64_too"]] = bf_result[["int64_too", "int64_col"]] / 2 + pd_result[["int64_col", "int64_too"]] = pd_result[["int64_too", "int64_col"]] / 2 + + pd.testing.assert_frame_equal(pd_result, bf_result.to_pandas(), check_dtype=False) + + +def test_setitem_multicolumn_with_dataframes_series_on_rhs_raise_error(scalars_dfs): + scalars_df, _ = scalars_dfs + bf_result = scalars_df.copy() + + with pytest.raises(ValueError): + bf_result[["int64_col", "int64_too"]] = bf_result["int64_col"] / 2 + + +def test_setitem_multicolumn_with_dataframes_different_lengths_raise_error(scalars_dfs): + scalars_df, _ = scalars_dfs + bf_result = scalars_df.copy() + + with pytest.raises(ValueError): + bf_result[["int64_col"]] = bf_result[["int64_col", "int64_too"]] / 2 + + def test_assign_existing_column(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs kwargs = {"int64_col": 2} @@ -955,7 +1313,7 @@ def test_assign_existing_column(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["int64_col"] = pd_result["int64_col"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_listlike_to_empty_df(session): @@ -967,7 +1325,7 @@ def test_assign_listlike_to_empty_df(session): pd_result["new_col"] = pd_result["new_col"].astype("Int64") pd_result.index = pd_result.index.astype("Int64") - assert_pandas_df_equal(bf_result.to_pandas(), pd_result) + assert_frame_equal(bf_result.to_pandas(), pd_result) def test_assign_to_empty_df_multiindex_error(session): @@ -1001,7 +1359,7 @@ def test_assign_series(scalars_dfs, ordered): bf_result = df.to_pandas(ordered=ordered) pd_result = scalars_pandas_df.assign(new_col=scalars_pandas_df[column_name]) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) def test_assign_series_overwrite(scalars_dfs): @@ -1013,7 +1371,7 @@ def test_assign_series_overwrite(scalars_dfs): **{column_name: scalars_pandas_df[column_name] + 3} ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_sequential(scalars_dfs): @@ -1028,7 +1386,7 @@ def test_assign_sequential(scalars_dfs): pd_result["new_col"] = pd_result["new_col"].astype("Int64") pd_result["new_col2"] = pd_result["new_col2"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) # Require an index so that the self-join is consistent each time. @@ -1062,7 +1420,7 @@ def test_assign_different_df( new_col=scalars_pandas_df_index[column_name] ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_different_df_w_loc( @@ -1113,10 +1471,9 @@ def test_assign_callable_lambda(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["new_col"] = pd_result["new_col"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) -@skip_legacy_pandas @pytest.mark.parametrize( ("axis", "how", "ignore_index", "subset"), [ @@ -1129,7 +1486,9 @@ def test_assign_callable_lambda(scalars_dfs): (1, "all", False, None), ], ) -def test_df_dropna(scalars_dfs, axis, how, ignore_index, subset): +def test_df_dropna_by_how(scalars_dfs, axis, how, ignore_index, subset): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs df = scalars_df.dropna(axis=axis, how=how, ignore_index=ignore_index, subset=subset) bf_result = df.to_pandas() @@ -1142,8 +1501,39 @@ def test_df_dropna(scalars_dfs, axis, how, ignore_index, subset): pandas.testing.assert_frame_equal(bf_result, pd_result) -@skip_legacy_pandas +@pytest.mark.parametrize( + ("axis", "ignore_index", "subset", "thresh"), + [ + (0, False, None, 2), + (0, True, None, 3), + (1, False, None, 2), + ], +) +def test_df_dropna_by_thresh(scalars_dfs, axis, ignore_index, subset, thresh): + """ + Tests that dropna correctly keeps rows/columns with a minimum number + of non-null values. + """ + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + df_result = scalars_df.dropna( + axis=axis, thresh=thresh, ignore_index=ignore_index, subset=subset + ) + pd_result = scalars_pandas_df.dropna( + axis=axis, thresh=thresh, ignore_index=ignore_index, subset=subset + ) + + bf_result = df_result.to_pandas() + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + pd.testing.assert_frame_equal(bf_result, pd_result) + + def test_df_dropna_range_columns(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs scalars_df = scalars_df.copy() scalars_pandas_df = scalars_pandas_df.copy() @@ -1342,11 +1732,12 @@ def test_df_iter( assert bf_i == df_i -@skip_legacy_pandas def test_iterrows( scalars_df_index, scalars_pandas_df_index, ): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df_index = scalars_df_index.add_suffix("_suffix", axis=1) scalars_pandas_df_index = scalars_pandas_df_index.add_suffix("_suffix", axis=1) for (bf_index, bf_series), (pd_index, pd_series) in zip( @@ -1377,7 +1768,7 @@ def test_itertuples(scalars_df_index, index, name): assert bf_tuple == pd_tuple -def test_df_isin_list(scalars_dfs): +def test_df_isin_list_w_null(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs values = ["Hello, World!", 55555, 2.51, pd.NA, True] bf_result = ( @@ -1392,6 +1783,21 @@ def test_df_isin_list(scalars_dfs): pandas.testing.assert_frame_equal(bf_result, pd_result.astype("boolean")) +def test_df_isin_list_wo_null(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + values = ["Hello, World!", 55555, 2.51, True] + bf_result = ( + scalars_df[["int64_col", "float64_col", "string_col", "bool_col"]] + .isin(values) + .to_pandas() + ) + pd_result = scalars_pandas_df[ + ["int64_col", "float64_col", "string_col", "bool_col"] + ].isin(values) + + pandas.testing.assert_frame_equal(bf_result, pd_result.astype("boolean")) + + def test_df_isin_dict(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs values = { @@ -1462,9 +1868,7 @@ def test_df_merge(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) @pytest.mark.parametrize( @@ -1497,9 +1901,7 @@ def test_df_merge_multi_key(scalars_dfs, left_on, right_on): sort=True, ) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) @pytest.mark.parametrize( @@ -1529,9 +1931,7 @@ def test_merge_custom_col_name(scalars_dfs, merge_how): pandas_right_df = scalars_pandas_df[right_columns] pd_result = pandas_left_df.merge(pandas_right_df, merge_how, on, sort=True) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) @pytest.mark.parametrize( @@ -1564,9 +1964,49 @@ def test_merge_left_on_right_on(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + + +def test_self_merge_self_w_on_args(): + data = { + "A": pd.Series([1, 2, 3], dtype="Int64"), + "B": pd.Series([1, 2, 3], dtype="Int64"), + "C": pd.Series([100, 200, 300], dtype="Int64"), + "D": pd.Series(["alpha", "beta", "gamma"], dtype="string[pyarrow]"), + } + df = pd.DataFrame(data) + + df1 = df[["A", "C"]] + df2 = df[["B", "C", "D"]] + pd_result = df1.merge(df2, left_on=["A", "C"], right_on=["B", "C"], how="inner") + + bf_df = bpd.DataFrame(data) + + bf_df1 = bf_df[["A", "C"]] + bf_df2 = bf_df[["B", "C", "D"]] + bf_result = bf_df1.merge( + bf_df2, left_on=["A", "C"], right_on=["B", "C"], how="inner" + ).to_pandas() + pd.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) + + +@pytest.mark.parametrize( + ("decimals",), + [ + (2,), + ({"float64_col": 0, "bool_col": 1, "int64_too": -3},), + ({},), + ], +) +def test_dataframe_round(scalars_dfs, decimals): + if pd.__version__.startswith("1."): + pytest.skip("Rounding doesn't work as expected in pandas 1.x") + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df.round(decimals).to_pandas() + pd_result = scalars_pandas_df.round(decimals) + + assert_frame_equal(bf_result, pd_result) def test_get_dtypes(scalars_df_default_index): @@ -1586,6 +2026,7 @@ def test_get_dtypes(scalars_df_default_index): "string_col": pd.StringDtype(storage="pyarrow"), "time_col": pd.ArrowDtype(pa.time64("us")), "timestamp_col": pd.ArrowDtype(pa.timestamp("us", tz="UTC")), + "duration_col": pd.ArrowDtype(pa.duration("us")), } pd.testing.assert_series_equal( dtypes, @@ -1713,6 +2154,29 @@ def test_len(scalars_dfs): assert bf_result == pd_result +@pytest.mark.parametrize( + ("n_rows",), + [ + (50,), + (10000,), + ], +) +@pytest.mark.parametrize( + "write_engine", + ["bigquery_load", "bigquery_streaming", "bigquery_write"], +) +def test_df_len_local(session, n_rows, write_engine): + assert ( + len( + session.read_pandas( + pd.DataFrame(np.random.randint(1, 7, n_rows), columns=["one"]), + write_engine=write_engine, + ) + ) + == n_rows + ) + + def test_size(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs bf_result = scalars_df.size @@ -1790,6 +2254,52 @@ def test_reset_index(scalars_df_index, scalars_pandas_df_index, drop): pandas.testing.assert_frame_equal(bf_result, pd_result) +def test_reset_index_allow_duplicates(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.copy() + scalars_df_index.index.name = "int64_col" + df = scalars_df_index.reset_index(allow_duplicates=True, drop=False) + assert df.index.name is None + + bf_result = df.to_pandas() + + scalars_pandas_df_index = scalars_pandas_df_index.copy() + scalars_pandas_df_index.index.name = "int64_col" + pd_result = scalars_pandas_df_index.reset_index(allow_duplicates=True, drop=False) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_reset_index_duplicates_error(scalars_df_index): + scalars_df_index = scalars_df_index.copy() + scalars_df_index.index.name = "int64_col" + with pytest.raises(ValueError): + scalars_df_index.reset_index(allow_duplicates=False, drop=False) + + +@pytest.mark.parametrize( + ("drop",), + ((True,), (False,)), +) +def test_reset_index_inplace(scalars_df_index, scalars_pandas_df_index, drop): + df = scalars_df_index.copy() + df.reset_index(drop=drop, inplace=True) + assert df.index.name is None + + bf_result = df.to_pandas() + pd_result = scalars_pandas_df_index.copy() + pd_result.reset_index(drop=drop, inplace=True) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pandas.testing.assert_frame_equal(bf_result, pd_result) + + def test_reset_index_then_filter( scalars_df_index, scalars_pandas_df_index, @@ -1935,17 +2445,34 @@ def test_set_index_key_error(scalars_dfs): ("na_position",), (("first",), ("last",)), ) -def test_sort_index(scalars_dfs, ascending, na_position): +@pytest.mark.parametrize( + ("axis",), + ((0,), ("columns",)), +) +def test_sort_index(scalars_dfs, ascending, na_position, axis): index_column = "int64_col" scalars_df, scalars_pandas_df = scalars_dfs df = scalars_df.set_index(index_column) - bf_result = df.sort_index(ascending=ascending, na_position=na_position).to_pandas() + bf_result = df.sort_index( + ascending=ascending, na_position=na_position, axis=axis + ).to_pandas() pd_result = scalars_pandas_df.set_index(index_column).sort_index( - ascending=ascending, na_position=na_position + ascending=ascending, na_position=na_position, axis=axis ) pandas.testing.assert_frame_equal(bf_result, pd_result) +def test_dataframe_sort_index_inplace(scalars_dfs): + index_column = "int64_col" + scalars_df, scalars_pandas_df = scalars_dfs + df = scalars_df.copy().set_index(index_column) + df.sort_index(ascending=False, inplace=True) + bf_result = df.to_pandas() + + pd_result = scalars_pandas_df.set_index(index_column).sort_index(ascending=False) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + def test_df_abs(scalars_dfs_maybe_ordered): scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered columns = ["int64_col", "int64_too", "float64_col"] @@ -1961,7 +2488,7 @@ def test_df_pos(scalars_dfs): bf_result = (+scalars_df[["int64_col", "numeric_col"]]).to_pandas() pd_result = +scalars_pandas_df[["int64_col", "numeric_col"]] - assert_pandas_df_equal(pd_result, bf_result) + assert_frame_equal(pd_result, bf_result) def test_df_neg(scalars_dfs): @@ -1969,7 +2496,17 @@ def test_df_neg(scalars_dfs): bf_result = (-scalars_df[["int64_col", "numeric_col"]]).to_pandas() pd_result = -scalars_pandas_df[["int64_col", "numeric_col"]] - assert_pandas_df_equal(pd_result, bf_result) + assert_frame_equal(pd_result, bf_result) + + +def test_df__abs__(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = ( + abs(scalars_df[["int64_col", "numeric_col", "float64_col"]]) + ).to_pandas() + pd_result = abs(scalars_pandas_df[["int64_col", "numeric_col", "float64_col"]]) + + assert_frame_equal(pd_result, bf_result) def test_df_invert(scalars_dfs): @@ -1979,7 +2516,7 @@ def test_df_invert(scalars_dfs): bf_result = (~scalars_df[columns]).to_pandas() pd_result = ~scalars_pandas_df[columns] - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_df_isnull(scalars_dfs): @@ -1996,7 +2533,7 @@ def test_df_isnull(scalars_dfs): pd_result["string_col"] = pd_result["string_col"].astype(pd.BooleanDtype()) pd_result["bool_col"] = pd_result["bool_col"].astype(pd.BooleanDtype()) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_df_notnull(scalars_dfs): @@ -2013,7 +2550,7 @@ def test_df_notnull(scalars_dfs): pd_result["string_col"] = pd_result["string_col"].astype(pd.BooleanDtype()) pd_result["bool_col"] = pd_result["bool_col"].astype(pd.BooleanDtype()) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -2214,7 +2751,7 @@ def test_combine_first( ), ], ) -def test_corr_w_numeric_only(scalars_dfs_maybe_ordered, columns, numeric_only): +def test_df_corr_w_numeric_only(scalars_dfs_maybe_ordered, columns, numeric_only): scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered bf_result = scalars_df[columns].corr(numeric_only=numeric_only).to_pandas() @@ -2223,12 +2760,18 @@ def test_corr_w_numeric_only(scalars_dfs_maybe_ordered, columns, numeric_only): # BigFrames and Pandas differ in their data type handling: # - Column types: BigFrames uses Float64, Pandas uses float64. # - Index types: BigFrames uses strign, Pandas uses object. + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) + # Only check row order in ordered mode. pd.testing.assert_frame_equal( - bf_result, pd_result, check_dtype=False, check_index_type=False + bf_result, + pd_result, + check_dtype=False, + check_index_type=False, + check_like=~scalars_df._block.session._strictly_ordered, ) -def test_corr_w_invalid_parameters(scalars_dfs): +def test_df_corr_w_invalid_parameters(scalars_dfs): columns = ["int64_too", "int64_col", "float64_col"] scalars_df, _ = scalars_dfs @@ -2261,8 +2804,14 @@ def test_cov_w_numeric_only(scalars_dfs_maybe_ordered, columns, numeric_only): # BigFrames and Pandas differ in their data type handling: # - Column types: BigFrames uses Float64, Pandas uses float64. # - Index types: BigFrames uses strign, Pandas uses object. + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) + # Only check row order in ordered mode. pd.testing.assert_frame_equal( - bf_result, pd_result, check_dtype=False, check_index_type=False + bf_result, + pd_result, + check_dtype=False, + check_index_type=False, + check_like=~scalars_df._block.session._strictly_ordered, ) @@ -2314,8 +2863,9 @@ def test_df_corrwith_df_non_numeric_error(scalars_dfs): scalars_df[l_cols].corrwith(scalars_df[r_cols], numeric_only=False) -@skip_legacy_pandas def test_df_corrwith_series(scalars_dfs_maybe_ordered): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered l_cols = ["int64_col", "float64_col", "int64_too"] @@ -2373,7 +2923,23 @@ def test_scalar_binop(scalars_dfs, op, other_scalar, reverse_operands): bf_result = maybe_reversed_op(scalars_df[columns], other_scalar).to_pandas() pd_result = maybe_reversed_op(scalars_pandas_df[columns], other_scalar) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) + + +def test_dataframe_string_radd_const(scalars_dfs): + pytest.importorskip( + "pandas", + minversion="2.0.0", + reason="PyArrow string addition requires pandas 2.0+", + ) + + scalars_df, scalars_pandas_df = scalars_dfs + columns = ["string_col", "string_col"] + + bf_result = ("prefix" + scalars_df[columns]).to_pandas() + pd_result = "prefix" + scalars_pandas_df[columns] + + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize(("other_scalar"), [1, -2]) @@ -2385,7 +2951,7 @@ def test_mod(scalars_dfs, other_scalar): bf_result = (scalars_df[["int64_col", "int64_too"]] % other_scalar).to_pandas() pd_result = scalars_pandas_df[["int64_col", "int64_too"]] % other_scalar - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_scalar_binop_str_exception(scalars_dfs): @@ -2441,10 +3007,9 @@ def test_series_binop_axis_index( bf_result = op(scalars_df[df_columns], scalars_df[series_column]).to_pandas() pd_result = op(scalars_pandas_df[df_columns], scalars_pandas_df[series_column]) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) -@skip_legacy_pandas @pytest.mark.parametrize( ("input"), [ @@ -2459,6 +3024,8 @@ def test_series_binop_axis_index( ], ) def test_listlike_binop_axis_1_in_memory_data(scalars_dfs, input): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs df_columns = ["int64_col", "float64_col", "int64_too"] @@ -2468,11 +3035,12 @@ def test_listlike_binop_axis_1_in_memory_data(scalars_dfs, input): input = input.to_pandas() pd_result = scalars_pandas_df[df_columns].add(input, axis=1) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) -@skip_legacy_pandas def test_df_reverse_binop_pandas(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs pd_series = pd.Series([100, 200, 300]) @@ -2482,7 +3050,7 @@ def test_df_reverse_binop_pandas(scalars_dfs): bf_result = pd_series + scalars_df[df_columns].to_pandas() pd_result = pd_series + scalars_pandas_df[df_columns] - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_listlike_binop_axis_1_bf_index(scalars_dfs): @@ -2497,19 +3065,19 @@ def test_listlike_binop_axis_1_bf_index(scalars_dfs): ) pd_result = scalars_pandas_df[df_columns].add(pd.Index([1000, 2000, 3000]), axis=1) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) -def test_binop_with_self_aggregate(session, scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs +def test_binop_with_self_aggregate(scalars_dfs_maybe_ordered): + scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered df_columns = ["int64_col", "float64_col", "int64_too"] # Ensure that this takes the optimized single-query path by counting executions - execution_count_before = session._metrics.execution_count + execution_count_before = scalars_df._session._metrics.execution_count bf_df = scalars_df[df_columns] bf_result = (bf_df - bf_df.mean()).to_pandas() - execution_count_after = session._metrics.execution_count + execution_count_after = scalars_df._session._metrics.execution_count pd_df = scalars_pandas_df[df_columns] pd_result = pd_df - pd_df.mean() @@ -2517,7 +3085,28 @@ def test_binop_with_self_aggregate(session, scalars_dfs): executions = execution_count_after - execution_count_before assert executions == 1 - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +def test_binop_with_self_aggregate_w_index_reset(scalars_dfs_maybe_ordered): + scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered + + df_columns = ["int64_col", "float64_col", "int64_too"] + + # Ensure that this takes the optimized single-query path by counting executions + execution_count_before = scalars_df._session._metrics.execution_count + bf_df = scalars_df[df_columns].reset_index(drop=True) + bf_result = (bf_df - bf_df.mean()).to_pandas() + execution_count_after = scalars_df._session._metrics.execution_count + + pd_df = scalars_pandas_df[df_columns].reset_index(drop=True) + pd_result = pd_df - pd_df.mean() + + executions = execution_count_after - execution_count_before + + assert executions == 1 + pd_result.index = pd_result.index.astype("Int64") + assert_frame_equal(bf_result, pd_result, check_dtype=False, check_index_type=False) @pytest.mark.parametrize( @@ -2585,7 +3174,7 @@ def test_series_binop_add_different_table( scalars_pandas_df_index[series_column], axis="index" ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) # TODO(garrettwu): Test series binop with different index @@ -2599,8 +3188,6 @@ def test_series_binop_add_different_table( @all_joins def test_join_same_table(scalars_dfs_maybe_ordered, how): bf_df, pd_df = scalars_dfs_maybe_ordered - if not bf_df._session._strictly_ordered and how == "cross": - pytest.skip("Cross join not supported in partial ordering mode.") bf_df_a = bf_df.set_index("int64_too")[["string_col", "int64_col"]] bf_df_a = bf_df_a.sort_index() @@ -2620,7 +3207,22 @@ def test_join_same_table(scalars_dfs_maybe_ordered, how): pd_result = pd_df_a.join(pd_df_b, how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) + + +def test_join_incompatible_key_type_error(scalars_dfs): + bf_df, _ = scalars_dfs + + bf_df_a = bf_df.set_index("int64_too")[["string_col", "int64_col"]] + bf_df_a = bf_df_a.sort_index() + + bf_df_b = bf_df.set_index("date_col")[["float64_col"]] + bf_df_b = bf_df_b[bf_df_b.float64_col > 0] + bf_df_b = bf_df_b.sort_values("float64_col") + + with pytest.raises(TypeError): + # joining incompatible date, int columns + bf_df_a.join(bf_df_b, how="left") @all_joins @@ -2633,15 +3235,105 @@ def test_join_different_table( pd_df_a = scalars_pandas_df_index[["string_col", "int64_col"]] pd_df_b = scalars_pandas_df_index.dropna()[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) -def test_join_duplicate_columns_raises_not_implemented(scalars_dfs): - scalars_df, _ = scalars_dfs - df_a = scalars_df[["string_col", "float64_col"]] - df_b = scalars_df[["float64_col"]] - with pytest.raises(NotImplementedError): - df_a.join(df_b, how="outer").to_pandas() +@all_joins +def test_join_different_table_with_duplicate_column_name( + scalars_df_index, scalars_pandas_df_index, how +): + bf_df_a = scalars_df_index[["string_col", "int64_col", "int64_too"]].rename( + columns={"int64_too": "int64_col"} + ) + bf_df_b = scalars_df_index.dropna()[ + ["string_col", "int64_col", "int64_too"] + ].rename(columns={"int64_too": "int64_col"}) + bf_result = bf_df_a.join(bf_df_b, how=how, lsuffix="_l", rsuffix="_r").to_pandas() + pd_df_a = scalars_pandas_df_index[["string_col", "int64_col", "int64_too"]].rename( + columns={"int64_too": "int64_col"} + ) + pd_df_b = scalars_pandas_df_index.dropna()[ + ["string_col", "int64_col", "int64_too"] + ].rename(columns={"int64_too": "int64_col"}) + pd_result = pd_df_a.join(pd_df_b, how=how, lsuffix="_l", rsuffix="_r") + + # Ensure no inplace changes + pd.testing.assert_index_equal(bf_df_a.columns, pd_df_a.columns) + pd.testing.assert_index_equal(bf_df_b.index.to_pandas(), pd_df_b.index) + pd.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) + + +@all_joins +def test_join_param_on_with_duplicate_column_name_not_on_col( + scalars_df_index, scalars_pandas_df_index, how +): + # This test is for duplicate column names, but the 'on' column is not duplicated. + if how == "cross": + return + bf_df_a = scalars_df_index[ + ["string_col", "datetime_col", "timestamp_col", "int64_too"] + ].rename(columns={"timestamp_col": "datetime_col"}) + bf_df_b = scalars_df_index.dropna()[ + ["string_col", "datetime_col", "timestamp_col"] + ].rename(columns={"timestamp_col": "datetime_col"}) + bf_result = bf_df_a.join( + bf_df_b, on="int64_too", how=how, lsuffix="_l", rsuffix="_r" + ).to_pandas() + pd_df_a = scalars_pandas_df_index[ + ["string_col", "datetime_col", "timestamp_col", "int64_too"] + ].rename(columns={"timestamp_col": "datetime_col"}) + pd_df_b = scalars_pandas_df_index.dropna()[ + ["string_col", "datetime_col", "timestamp_col"] + ].rename(columns={"timestamp_col": "datetime_col"}) + pd_result = pd_df_a.join( + pd_df_b, on="int64_too", how=how, lsuffix="_l", rsuffix="_r" + ) + pd.testing.assert_frame_equal( + bf_result.sort_index(), + pd_result.sort_index(), + check_like=True, + check_index_type=False, + check_names=False, + ) + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) + + +@pytest.mark.skipif( + pandas.__version__.startswith("1."), reason="bad left join in pandas 1.x" +) +@all_joins +def test_join_param_on_with_duplicate_column_name_on_col( + scalars_df_index, scalars_pandas_df_index, how +): + # This test is for duplicate column names, and the 'on' column is duplicated. + if how == "cross": + return + bf_df_a = scalars_df_index[ + ["string_col", "datetime_col", "timestamp_col", "int64_too"] + ].rename(columns={"timestamp_col": "datetime_col"}) + bf_df_b = scalars_df_index.dropna()[ + ["string_col", "datetime_col", "timestamp_col", "int64_too"] + ].rename(columns={"timestamp_col": "datetime_col"}) + bf_result = bf_df_a.join( + bf_df_b, on="int64_too", how=how, lsuffix="_l", rsuffix="_r" + ).to_pandas() + pd_df_a = scalars_pandas_df_index[ + ["string_col", "datetime_col", "timestamp_col", "int64_too"] + ].rename(columns={"timestamp_col": "datetime_col"}) + pd_df_b = scalars_pandas_df_index.dropna()[ + ["string_col", "datetime_col", "timestamp_col", "int64_too"] + ].rename(columns={"timestamp_col": "datetime_col"}) + pd_result = pd_df_a.join( + pd_df_b, on="int64_too", how=how, lsuffix="_l", rsuffix="_r" + ) + pd.testing.assert_frame_equal( + bf_result.sort_index(), + pd_result.sort_index(), + check_like=True, + check_index_type=False, + check_names=False, + ) + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) @all_joins @@ -2662,7 +3354,7 @@ def test_join_param_on(scalars_dfs, how): pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) pd_df_b = pd_df[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, on="rowindex_2", how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -2683,7 +3375,7 @@ def test_df_join_series(scalars_dfs, how): pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) pd_series_b = pd_df["float64_col"] pd_result = pd_df_a.join(pd_series_b, on="rowindex_2", how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @pytest.mark.parametrize( @@ -2700,9 +3392,35 @@ def test_dataframe_sort_values( scalars_df_index, scalars_pandas_df_index, by, ascending, na_position ): # Test needs values to be unique - bf_result = scalars_df_index.sort_values( - by, ascending=ascending, na_position=na_position - ).to_pandas() + bf_result = scalars_df_index.sort_values( + by, ascending=ascending, na_position=na_position + ).to_pandas() + pd_result = scalars_pandas_df_index.sort_values( + by, ascending=ascending, na_position=na_position + ) + + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("by", "ascending", "na_position"), + [ + ("int64_col", True, "first"), + (["bool_col", "int64_col"], True, "last"), + ], +) +def test_dataframe_sort_values_inplace( + scalars_df_index, scalars_pandas_df_index, by, ascending, na_position +): + # Test needs values to be unique + bf_sorted = scalars_df_index.copy() + bf_sorted.sort_values( + by, ascending=ascending, na_position=na_position, inplace=True + ) + bf_result = bf_sorted.to_pandas() pd_result = scalars_pandas_df_index.sort_values( by, ascending=ascending, na_position=na_position ) @@ -2820,7 +3538,8 @@ def test_dataframe_diff(scalars_df_index, scalars_pandas_df_index, periods): def test_dataframe_pct_change(scalars_df_index, scalars_pandas_df_index, periods): col_names = ["int64_too", "float64_col", "int64_col"] bf_result = scalars_df_index[col_names].pct_change(periods=periods).to_pandas() - pd_result = scalars_pandas_df_index[col_names].pct_change(periods=periods) + # pandas 3.0 does not automatically ffill anymore + pd_result = scalars_pandas_df_index[col_names].ffill().pct_change(periods=periods) pd.testing.assert_frame_equal( pd_result, bf_result, @@ -2915,150 +3634,6 @@ def test_dataframe_agg_int_multi_string(scalars_dfs): ) -@skip_legacy_pandas -def test_df_describe_non_temporal(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - # excluding temporal columns here because BigFrames cannot perform percentiles operations on them - unsupported_columns = ["datetime_col", "timestamp_col", "time_col", "date_col"] - bf_result = scalars_df.drop(columns=unsupported_columns).describe().to_pandas() - - modified_pd_df = scalars_pandas_df.drop(columns=unsupported_columns) - pd_result = modified_pd_df.describe() - - # Pandas may produce narrower numeric types, but bigframes always produces Float64 - pd_result = pd_result.astype("Float64") - - # Drop quartiles, as they are approximate - bf_min = bf_result.loc["min", :] - bf_p25 = bf_result.loc["25%", :] - bf_p50 = bf_result.loc["50%", :] - bf_p75 = bf_result.loc["75%", :] - bf_max = bf_result.loc["max", :] - - bf_result = bf_result.drop(labels=["25%", "50%", "75%"]) - pd_result = pd_result.drop(labels=["25%", "50%", "75%"]) - - pd.testing.assert_frame_equal(pd_result, bf_result, check_index_type=False) - - # Double-check that quantiles are at least plausible. - assert ( - (bf_min <= bf_p25) - & (bf_p25 <= bf_p50) - & (bf_p50 <= bf_p50) - & (bf_p75 <= bf_max) - ).all() - - -@skip_legacy_pandas -@pytest.mark.parametrize("include", [None, "all"]) -def test_df_describe_non_numeric(scalars_dfs, include): - scalars_df, scalars_pandas_df = scalars_dfs - - # Excluding "date_col" here because in BigFrames it is used as PyArrow[date32()], which is - # considered numerical in Pandas - target_columns = ["string_col", "bytes_col", "bool_col", "time_col"] - - modified_bf = scalars_df[target_columns] - bf_result = modified_bf.describe(include=include).to_pandas() - - modified_pd_df = scalars_pandas_df[target_columns] - pd_result = modified_pd_df.describe(include=include) - - # Reindex results with the specified keys and their order, because - # the relative order is not important. - bf_result = bf_result.reindex(["count", "nunique"]) - pd_result = pd_result.reindex( - ["count", "unique"] - # BF counter part of "unique" is called "nunique" - ).rename(index={"unique": "nunique"}) - - pd.testing.assert_frame_equal( - pd_result.astype("Int64"), - bf_result, - check_index_type=False, - ) - - -@skip_legacy_pandas -def test_df_describe_temporal(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - - temporal_columns = ["datetime_col", "timestamp_col", "time_col", "date_col"] - - modified_bf = scalars_df[temporal_columns] - bf_result = modified_bf.describe(include="all").to_pandas() - - modified_pd_df = scalars_pandas_df[temporal_columns] - pd_result = modified_pd_df.describe(include="all") - - # Reindex results with the specified keys and their order, because - # the relative order is not important. - bf_result = bf_result.reindex(["count", "nunique"]) - pd_result = pd_result.reindex( - ["count", "unique"] - # BF counter part of "unique" is called "nunique" - ).rename(index={"unique": "nunique"}) - - pd.testing.assert_frame_equal( - pd_result.astype("Float64"), - bf_result.astype("Float64"), - check_index_type=False, - ) - - -@skip_legacy_pandas -def test_df_describe_mixed_types_include_all(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - - numeric_columns = [ - "int64_col", - "float64_col", - ] - non_numeric_columns = ["string_col"] - supported_columns = numeric_columns + non_numeric_columns - - modified_bf = scalars_df[supported_columns] - bf_result = modified_bf.describe(include="all").to_pandas() - - modified_pd_df = scalars_pandas_df[supported_columns] - pd_result = modified_pd_df.describe(include="all") - - # Drop quartiles, as they are approximate - bf_min = bf_result.loc["min", :] - bf_p25 = bf_result.loc["25%", :] - bf_p50 = bf_result.loc["50%", :] - bf_p75 = bf_result.loc["75%", :] - bf_max = bf_result.loc["max", :] - - # Reindex results with the specified keys and their order, because - # the relative order is not important. - bf_result = bf_result.reindex(["count", "nunique", "mean", "std", "min", "max"]) - pd_result = pd_result.reindex( - ["count", "unique", "mean", "std", "min", "max"] - # BF counter part of "unique" is called "nunique" - ).rename(index={"unique": "nunique"}) - - pd.testing.assert_frame_equal( - pd_result[numeric_columns].astype("Float64"), - bf_result[numeric_columns], - check_index_type=False, - ) - - pd.testing.assert_frame_equal( - pd_result[non_numeric_columns].astype("Int64"), - bf_result[non_numeric_columns], - check_index_type=False, - ) - - # Double-check that quantiles are at least plausible. - assert ( - (bf_min <= bf_p25) - & (bf_p25 <= bf_p50) - & (bf_p50 <= bf_p50) - & (bf_p75 <= bf_max) - ).all() - - def test_df_transpose(): # Include some floats to ensure type coercion values = [[0, 3.5, True], [1, 4.5, False], [2, 6.5, None]] @@ -3080,7 +3655,7 @@ def test_df_transpose(): pd_result = pd_df.T bf_result = bf_df.T.to_pandas() - pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) + assert_frame_equal(pd_result, bf_result, check_dtype=False, nulls_are_nan=True) # type: ignore def test_df_transpose_error(): @@ -3247,12 +3822,18 @@ def test_df_pivot_hockey(hockey_df, hockey_pandas_df, values, index, columns): @pytest.mark.parametrize( - ("values", "index", "columns", "aggfunc"), + ("values", "index", "columns", "aggfunc", "fill_value"), [ - (("culmen_length_mm", "body_mass_g"), "species", "sex", "std"), - (["body_mass_g", "culmen_length_mm"], ("species", "island"), "sex", "sum"), - ("body_mass_g", "sex", ["island", "species"], "mean"), - ("culmen_depth_mm", "island", "species", "max"), + (("culmen_length_mm", "body_mass_g"), "species", "sex", "std", 1.0), + ( + ["body_mass_g", "culmen_length_mm"], + ("species", "island"), + "sex", + "sum", + None, + ), + ("body_mass_g", "sex", ["island", "species"], "mean", None), + ("culmen_depth_mm", "island", "species", "max", -1), ], ) def test_df_pivot_table( @@ -3262,12 +3843,21 @@ def test_df_pivot_table( index, columns, aggfunc, + fill_value, ): bf_result = penguins_df_default_index.pivot_table( - values=values, index=index, columns=columns, aggfunc=aggfunc + values=values, + index=index, + columns=columns, + aggfunc=aggfunc, + fill_value=fill_value, ).to_pandas() pd_result = penguins_pandas_df_default_index.pivot_table( - values=values, index=index, columns=columns, aggfunc=aggfunc + values=values, + index=index, + columns=columns, + aggfunc=aggfunc, + fill_value=fill_value, ) pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_column_type=False @@ -3346,6 +3936,15 @@ def test__dir__with_rename(scalars_dfs): assert "drop" in results +def test_loc_select_columns_w_repeats(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index[["int64_col", "int64_col", "int64_too"]].to_pandas() + pd_result = scalars_pandas_df_index[["int64_col", "int64_col", "int64_too"]] + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + @pytest.mark.parametrize( ("start", "stop", "step"), [ @@ -3370,6 +3969,24 @@ def test_iloc_slice(scalars_df_index, scalars_pandas_df_index, start, stop, step ) +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (0, 0, None), + ], +) +def test_iloc_slice_after_cache( + scalars_df_index, scalars_pandas_df_index, start, stop, step +): + scalars_df_index.cache() + bf_result = scalars_df_index.iloc[start:stop:step].to_pandas() + pd_result = scalars_pandas_df_index.iloc[start:stop:step] + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + def test_iloc_slice_zero_step(scalars_df_index): with pytest.raises(ValueError): scalars_df_index.iloc[0:0:0] @@ -3386,7 +4003,7 @@ def test_iloc_slice_nested(scalars_df_index, scalars_pandas_df_index, ordered): bf_result = scalars_df_index.iloc[1:].iloc[1:].to_pandas(ordered=ordered) pd_result = scalars_pandas_df_index.iloc[1:].iloc[1:] - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) @pytest.mark.parametrize( @@ -3414,6 +4031,24 @@ def test_iloc_tuple(scalars_df_index, scalars_pandas_df_index, index): assert bf_result == pd_result +@pytest.mark.parametrize( + "index", + [(slice(None), [1, 2, 3]), (slice(1, 7, 2), [2, 5, 3])], +) +def test_iloc_tuple_multi_columns(scalars_df_index, scalars_pandas_df_index, index): + bf_result = scalars_df_index.iloc[index].to_pandas() + pd_result = scalars_pandas_df_index.iloc[index] + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_iloc_tuple_multi_columns_single_row(scalars_df_index, scalars_pandas_df_index): + index = (2, [2, 1, 3, -4]) + bf_result = scalars_df_index.iloc[index] + pd_result = scalars_pandas_df_index.iloc[index] + pd.testing.assert_series_equal(bf_result, pd_result) + + @pytest.mark.parametrize( ("index", "error"), [ @@ -3456,9 +4091,7 @@ def test_iat_errors(scalars_df_index, scalars_pandas_df_index, index, error): scalars_df_index.iat[index] -def test_iloc_single_integer_out_of_bound_error( - scalars_df_index, scalars_pandas_df_index -): +def test_iloc_single_integer_out_of_bound_error(scalars_df_index): with pytest.raises(IndexError, match="single positional indexer is out-of-bounds"): scalars_df_index.iloc[99] @@ -3473,6 +4106,17 @@ def test_loc_bool_series(scalars_df_index, scalars_pandas_df_index): ) +def test_loc_list_select_rows_and_columns(scalars_df_index, scalars_pandas_df_index): + idx_list = [0, 3, 5] + bf_result = scalars_df_index.loc[idx_list, ["bool_col", "int64_col"]].to_pandas() + pd_result = scalars_pandas_df_index.loc[idx_list, ["bool_col", "int64_col"]] + + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + def test_loc_select_column(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.loc[:, "int64_col"].to_pandas() pd_result = scalars_pandas_df_index.loc[:, "int64_col"] @@ -3724,10 +4368,8 @@ def test_dataframe_aggregates_axis_1(scalars_df_index, scalars_pandas_df_index, bf_result = op(scalars_df_index[col_names]).to_pandas() pd_result = op(scalars_pandas_df_index[col_names]) - # Pandas may produce narrower numeric types, but bigframes always produces Float64 - pd_result = pd_result.astype("Float64") # Pandas has object index type - pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + assert_series_equal(pd_result, bf_result, check_index_type=False, check_dtype=False) def test_dataframe_aggregates_median(scalars_df_index, scalars_pandas_df_index): @@ -4025,7 +4667,7 @@ def test_df_rows_filter_items(scalars_df_index, scalars_pandas_df_index): # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pd.Int64Dtype()) # Ignore ordering as pandas order differently depending on version - assert_pandas_df_equal( + assert_frame_equal( bf_result, pd_result, ignore_order=True, @@ -4252,6 +4894,22 @@ def test_df___array__(scalars_df_index, scalars_pandas_df_index): ) +@pytest.mark.parametrize( + ("key",), + [ + ("hello",), + (2,), + ("int64_col",), + (None,), + ], +) +def test_df_contains(scalars_df_index, scalars_pandas_df_index, key): + bf_result = key in scalars_df_index + pd_result = key in scalars_pandas_df_index + + assert bf_result == pd_result + + def test_df_getattr_attribute_error_when_pandas_has(scalars_df_index): # swapaxes is implemented in pandas but not in bigframes with pytest.raises(AttributeError): @@ -4281,7 +4939,7 @@ def test_df_setattr_index(): pd_df.index = pandas.Index([4, 5]) bf_df.index = [4, 5] - assert_pandas_df_equal( + assert_frame_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -4296,7 +4954,7 @@ def test_df_setattr_columns(): bf_df.columns = pandas.Index([4, 5, 6]) - assert_pandas_df_equal( + assert_frame_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -4309,7 +4967,7 @@ def test_df_setattr_modify_column(): pd_df.my_column = [4, 5] bf_df.my_column = [4, 5] - assert_pandas_df_equal( + assert_frame_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -4358,9 +5016,15 @@ def test_loc_list_multiindex(scalars_dfs_maybe_ordered): ) -def test_iloc_list(scalars_df_index, scalars_pandas_df_index): - index_list = [0, 0, 0, 5, 4, 7] - +@pytest.mark.parametrize( + "index_list", + [ + [0, 1, 2, 3, 4, 4], + [0, 0, 0, 5, 4, 7, -2, -5, 3], + [-1, -2, -3, -4, -5, -5], + ], +) +def test_iloc_list(scalars_df_index, scalars_pandas_df_index, index_list): bf_result = scalars_df_index.iloc[index_list] pd_result = scalars_pandas_df_index.iloc[index_list] @@ -4370,6 +5034,26 @@ def test_iloc_list(scalars_df_index, scalars_pandas_df_index): ) +@pytest.mark.parametrize( + "index_list", + [ + [0, 1, 2, 3, 4, 4], + [0, 0, 0, 5, 4, 7, -2, -5, 3], + [-1, -2, -3, -4, -5, -5], + ], +) +def test_iloc_list_partial_ordering( + scalars_df_partial_ordering, scalars_pandas_df_index, index_list +): + bf_result = scalars_df_partial_ordering.iloc[index_list] + pd_result = scalars_pandas_df_index.iloc[index_list] + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + def test_iloc_list_multiindex(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs scalars_df = scalars_df.copy() @@ -4504,11 +5188,38 @@ def test_loc_bf_index_integer_index_renamed_col( ) def test_df_drop_duplicates(scalars_df_index, scalars_pandas_df_index, keep, subset): columns = ["bool_col", "int64_too", "int64_col"] - bf_series = scalars_df_index[columns].drop_duplicates(subset, keep=keep).to_pandas() - pd_series = scalars_pandas_df_index[columns].drop_duplicates(subset, keep=keep) + bf_df = scalars_df_index[columns].drop_duplicates(subset, keep=keep).to_pandas() + pd_df = scalars_pandas_df_index[columns].drop_duplicates(subset, keep=keep) pd.testing.assert_frame_equal( - pd_series, - bf_series, + pd_df, + bf_df, + ) + + +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + (False,), + ], +) +def test_df_drop_duplicates_w_json(json_df, keep): + bf_df = json_df.drop_duplicates(keep=keep).to_pandas() + + # drop_duplicates relies on pa.compute.dictionary_encode, which is incompatible + # with Arrow string extension types. Temporary conversion to standard Pandas + # strings is required. + json_pandas_df = json_df.to_pandas() + json_pandas_df["json_col"] = json_pandas_df["json_col"].astype( + pd.StringDtype(storage="pyarrow") + ) + + pd_df = json_pandas_df.drop_duplicates(keep=keep) + pd_df["json_col"] = pd_df["json_col"].astype(dtypes.JSON_DTYPE) + pd.testing.assert_frame_equal( + pd_df, + bf_df, ) @@ -4538,9 +5249,7 @@ def test_df_from_dict_columns_orient(): data = {"a": [1, 2], "b": [3.3, 2.4]} bf_result = dataframe.DataFrame.from_dict(data, orient="columns").to_pandas() pd_result = pd.DataFrame.from_dict(data, orient="columns") - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_from_dict_index_orient(): @@ -4549,9 +5258,7 @@ def test_df_from_dict_index_orient(): data, orient="index", columns=["col1", "col2"] ).to_pandas() pd_result = pd.DataFrame.from_dict(data, orient="index", columns=["col1", "col2"]) - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_from_dict_tight_orient(): @@ -4565,9 +5272,7 @@ def test_df_from_dict_tight_orient(): bf_result = dataframe.DataFrame.from_dict(data, orient="tight").to_pandas() pd_result = pd.DataFrame.from_dict(data, orient="tight") - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_from_records(): @@ -4577,9 +5282,7 @@ def test_df_from_records(): records, columns=["c1", "c2"] ).to_pandas() pd_result = pd.DataFrame.from_records(records, columns=["c1", "c2"]) - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_to_dict(scalars_df_index, scalars_pandas_df_index): @@ -4617,8 +5320,12 @@ def test_df_to_json_local_str(scalars_df_index, scalars_pandas_df_index): assert bf_result == pd_result -@skip_legacy_pandas def test_df_to_json_local_file(scalars_df_index, scalars_pandas_df_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + # duration not fully supported at pandas level + scalars_df_index = scalars_df_index.drop(columns="duration_col") + scalars_pandas_df_index = scalars_pandas_df_index.drop(columns="duration_col") with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: scalars_df_index.to_json(bf_result_file, orient="table") # default_handler for arrow types that have no default conversion @@ -4730,6 +5437,7 @@ def test_df_to_orc(scalars_df_index, scalars_pandas_df_index): "time_col", "timestamp_col", "geography_col", + "duration_col", ] bf_result_file = tempfile.TemporaryFile() @@ -4744,7 +5452,6 @@ def test_df_to_orc(scalars_df_index, scalars_pandas_df_index): assert bf_result == pd_result -@skip_legacy_pandas @pytest.mark.parametrize( ("expr",), [ @@ -4754,6 +5461,8 @@ def test_df_to_orc(scalars_df_index, scalars_pandas_df_index): ], ) def test_df_eval(scalars_dfs, expr): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_result = scalars_df.eval(expr).to_pandas() @@ -4762,7 +5471,6 @@ def test_df_eval(scalars_dfs, expr): pd.testing.assert_frame_equal(bf_result, pd_result) -@skip_legacy_pandas @pytest.mark.parametrize( ("expr",), [ @@ -4772,6 +5480,8 @@ def test_df_eval(scalars_dfs, expr): ], ) def test_df_query(scalars_dfs, expr): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") # local_var is referenced in expressions local_var = 3 # NOQA scalars_df, scalars_pandas_df = scalars_dfs @@ -4810,13 +5520,13 @@ def test_df_value_counts(scalars_dfs, subset, normalize, ascending, dropna): @pytest.mark.parametrize( - ("na_option", "method", "ascending", "numeric_only"), + ("na_option", "method", "ascending", "numeric_only", "pct"), [ - ("keep", "average", True, True), - ("top", "min", False, False), - ("bottom", "max", False, False), - ("top", "first", False, False), - ("bottom", "dense", False, False), + ("keep", "average", True, True, True), + ("top", "min", False, False, False), + ("bottom", "max", False, False, True), + ("top", "first", False, False, False), + ("bottom", "dense", False, False, True), ], ) def test_df_rank_with_nulls( @@ -4826,6 +5536,7 @@ def test_df_rank_with_nulls( method, ascending, numeric_only, + pct, ): unsupported_columns = ["geography_col"] bf_result = ( @@ -4835,6 +5546,7 @@ def test_df_rank_with_nulls( method=method, ascending=ascending, numeric_only=numeric_only, + pct=pct, ) .to_pandas() ) @@ -4845,6 +5557,7 @@ def test_df_rank_with_nulls( method=method, ascending=ascending, numeric_only=numeric_only, + pct=pct, ) .astype(pd.Float64Dtype()) ) @@ -4861,14 +5574,16 @@ def test_df_bool_interpretation_error(scalars_df_index): def test_query_job_setters(scalars_df_default_index: dataframe.DataFrame): - job_ids = set() - repr(scalars_df_default_index) - assert scalars_df_default_index.query_job is not None - job_ids.add(scalars_df_default_index.query_job.job_id) - scalars_df_default_index.to_pandas() - job_ids.add(scalars_df_default_index.query_job.job_id) + # if allow_large_results=False, might not create query job + with bigframes.option_context("compute.allow_large_results", True): + job_ids = set() + repr(scalars_df_default_index) + assert scalars_df_default_index.query_job is not None + job_ids.add(scalars_df_default_index.query_job.job_id) + scalars_df_default_index.to_pandas(allow_large_results=True) + job_ids.add(scalars_df_default_index.query_job.job_id) - assert len(job_ids) == 2 + assert len(job_ids) == 2 def test_df_cached(scalars_df_index): @@ -4881,6 +5596,23 @@ def test_df_cached(scalars_df_index): pandas.testing.assert_frame_equal(df.to_pandas(), df_cached_copy.to_pandas()) +def test_df_cached_many_index_cols(scalars_df_index): + index_cols = [ + "int64_too", + "time_col", + "int64_col", + "bool_col", + "date_col", + "timestamp_col", + "string_col", + ] + df = scalars_df_index.set_index(index_cols) + df = df[df["rowindex_2"] % 2 == 0] + + df_cached_copy = df.cache() + pandas.testing.assert_frame_equal(df.to_pandas(), df_cached_copy.to_pandas()) + + def test_assign_after_binop_row_joins(): pd_df = pd.DataFrame( { @@ -4897,7 +5629,7 @@ def test_assign_after_binop_row_joins(): bf_df["metric_diff"] = bf_df.metric1 - bf_df.metric2 pd_df["metric_diff"] = pd_df.metric1 - pd_df.metric2 - assert_pandas_df_equal(bf_df.to_pandas(), pd_df) + assert_frame_equal(bf_df.to_pandas(), pd_df) def test_df_cache_with_implicit_join(scalars_df_index): @@ -5066,7 +5798,7 @@ def test_query_complexity_repeated_joins( bf_result = bf_df.to_pandas() pd_result = pd_df - assert_pandas_df_equal(bf_result, pd_result, check_index_type=False) + assert_frame_equal(bf_result, pd_result, check_index_type=False) def test_query_complexity_repeated_subtrees( @@ -5080,7 +5812,7 @@ def test_query_complexity_repeated_subtrees( bf_df = bpd.concat(10 * [bf_df]).head(5) bf_result = bf_df.to_pandas() pd_result = pd_df - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.skipif( @@ -5088,9 +5820,7 @@ def test_query_complexity_repeated_subtrees( # See: https://github.com/python/cpython/issues/112282 reason="setrecursionlimit has no effect on the Python C stack since Python 3.12.", ) -def test_query_complexity_repeated_analytic( - scalars_df_index, scalars_pandas_df_index, with_multiquery_execution -): +def test_query_complexity_repeated_analytic(scalars_df_index, scalars_pandas_df_index): bf_df = scalars_df_index[["int64_col", "int64_too"]] pd_df = scalars_pandas_df_index[["int64_col", "int64_too"]] # Uses LAG analytic operator, each in a new SELECT @@ -5099,18 +5829,7 @@ def test_query_complexity_repeated_analytic( pd_df = pd_df.diff() bf_result = bf_df.to_pandas() pd_result = pd_df - assert_pandas_df_equal(bf_result, pd_result) - - -def test_to_pandas_downsampling_option_override(session): - df = session.read_gbq("bigframes-dev.bigframes_tests_sys.batting") - download_size = 1 - - df = df.to_pandas(max_download_size=download_size, sampling_method="head") - - total_memory_bytes = df.memory_usage(deep=True).sum() - total_memory_mb = total_memory_bytes / (1024 * 1024) - assert total_memory_mb == pytest.approx(download_size, rel=0.5) + assert_frame_equal(bf_result, pd_result) def test_to_gbq_and_create_dataset(session, scalars_df_index, dataset_id_not_created): @@ -5128,6 +5847,16 @@ def test_to_gbq_and_create_dataset(session, scalars_df_index, dataset_id_not_cre assert not loaded_scalars_df_index.empty +def test_read_gbq_to_pandas_no_exec(unordered_session: bigframes.Session): + metrics = unordered_session._metrics + execs_pre = metrics.execution_count + df = unordered_session.read_gbq("bigquery-public-data.ml_datasets.penguins") + df.to_pandas() + execs_post = metrics.execution_count + assert df.shape == (344, 7) + assert execs_pre == execs_post + + def test_to_gbq_table_labels(scalars_df_index): destination_table = "bigframes-dev.bigframes_tests_sys.table_labels" result_table = scalars_df_index.to_gbq( @@ -5221,7 +5950,6 @@ def test_dataframe_explode_xfail(col_names): df.explode(col_names) -@skip_legacy_pandas @pytest.mark.parametrize( ("on", "rule", "origin"), [ @@ -5230,19 +5958,15 @@ def test_dataframe_explode_xfail(col_names): pytest.param("datetime_col", "5M", "epoch"), pytest.param("datetime_col", "3Q", "start_day"), pytest.param("datetime_col", "3YE", "start"), - pytest.param( - "int64_col", "100D", "start", marks=pytest.mark.xfail(raises=TypeError) - ), - pytest.param( - "datetime_col", "100D", "end", marks=pytest.mark.xfail(raises=ValueError) - ), ], ) -def test__resample_with_column( +def test_resample_with_column( scalars_df_index, scalars_pandas_df_index, on, rule, origin ): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") bf_result = ( - scalars_df_index._resample(rule=rule, on=on, origin=origin)[ + scalars_df_index.resample(rule=rule, on=on, origin=origin)[ ["int64_col", "int64_too"] ] .max() @@ -5256,35 +5980,59 @@ def test__resample_with_column( ) -@skip_legacy_pandas +@pytest.mark.parametrize("index_col", ["timestamp_col", "datetime_col"]) @pytest.mark.parametrize( - ("append", "level", "col", "rule"), + ("index_append", "level"), + [(True, 1), (False, None), (False, 0)], +) +@pytest.mark.parametrize( + "rule", [ - pytest.param(False, None, "timestamp_col", "100d"), - pytest.param(True, 1, "timestamp_col", "1200h"), - pytest.param(False, None, "datetime_col", "100d"), + # TODO(tswast): support timedeltas and dataoffsets. + # TODO(tswast): support bins that default to "right". + "100d", + "1200h", ], ) -def test__resample_with_index( - scalars_df_index, scalars_pandas_df_index, append, level, col, rule +# TODO(tswast): support "right" +@pytest.mark.parametrize("closed", ["left", None]) +# TODO(tswast): support "right" +@pytest.mark.parametrize("label", ["left", None]) +@pytest.mark.parametrize( + "origin", + ["epoch", "start", "start_day"], # TODO(tswast): support end, end_day. +) +def test_resample_with_index( + scalars_df_index, + scalars_pandas_df_index, + index_append, + level, + index_col, + rule, + closed, + origin, + label, ): - scalars_df_index = scalars_df_index.set_index(col, append=append) - scalars_pandas_df_index = scalars_pandas_df_index.set_index(col, append=append) + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df_index = scalars_df_index.set_index(index_col, append=index_append) + scalars_pandas_df_index = scalars_pandas_df_index.set_index( + index_col, append=index_append + ) bf_result = ( scalars_df_index[["int64_col", "int64_too"]] - ._resample(rule=rule, level=level) + .resample(rule=rule, level=level, closed=closed, origin=origin, label=label) .min() .to_pandas() ) pd_result = ( scalars_pandas_df_index[["int64_col", "int64_too"]] - .resample(rule=rule, level=level) + .resample(rule=rule, level=level, closed=closed, origin=origin, label=label) .min() ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) -@skip_legacy_pandas @pytest.mark.parametrize( ("rule", "origin", "data"), [ @@ -5323,13 +6071,15 @@ def test__resample_with_index( ), ], ) -def test__resample_start_time(rule, origin, data): +def test_resample_start_time(rule, origin, data): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") col = "timestamp_col" scalars_df_index = bpd.DataFrame(data).set_index(col) scalars_pandas_df_index = pd.DataFrame(data).set_index(col) scalars_pandas_df_index.index.name = None - bf_result = scalars_df_index._resample(rule=rule, origin=origin).min().to_pandas() + bf_result = scalars_df_index.resample(rule=rule, origin=origin).min().to_pandas() pd_result = scalars_pandas_df_index.resample(rule=rule, origin=origin).min() @@ -5378,5 +6128,100 @@ def test_df_astype_python_types(scalars_dfs): def test_astype_invalid_type_fail(scalars_dfs): bf_df, _ = scalars_dfs - with pytest.raises(TypeError, match=r".*Share your usecase with.*"): + with pytest.raises(TypeError, match=r".*Share your use case with.*"): bf_df.astype(123) + + +def test_agg_with_dict_lists_strings(scalars_dfs): + bf_df, pd_df = scalars_dfs + agg_funcs = { + "int64_too": ["min", "max"], + "int64_col": ["min", "count"], + } + + bf_result = bf_df.agg(agg_funcs).to_pandas() + pd_result = pd_df.agg(agg_funcs) + + pd.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_agg_with_dict_lists_callables(scalars_dfs): + bf_df, pd_df = scalars_dfs + agg_funcs = { + "int64_too": [np.min, np.max], + "int64_col": [np.min, np.var], + } + + bf_result = bf_df.agg(agg_funcs).to_pandas() + pd_result = pd_df.agg(agg_funcs) + + pd.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_agg_with_dict_list_and_str(scalars_dfs): + bf_df, pd_df = scalars_dfs + agg_funcs = { + "int64_too": ["min", "max"], + "int64_col": "sum", + } + + bf_result = bf_df.agg(agg_funcs).to_pandas() + pd_result = pd_df.agg(agg_funcs) + + pd.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_agg_with_dict_strs(scalars_dfs): + bf_df, pd_df = scalars_dfs + agg_funcs = { + "int64_too": "min", + "int64_col": "sum", + "float64_col": "max", + } + + bf_result = bf_df.agg(agg_funcs).to_pandas() + pd_result = pd_df.agg(agg_funcs) + pd_result.index = pd_result.index.astype("string[pyarrow]") + + pd.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_df_agg_with_builtins(scalars_dfs): + bf_df, pd_df = scalars_dfs + + bf_result = ( + bf_df[["int64_col", "bool_col"]] + .dropna() + .groupby(bf_df.int64_too % 2) + .agg({"int64_col": [len, sum, min, max, list], "bool_col": [all, any, max]}) + .to_pandas() + ) + pd_result = ( + pd_df[["int64_col", "bool_col"]] + .dropna() + .groupby(pd_df.int64_too % 2) + .agg({"int64_col": [len, sum, min, max, list], "bool_col": [all, any, max]}) + ) + + pd.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_agg_with_dict_containing_non_existing_col_raise_key_error(scalars_dfs): + bf_df, _ = scalars_dfs + agg_funcs = { + "int64_too": ["min", "max"], + "nonexisting_col": ["count"], + } + + with pytest.raises(KeyError): + bf_df.agg(agg_funcs) diff --git a/tests/system/small/test_dataframe_io.py b/tests/system/small/test_dataframe_io.py index b07213f943..02acb8d8f2 100644 --- a/tests/system/small/test_dataframe_io.py +++ b/tests/system/small/test_dataframe_io.py @@ -12,17 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -import math from typing import Tuple -import db_dtypes # type:ignore import google.api_core.exceptions +import numpy +import numpy.testing import pandas as pd import pandas.testing import pyarrow as pa import pytest -from tests.system import utils +import bigframes.dtypes as dtypes +from bigframes.testing import utils try: import pandas_gbq # type: ignore @@ -32,8 +33,11 @@ import typing +from google.cloud import bigquery + import bigframes import bigframes.dataframe +import bigframes.enums import bigframes.features import bigframes.pandas as bpd @@ -51,7 +55,7 @@ def test_sql_executes(scalars_df_default_index, bigquery_client): """ # Do some operations to make for more complex SQL. df = ( - scalars_df_default_index.drop(columns=["geography_col"]) + scalars_df_default_index.drop(columns=["geography_col", "duration_col"]) .groupby("string_col") .max() ) @@ -83,7 +87,7 @@ def test_sql_executes_and_includes_named_index( """ # Do some operations to make for more complex SQL. df = ( - scalars_df_default_index.drop(columns=["geography_col"]) + scalars_df_default_index.drop(columns=["geography_col", "duration_col"]) .groupby("string_col") .max() ) @@ -116,7 +120,7 @@ def test_sql_executes_and_includes_named_multiindex( """ # Do some operations to make for more complex SQL. df = ( - scalars_df_default_index.drop(columns=["geography_col"]) + scalars_df_default_index.drop(columns=["geography_col", "duration_col"]) .groupby(["string_col", "bool_col"]) .max() ) @@ -249,154 +253,237 @@ def test_to_pandas_array_struct_correct_result(session): ) -def test_load_json_w_unboxed_py_value(session): +def test_to_pandas_override_global_option(scalars_df_index): + # Direct call to_pandas uses global default setting (allow_large_results=True), + # table has 'bqdf' prefix. + with bigframes.option_context("compute.allow_large_results", True): + + scalars_df_index.to_pandas() + table_id = scalars_df_index._query_job.destination.table_id + assert table_id is not None + + # When allow_large_results=False, a query_job object should not be created. + # Therefore, the table_id should remain unchanged. + scalars_df_index.to_pandas(allow_large_results=False) + assert scalars_df_index._query_job.destination.table_id == table_id + + +def test_to_pandas_downsampling_option_override(session): + df = session.read_gbq("bigframes-dev.bigframes_tests_sys.batting") + download_size = 1 + + with pytest.warns( + UserWarning, match="The data size .* exceeds the maximum download limit" + ): + # limits only apply for allow_large_result=True + df = df.to_pandas( + max_download_size=download_size, + sampling_method="head", + allow_large_results=True, + ) + + total_memory_bytes = df.memory_usage(deep=True).sum() + total_memory_mb = total_memory_bytes / (1024 * 1024) + assert total_memory_mb == pytest.approx(download_size, rel=0.5) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + pytest.param( + {"sampling_method": "head"}, + r"DEPRECATED[\S\s]*sampling_method[\S\s]*DataFrame.sample", + id="sampling_method", + ), + pytest.param( + {"random_state": 10}, + r"DEPRECATED[\S\s]*random_state[\S\s]*DataFrame.sample", + id="random_state", + ), + pytest.param( + {"max_download_size": 10}, + r"DEPRECATED[\S\s]*max_download_size[\S\s]*DataFrame.to_pandas_batches", + id="max_download_size", + ), + ], +) +def test_to_pandas_warns_deprecated_parameters(scalars_df_index, kwargs, message): + with pytest.warns(FutureWarning, match=message): + scalars_df_index.to_pandas( + # limits only apply for allow_large_result=True + allow_large_results=True, + **kwargs, + ) + + +def test_to_pandas_dry_run(session, scalars_pandas_df_multi_index): + bf_df = session.read_pandas(scalars_pandas_df_multi_index) + + result = bf_df.to_pandas(dry_run=True) + + assert isinstance(result, pd.Series) + assert len(result) > 0 + + +def test_to_arrow_override_global_option(scalars_df_index): + # Direct call to_arrow uses global default setting (allow_large_results=True), + with bigframes.option_context("compute.allow_large_results", True): + + scalars_df_index.to_arrow() + table_id = scalars_df_index._query_job.destination.table_id + assert table_id is not None + + # When allow_large_results=False, a query_job object should not be created. + # Therefore, the table_id should remain unchanged. + scalars_df_index.to_arrow(allow_large_results=False) + assert scalars_df_index._query_job.destination.table_id == table_id + + +def test_to_pandas_batches_populates_total_bytes_processed(scalars_df_default_index): + batches = scalars_df_default_index.sort_values( + "int64_col" + ).to_pandas_batches() # Do a sort to force query execution. + assert batches.total_bytes_processed > 0 + + +def test_to_pandas_batches_w_correct_dtypes(scalars_df_default_index): + """Verify to_pandas_batches() APIs returns the expected dtypes.""" + expected = scalars_df_default_index.dtypes + for df in scalars_df_default_index.to_pandas_batches(): + actual = df.dtypes + pd.testing.assert_series_equal(actual, expected) + + +def test_to_pandas_batches_w_empty_dataframe(session): + """Verify to_pandas_batches() APIs returns at least one DataFrame. + + See b/428918844 for additional context. + """ + empty = bpd.DataFrame( + { + "idx1": [], + "idx2": [], + "col1": pandas.Series([], dtype="string[pyarrow]"), + "col2": pandas.Series([], dtype="Int64"), + }, + session=session, + ).set_index(["idx1", "idx2"], drop=True) + + results = list(empty.to_pandas_batches()) + assert len(results) == 1 + assert list(results[0].index.names) == ["idx1", "idx2"] + assert list(results[0].columns) == ["col1", "col2"] + pandas.testing.assert_series_equal(results[0].dtypes, empty.dtypes) + + +@pytest.mark.skipif( + bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable, + reason="Test for pandas 1.x behavior only", +) +def test_to_pandas_batches_preserves_dtypes_for_populated_nested_json_pandas1(session): + """Verifies to_pandas_batches() preserves dtypes for nested JSON in pandas 1.x.""" sql = """ - SELECT 0 AS id, JSON_OBJECT('boolean', True) AS json_col, - UNION ALL - SELECT 1, JSON_OBJECT('int', 100), - UNION ALL - SELECT 2, JSON_OBJECT('float', 0.98), - UNION ALL - SELECT 3, JSON_OBJECT('string', 'hello world'), - UNION ALL - SELECT 4, JSON_OBJECT('array', [8, 9, 10]), - UNION ALL - SELECT 5, JSON_OBJECT('null', null), - UNION ALL SELECT - 6, - JSON_OBJECT( - 'dict', - JSON_OBJECT( - 'int', 1, - 'array', [JSON_OBJECT('bar', 'hello'), JSON_OBJECT('foo', 1)] - ) - ), + 0 AS id, + [JSON '{"a":1}', JSON '{"b":2}'] AS json_array, + STRUCT(JSON '{"x":1}' AS json_field, 'test' AS str_field) AS json_struct """ df = session.read_gbq(sql, index_col="id") + batches = list(df.to_pandas_batches()) - assert df.dtypes["json_col"] == db_dtypes.JSONDtype() - assert isinstance(df["json_col"][0], dict) - - assert df["json_col"][0]["boolean"] - assert df["json_col"][1]["int"] == 100 - assert math.isclose(df["json_col"][2]["float"], 0.98) - assert df["json_col"][3]["string"] == "hello world" - assert df["json_col"][4]["array"] == [8, 9, 10] - assert df["json_col"][5]["null"] is None - assert df["json_col"][6]["dict"] == { - "int": 1, - "array": [{"bar": "hello"}, {"foo": 1}], - } + assert batches[0].dtypes["json_array"] == "object" + assert isinstance(batches[0].dtypes["json_struct"], pd.ArrowDtype) -def test_load_json_to_pandas_has_correct_result(session): - df = session.read_gbq("SELECT JSON_OBJECT('foo', 10, 'bar', TRUE) AS json_col") - assert df.dtypes["json_col"] == db_dtypes.JSONDtype() - result = df.to_pandas() +@pytest.mark.skipif( + not bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable, + reason="Test for pandas 2.x behavior only", +) +def test_to_pandas_batches_preserves_dtypes_for_populated_nested_json_pandas2(session): + """Verifies to_pandas_batches() preserves dtypes for nested JSON in pandas 2.x.""" + sql = """ + SELECT + 0 AS id, + [JSON '{"a":1}', JSON '{"b":2}'] AS json_array, + STRUCT(JSON '{"x":1}' AS json_field, 'test' AS str_field) AS json_struct + """ + df = session.read_gbq(sql, index_col="id") + batches = list(df.to_pandas_batches()) - # The order of keys within the JSON object shouldn't matter for equality checks. - pd_df = pd.DataFrame( - {"json_col": [{"bar": True, "foo": 10}]}, - dtype=db_dtypes.JSONDtype(), - ) - pd_df.index = pd_df.index.astype("Int64") - pd.testing.assert_series_equal(result.dtypes, pd_df.dtypes) - pd.testing.assert_series_equal(result["json_col"], pd_df["json_col"]) + assert isinstance(batches[0].dtypes["json_array"], pd.ArrowDtype) + assert isinstance(batches[0].dtypes["json_array"].pyarrow_dtype, pa.ListType) + assert isinstance(batches[0].dtypes["json_struct"], pd.ArrowDtype) -def test_load_json_in_struct(session): - """Avoid regressions for internal issue 381148539.""" +@pytest.mark.skipif( + bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable, + reason="Test for pandas 1.x behavior only", +) +def test_to_pandas_batches_should_not_error_on_empty_nested_json_pandas1(session): + """Verify to_pandas_batches() works with empty nested JSON types in pandas 1.x.""" + sql = """ - SELECT 0 AS id, STRUCT(JSON_OBJECT('boolean', True) AS data, 1 AS number) AS struct_col - UNION ALL - SELECT 1, STRUCT(JSON_OBJECT('int', 100), 2), - UNION ALL - SELECT 2, STRUCT(JSON_OBJECT('float', 0.98), 3), - UNION ALL - SELECT 3, STRUCT(JSON_OBJECT('string', 'hello world'), 4), - UNION ALL - SELECT 4, STRUCT(JSON_OBJECT('array', [8, 9, 10]), 5), - UNION ALL - SELECT 5, STRUCT(JSON_OBJECT('null', null), 6), - UNION ALL SELECT - 6, - STRUCT(JSON_OBJECT( - 'dict', - JSON_OBJECT( - 'int', 1, - 'array', [JSON_OBJECT('bar', 'hello'), JSON_OBJECT('foo', 1)] - ) - ), 7), + 1 AS id, + [] AS json_array, + STRUCT(NULL AS json_field, 'test2' AS str_field) AS json_struct """ df = session.read_gbq(sql, index_col="id") - assert isinstance(df.dtypes["struct_col"], pd.ArrowDtype) - assert isinstance(df.dtypes["struct_col"].pyarrow_dtype, pa.StructType) - - data = df["struct_col"].struct.field("data") - assert data.dtype == db_dtypes.JSONDtype() - - assert data[0]["boolean"] - assert data[1]["int"] == 100 - assert math.isclose(data[2]["float"], 0.98) - assert data[3]["string"] == "hello world" - assert data[4]["array"] == [8, 9, 10] - assert data[5]["null"] is None - assert data[6]["dict"] == { - "int": 1, - "array": [{"bar": "hello"}, {"foo": 1}], - } + # The main point: this should not raise an error + batches = list(df.to_pandas_batches()) + assert sum(len(b) for b in batches) == 1 + assert batches[0].dtypes["json_array"] == "object" + assert isinstance(batches[0].dtypes["json_struct"], pd.ArrowDtype) + + +@pytest.mark.skipif( + not bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable, + reason="Test for pandas 2.x behavior only", +) +def test_to_pandas_batches_should_not_error_on_empty_nested_json_pandas2(session): + """Verify to_pandas_batches() works with empty nested JSON types in pandas 2.x.""" -def test_load_json_in_array(session): sql = """ SELECT - 0 AS id, - [ - JSON_OBJECT('boolean', True), - JSON_OBJECT('int', 100), - JSON_OBJECT('float', 0.98), - JSON_OBJECT('string', 'hello world'), - JSON_OBJECT('array', [8, 9, 10]), - JSON_OBJECT('null', null), - JSON_OBJECT( - 'dict', - JSON_OBJECT( - 'int', 1, - 'array', [JSON_OBJECT('bar', 'hello'), JSON_OBJECT('foo', 1)] - ) - ) - ] AS array_col, + 1 AS id, + [] AS json_array, + STRUCT(NULL AS json_field, 'test2' AS str_field) AS json_struct """ df = session.read_gbq(sql, index_col="id") - assert isinstance(df.dtypes["array_col"], pd.ArrowDtype) - assert isinstance(df.dtypes["array_col"].pyarrow_dtype, pa.ListType) - - data = df["array_col"].list - assert data.len()[0] == 7 - assert data[0].dtype == db_dtypes.JSONDtype() - - assert data[0][0]["boolean"] - assert data[1][0]["int"] == 100 - assert math.isclose(data[2][0]["float"], 0.98) - assert data[3][0]["string"] == "hello world" - assert data[4][0]["array"] == [8, 9, 10] - assert data[5][0]["null"] is None - assert data[6][0]["dict"] == { - "int": 1, - "array": [{"bar": "hello"}, {"foo": 1}], - } + # The main point: this should not raise an error + batches = list(df.to_pandas_batches()) + assert sum(len(b) for b in batches) == 1 + assert isinstance(batches[0].dtypes["json_array"], pd.ArrowDtype) + assert isinstance(batches[0].dtypes["json_struct"], pd.ArrowDtype) + assert isinstance(batches[0].dtypes["json_struct"].pyarrow_dtype, pa.StructType) -def test_to_pandas_batches_w_correct_dtypes(scalars_df_default_index): - """Verify to_pandas_batches() APIs returns the expected dtypes.""" - expected = scalars_df_default_index.dtypes - for df in scalars_df_default_index.to_pandas_batches(): - actual = df.dtypes - pd.testing.assert_series_equal(actual, expected) + +@pytest.mark.parametrize("allow_large_results", (True, False)) +def test_to_pandas_batches_w_page_size_and_max_results(session, allow_large_results): + """Verify to_pandas_batches() APIs returns the expected page size. + + Regression test for b/407521010. + """ + bf_df = session.read_gbq( + "bigquery-public-data.usa_names.usa_1910_2013", + index_col=bigframes.enums.DefaultIndexKind.NULL, + ) + expected_column_count = len(bf_df.columns) + + batch_count = 0 + for pd_df in bf_df.to_pandas_batches( + page_size=42, allow_large_results=allow_large_results, max_results=42 * 3 + ): + batch_row_count, batch_column_count = pd_df.shape + batch_count += 1 + assert batch_column_count == expected_column_count + assert batch_row_count == 42 + + assert batch_count == 3 @pytest.mark.parametrize( @@ -489,7 +576,7 @@ def test_to_csv_tabs( [True, False], ) @pytest.mark.skipif(pandas_gbq is None, reason="required by pd.read_gbq") -def test_to_gbq_index(scalars_dfs, dataset_id, index): +def test_to_gbq_w_index(scalars_dfs, dataset_id, index): """Test the `to_gbq` API with the `index` parameter.""" scalars_df, scalars_pandas_df = scalars_dfs destination_table = f"{dataset_id}.test_index_df_to_gbq_{index}" @@ -516,48 +603,67 @@ def test_to_gbq_index(scalars_dfs, dataset_id, index): pd.testing.assert_frame_equal(df_out, expected, check_index_type=False) -@pytest.mark.parametrize( - ("if_exists", "expected_index"), - [ - pytest.param("replace", 1), - pytest.param("append", 2), - pytest.param( - "fail", - 0, - marks=pytest.mark.xfail( - raises=google.api_core.exceptions.Conflict, - ), - ), - pytest.param( - "unknown", - 0, - marks=pytest.mark.xfail( - raises=ValueError, - ), - ), - ], -) -@pytest.mark.skipif(pandas_gbq is None, reason="required by pd.read_gbq") -def test_to_gbq_if_exists( - scalars_df_default_index, - scalars_pandas_df_default_index, - dataset_id, - if_exists, - expected_index, -): - """Test the `to_gbq` API with the `if_exists` parameter.""" - destination_table = f"{dataset_id}.test_to_gbq_if_exists_{if_exists}" +def test_to_gbq_if_exists_is_fail(scalars_dfs, dataset_id): + scalars_df, scalars_pandas_df = scalars_dfs + destination_table = f"{dataset_id}.test_to_gbq_if_exists_is_fails" + scalars_df.to_gbq(destination_table) - scalars_df_default_index.to_gbq(destination_table) - scalars_df_default_index.to_gbq(destination_table, if_exists=if_exists) + gcs_df = pd.read_gbq(destination_table, index_col="rowindex") + assert len(gcs_df) == len(scalars_pandas_df) + pd.testing.assert_index_equal(gcs_df.columns, scalars_pandas_df.columns) - gcs_df = pd.read_gbq(destination_table) - assert len(gcs_df.index) == expected_index * len( - scalars_pandas_df_default_index.index - ) - pd.testing.assert_index_equal( - gcs_df.columns, scalars_pandas_df_default_index.columns - ) + # Test default value is "fails" + with pytest.raises(ValueError, match="Table already exists"): + scalars_df.to_gbq(destination_table) + + with pytest.raises(ValueError, match="Table already exists"): + scalars_df.to_gbq(destination_table, if_exists="fail") + + +def test_to_gbq_if_exists_is_replace(scalars_dfs, dataset_id): + scalars_df, scalars_pandas_df = scalars_dfs + destination_table = f"{dataset_id}.test_to_gbq_if_exists_is_replace" + scalars_df.to_gbq(destination_table) + + gcs_df = pd.read_gbq(destination_table, index_col="rowindex") + assert len(gcs_df) == len(scalars_pandas_df) + pd.testing.assert_index_equal(gcs_df.columns, scalars_pandas_df.columns) + + # When replacing a table with same schema + scalars_df.to_gbq(destination_table, if_exists="replace") + gcs_df = pd.read_gbq(destination_table, index_col="rowindex") + assert len(gcs_df) == len(scalars_pandas_df) + pd.testing.assert_index_equal(gcs_df.columns, scalars_pandas_df.columns) + + # When replacing a table with different schema + partitial_scalars_df = scalars_df.drop(columns=["string_col"]) + partitial_scalars_df.to_gbq(destination_table, if_exists="replace") + gcs_df = pd.read_gbq(destination_table, index_col="rowindex") + assert len(gcs_df) == len(partitial_scalars_df) + pd.testing.assert_index_equal(gcs_df.columns, partitial_scalars_df.columns) + + +def test_to_gbq_if_exists_is_append(scalars_dfs, dataset_id): + scalars_df, scalars_pandas_df = scalars_dfs + destination_table = f"{dataset_id}.test_to_gbq_if_exists_is_append" + scalars_df.to_gbq(destination_table) + + gcs_df = pd.read_gbq(destination_table, index_col="rowindex") + assert len(gcs_df) == len(scalars_pandas_df) + pd.testing.assert_index_equal(gcs_df.columns, scalars_pandas_df.columns) + + # When appending to a table with same schema + scalars_df.to_gbq(destination_table, if_exists="append") + gcs_df = pd.read_gbq(destination_table, index_col="rowindex") + assert len(gcs_df) == 2 * len(scalars_pandas_df) + pd.testing.assert_index_equal(gcs_df.columns, scalars_pandas_df.columns) + + # When appending to a table with different schema + partitial_scalars_df = scalars_df.drop(columns=["string_col"]) + partitial_scalars_df.to_gbq(destination_table, if_exists="append") + gcs_df = pd.read_gbq(destination_table, index_col="rowindex") + assert len(gcs_df) == 3 * len(partitial_scalars_df) + pd.testing.assert_index_equal(gcs_df.columns, scalars_df.columns) def test_to_gbq_w_duplicate_column_names( @@ -583,6 +689,157 @@ def test_to_gbq_w_duplicate_column_names( ) +def test_to_gbq_w_protected_column_names( + scalars_df_index, scalars_pandas_df_index, dataset_id +): + """ + Column names can't use any of the following prefixes: + + * _TABLE_ + * _FILE_ + * _PARTITION + * _ROW_TIMESTAMP + * __ROOT__ + * _COLIDENTIFIER + + See: https://cloud.google.com/bigquery/docs/schemas#column_names + """ + destination_table = f"{dataset_id}.test_to_gbq_w_protected_column_names" + + scalars_df_index = scalars_df_index.rename( + columns={ + "bool_col": "_Table_Suffix", + "bytes_col": "_file_path", + "date_col": "_PARTITIONDATE", + "datetime_col": "_ROW_TIMESTAMP", + "int64_col": "__ROOT__", + "int64_too": "_COLIDENTIFIER", + "numeric_col": "COLIDENTIFIER", # Create a collision at serialization time. + } + )[ + [ + "_Table_Suffix", + "_file_path", + "_PARTITIONDATE", + "_ROW_TIMESTAMP", + "__ROOT__", + "_COLIDENTIFIER", + "COLIDENTIFIER", + ] + ] + scalars_df_index.to_gbq(destination_table, if_exists="replace") + + bf_result = bpd.read_gbq(destination_table, index_col="rowindex").to_pandas() + + # Leading _ characters are removed to make these columns valid in BigQuery. + expected = scalars_pandas_df_index.rename( + columns={ + "bool_col": "Table_Suffix", + "bytes_col": "file_path", + "date_col": "PARTITIONDATE", + "datetime_col": "ROW_TIMESTAMP", + "int64_col": "ROOT__", + "int64_too": "COLIDENTIFIER", + "numeric_col": "COLIDENTIFIER_1", + } + )[ + [ + "Table_Suffix", + "file_path", + "PARTITIONDATE", + "ROW_TIMESTAMP", + "ROOT__", + "COLIDENTIFIER", + "COLIDENTIFIER_1", + ] + ] + + pd.testing.assert_frame_equal(bf_result, expected) + + +def test_to_gbq_w_flexible_column_names( + scalars_df_index, dataset_id: str, bigquery_client +): + """Test the `to_gbq` API when dealing with flexible column names. + + This test is for BigQuery-backed storage nodes. + + See: https://cloud.google.com/bigquery/docs/schemas#flexible-column-names + """ + destination_table = f"{dataset_id}.test_to_gbq_w_flexible_column_names" + renamed_columns = { + # First column in Japanese (tests unicode). + "bool_col": "最初のカラム", + "bytes_col": "col with space", + # Dots aren't allowed in BigQuery column names, so these should be translated + "date_col": "col.with.dots", + "datetime_col": "col-with-hyphens", + "geography_col": "1start_with_number", + "int64_col": "col_with_underscore", + # Just numbers. + "int64_too": "123", + } + bf_df = scalars_df_index[renamed_columns.keys()].rename(columns=renamed_columns) + assert list(bf_df.columns) == list(renamed_columns.values()) + bf_df.to_gbq(destination_table, index=False) + + table = bigquery_client.get_table(destination_table) + columns = [field.name for field in table.schema] + assert columns == [ + "最初のカラム", + "col with space", + # Dots aren't allowed in BigQuery column names, so these should be translated + "col_with_dots", + "col-with-hyphens", + "1start_with_number", + "col_with_underscore", + "123", + ] + + +def test_to_gbq_w_flexible_column_names_local_node( + session, dataset_id: str, bigquery_client +): + """Test the `to_gbq` API when dealing with flexible column names. + + This test is for local nodes, e.g. read_pandas(), since those may go through + a different code path compared to data that starts in BigQuery. + + See: https://cloud.google.com/bigquery/docs/schemas#flexible-column-names + """ + destination_table = f"{dataset_id}.test_to_gbq_w_flexible_column_names_local_node" + + data = { + # First column in Japanese (tests unicode). + "最初のカラム": [1, 2, 3], + "col with space": [4, 5, 6], + # Dots aren't allowed in BigQuery column names, so these should be translated + "col.with.dots": [7, 8, 9], + "col-with-hyphens": [10, 11, 12], + "1start_with_number": [13, 14, 15], + "col_with_underscore": [16, 17, 18], + "123": [19, 20, 21], + } + pd_df = pd.DataFrame(data) + assert list(pd_df.columns) == list(data.keys()) + bf_df = session.read_pandas(pd_df) + assert list(bf_df.columns) == list(data.keys()) + bf_df.to_gbq(destination_table, index=False) + + table = bigquery_client.get_table(destination_table) + columns = [field.name for field in table.schema] + assert columns == [ + "最初のカラム", + "col with space", + # Dots aren't allowed in BigQuery column names, so these should be translated + "col_with_dots", + "col-with-hyphens", + "1start_with_number", + "col_with_underscore", + "123", + ] + + def test_to_gbq_w_None_column_names( scalars_df_index, scalars_pandas_df_index, dataset_id ): @@ -653,6 +910,27 @@ def test_to_gbq_w_clustering_no_destination( assert table.expires is not None +def test_to_gbq_w_clustering_existing_table( + scalars_df_default_index, + dataset_id, + bigquery_client, +): + destination_table = f"{dataset_id}.test_to_gbq_w_clustering_existing_table" + scalars_df_default_index.to_gbq(destination_table) + + table = bigquery_client.get_table(destination_table) + assert table.clustering_fields is None + assert table.expires is None + + with pytest.raises(ValueError, match="Table clustering fields cannot be changed"): + clustering_columns = ["int64_col"] + scalars_df_default_index.to_gbq( + destination_table, + if_exists="replace", + clustering_columns=clustering_columns, + ) + + def test_to_gbq_w_invalid_destination_table(scalars_df_index): with pytest.raises(ValueError): scalars_df_index.to_gbq("table_id") @@ -662,7 +940,8 @@ def test_to_gbq_w_json(bigquery_client): """Test the `to_gbq` API can get a JSON column.""" s1 = bpd.Series([1, 2, 3, 4]) s2 = bpd.Series( - ["a", 1, False, ["a", {"b": 1}], {"c": [1, 2, 3]}], dtype=db_dtypes.JSONDtype() + ['"a"', "1", "false", '["a", {"b": 1}]', '{"c": [1, 2, 3]}'], + dtype=dtypes.JSON_DTYPE, ) df = bpd.DataFrame({"id": s1, "json_col": s2}) @@ -673,6 +952,58 @@ def test_to_gbq_w_json(bigquery_client): assert table.schema[1].field_type == "JSON" +def test_to_gbq_with_timedelta(bigquery_client, dataset_id): + destination_table = f"{dataset_id}.test_to_gbq_with_timedelta" + s1 = bpd.Series([1, 2, 3, 4]) + s2 = bpd.to_timedelta(bpd.Series([1, 2, 3, 4]), unit="s") + df = bpd.DataFrame({"id": s1, "timedelta_col": s2}) + + df.to_gbq(destination_table) + table = bigquery_client.get_table(destination_table) + + assert table.schema[1].name == "timedelta_col" + assert table.schema[1].field_type == "INTEGER" + assert dtypes.TIMEDELTA_DESCRIPTION_TAG in table.schema[1].description + + +def test_gbq_round_trip_with_timedelta(session, dataset_id): + destination_table = f"{dataset_id}.test_gbq_roundtrip_with_timedelta" + df = pd.DataFrame( + { + "col_1": [1], + "col_2": [pd.Timedelta(1, "s")], + "col_3": [1.1], + } + ) + bpd.DataFrame(df).to_gbq(destination_table) + + result = session.read_gbq(destination_table) + + assert result["col_1"].dtype == dtypes.INT_DTYPE + assert result["col_2"].dtype == dtypes.TIMEDELTA_DTYPE + assert result["col_3"].dtype == dtypes.FLOAT_DTYPE + + +def test_to_gbq_timedelta_tag_ignored_when_appending(bigquery_client, dataset_id): + # First, create a table + destination_table = f"{dataset_id}.test_to_gbq_timedelta_tag_ignored_when_appending" + schema = [bigquery.SchemaField("my_col", "INTEGER")] + bigquery_client.create_table(bigquery.Table(destination_table, schema)) + + # Then, append to that table with timedelta values + df = pd.DataFrame( + { + "my_col": [pd.Timedelta(1, "s")], + } + ) + bpd.DataFrame(df).to_gbq(destination_table, if_exists="append") + + table = bigquery_client.get_table(destination_table) + assert table.schema[0].name == "my_col" + assert table.schema[0].field_type == "INTEGER" + assert table.schema[0].description is None + + @pytest.mark.parametrize( ("index"), [True, False], @@ -783,17 +1114,19 @@ def test_to_sql_query_unnamed_index_included( scalars_df_default_index: bpd.DataFrame, scalars_pandas_df_default_index: pd.DataFrame, ): - bf_df = scalars_df_default_index.reset_index(drop=True) + bf_df = scalars_df_default_index.reset_index(drop=True).drop(columns="duration_col") sql, idx_ids, idx_labels = bf_df._to_sql_query(include_index=True) assert len(idx_labels) == 1 assert len(idx_ids) == 1 assert idx_labels[0] is None assert idx_ids[0].startswith("bigframes") - pd_df = scalars_pandas_df_default_index.reset_index(drop=True) + pd_df = scalars_pandas_df_default_index.reset_index(drop=True).drop( + columns="duration_col" + ) roundtrip = session.read_gbq(sql, index_col=idx_ids) roundtrip.index.names = [None] - utils.assert_pandas_df_equal(roundtrip.to_pandas(), pd_df, check_index_type=False) + utils.assert_frame_equal(roundtrip.to_pandas(), pd_df, check_index_type=False) def test_to_sql_query_named_index_included( @@ -801,16 +1134,20 @@ def test_to_sql_query_named_index_included( scalars_df_default_index: bpd.DataFrame, scalars_pandas_df_default_index: pd.DataFrame, ): - bf_df = scalars_df_default_index.set_index("rowindex_2", drop=True) + bf_df = scalars_df_default_index.set_index("rowindex_2", drop=True).drop( + columns="duration_col" + ) sql, idx_ids, idx_labels = bf_df._to_sql_query(include_index=True) assert len(idx_labels) == 1 assert len(idx_ids) == 1 assert idx_labels[0] == "rowindex_2" assert idx_ids[0] == "rowindex_2" - pd_df = scalars_pandas_df_default_index.set_index("rowindex_2", drop=True) + pd_df = scalars_pandas_df_default_index.set_index("rowindex_2", drop=True).drop( + columns="duration_col" + ) roundtrip = session.read_gbq(sql, index_col=idx_ids) - utils.assert_pandas_df_equal(roundtrip.to_pandas(), pd_df) + utils.assert_frame_equal(roundtrip.to_pandas(), pd_df) def test_to_sql_query_unnamed_index_excluded( @@ -818,14 +1155,16 @@ def test_to_sql_query_unnamed_index_excluded( scalars_df_default_index: bpd.DataFrame, scalars_pandas_df_default_index: pd.DataFrame, ): - bf_df = scalars_df_default_index.reset_index(drop=True) + bf_df = scalars_df_default_index.reset_index(drop=True).drop(columns="duration_col") sql, idx_ids, idx_labels = bf_df._to_sql_query(include_index=False) assert len(idx_labels) == 0 assert len(idx_ids) == 0 - pd_df = scalars_pandas_df_default_index.reset_index(drop=True) + pd_df = scalars_pandas_df_default_index.reset_index(drop=True).drop( + columns="duration_col" + ) roundtrip = session.read_gbq(sql) - utils.assert_pandas_df_equal( + utils.assert_frame_equal( roundtrip.to_pandas(), pd_df, check_index_type=False, ignore_order=True ) @@ -835,15 +1174,28 @@ def test_to_sql_query_named_index_excluded( scalars_df_default_index: bpd.DataFrame, scalars_pandas_df_default_index: pd.DataFrame, ): - bf_df = scalars_df_default_index.set_index("rowindex_2", drop=True) + bf_df = scalars_df_default_index.set_index("rowindex_2", drop=True).drop( + columns="duration_col" + ) sql, idx_ids, idx_labels = bf_df._to_sql_query(include_index=False) assert len(idx_labels) == 0 assert len(idx_ids) == 0 - pd_df = scalars_pandas_df_default_index.set_index( - "rowindex_2", drop=True - ).reset_index(drop=True) + pd_df = ( + scalars_pandas_df_default_index.set_index("rowindex_2", drop=True) + .reset_index(drop=True) + .drop(columns="duration_col") + ) roundtrip = session.read_gbq(sql) - utils.assert_pandas_df_equal( + utils.assert_frame_equal( roundtrip.to_pandas(), pd_df, check_index_type=False, ignore_order=True ) + + +def test_to_numpy(scalars_dfs): + bf_df, pd_df = scalars_dfs + + bf_result = numpy.array(bf_df[["int64_too"]], dtype="int64") + pd_result = numpy.array(pd_df[["int64_too"]], dtype="int64") + + numpy.testing.assert_array_equal(bf_result, pd_result) diff --git a/tests/system/small/test_encryption.py b/tests/system/small/test_encryption.py index 7d684e64b4..1f30df451d 100644 --- a/tests/system/small/test_encryption.py +++ b/tests/system/small/test_encryption.py @@ -21,7 +21,7 @@ import bigframes import bigframes.ml.linear_model -from tests.system import utils +from bigframes.testing import utils @pytest.fixture(scope="module") @@ -41,6 +41,7 @@ def bq_cmek() -> str: @pytest.fixture(scope="module") def session_with_bq_cmek(bq_cmek) -> bigframes.Session: + # allow_large_results = False might not create table, and therefore no encryption config session = bigframes.Session(bigframes.BigQueryOptions(kms_key_name=bq_cmek)) return session @@ -52,7 +53,7 @@ def _assert_bq_table_is_encrypted( session: bigframes.Session, ): # Materialize the data in BQ - repr(df) + df.to_gbq() # The df should be backed by a query job with intended encryption on the result table assert df.query_job is not None @@ -69,7 +70,7 @@ def test_session_query_job(bq_cmek, session_with_bq_cmek): if not bq_cmek: # pragma: NO COVER pytest.skip("no cmek set for testing") # pragma: NO COVER - _, query_job = session_with_bq_cmek._loader._start_query( + query_job = session_with_bq_cmek._loader._start_query_with_job( "SELECT 123", job_config=bigquery.QueryJobConfig(use_query_cache=False) ) query_job.result() @@ -83,36 +84,6 @@ def test_session_query_job(bq_cmek, session_with_bq_cmek): assert table.encryption_configuration.kms_key_name == bq_cmek -def test_session_load_job(bq_cmek, session_with_bq_cmek): - if not bq_cmek: # pragma: NO COVER - pytest.skip("no cmek set for testing") # pragma: NO COVER - - # Session should have cmek set in the default query and load job configs - load_table = session_with_bq_cmek._temp_storage_manager._random_table() - - df = pandas.DataFrame({"col0": [1, 2, 3]}) - load_job_config = bigquery.LoadJobConfig() - load_job_config.schema = [ - bigquery.SchemaField(df.columns[0], bigquery.enums.SqlTypeNames.INT64) - ] - - load_job = session_with_bq_cmek.bqclient.load_table_from_dataframe( - df, - load_table, - job_config=load_job_config, - ) - load_job.result() - - assert load_job.destination == load_table - assert load_job.destination_encryption_configuration.kms_key_name.startswith( - bq_cmek - ) - - # The load destination table should be created with the intended encryption - table = session_with_bq_cmek.bqclient.get_table(load_job.destination) - assert table.encryption_configuration.kms_key_name == bq_cmek - - def test_read_gbq(bq_cmek, session_with_bq_cmek, scalars_table_id): if not bq_cmek: # pragma: NO COVER pytest.skip("no cmek set for testing") # pragma: NO COVER @@ -193,7 +164,7 @@ def test_to_gbq(bq_cmek, session_with_bq_cmek, scalars_table_id): # Write the result to BQ custom table and assert encryption session_with_bq_cmek.bqclient.get_table(output_table_id) - output_table_ref = session_with_bq_cmek._temp_storage_manager._random_table() + output_table_ref = session_with_bq_cmek._anon_dataset_manager.allocate_temp_table() output_table_id = str(output_table_ref) df.to_gbq(output_table_id) output_table = session_with_bq_cmek.bqclient.get_table(output_table_id) @@ -231,7 +202,7 @@ def test_read_pandas_large(bq_cmek, session_with_bq_cmek): _assert_bq_table_is_encrypted(df, bq_cmek, session_with_bq_cmek) -def test_bqml(bq_cmek, session_with_bq_cmek, penguins_table_id): +def test_kms_encryption_bqml(bq_cmek, session_with_bq_cmek, penguins_table_id): if not bq_cmek: # pragma: NO COVER pytest.skip("no cmek set for testing") # pragma: NO COVER diff --git a/tests/system/small/test_groupby.py b/tests/system/small/test_groupby.py index cbf6e1269d..579e7cd414 100644 --- a/tests/system/small/test_groupby.py +++ b/tests/system/small/test_groupby.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import numpy as np import pandas as pd import pytest import bigframes.pandas as bpd -from tests.system.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal # ================= # DataFrame.groupby @@ -60,6 +61,15 @@ def test_dataframe_groupby_head(scalars_df_index, scalars_pandas_df_index): pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) +def test_dataframe_groupby_len(scalars_df_index, scalars_pandas_df_index): + col_names = ["int64_too", "float64_col", "int64_col", "bool_col", "string_col"] + + bf_result = len(scalars_df_index[col_names].groupby("bool_col")) + pd_result = len(scalars_pandas_df_index[col_names].groupby("bool_col")) + + assert bf_result == pd_result + + def test_dataframe_groupby_median(scalars_df_index, scalars_pandas_df_index): col_names = ["int64_too", "float64_col", "int64_col", "bool_col", "string_col"] bf_result = ( @@ -94,6 +104,46 @@ def test_dataframe_groupby_quantile(scalars_df_index, scalars_pandas_df_index, q ) +@pytest.mark.parametrize( + ("na_option", "method", "ascending", "pct"), + [ + ( + "keep", + "average", + True, + False, + ), + ("top", "min", False, False), + ("bottom", "max", False, False), + ("top", "first", False, True), + ("bottom", "dense", False, True), + ], +) +def test_dataframe_groupby_rank( + scalars_df_index, scalars_pandas_df_index, na_option, method, ascending, pct +): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + col_names = ["int64_too", "float64_col", "int64_col", "string_col"] + bf_result = ( + scalars_df_index[col_names] + .groupby("string_col") + .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) + ).to_pandas() + pd_result = ( + ( + scalars_pandas_df_index[col_names] + .groupby("string_col") + .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) + ) + .astype("float64") + .astype("Float64") + ) + pd.testing.assert_frame_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + @pytest.mark.parametrize( ("operator"), [ @@ -120,6 +170,26 @@ def test_dataframe_groupby_aggregate( pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) +def test_dataframe_groupby_corr(scalars_df_index, scalars_pandas_df_index): + col_names = ["int64_too", "float64_col", "int64_col", "bool_col"] + bf_result = scalars_df_index[col_names].groupby("bool_col").corr().to_pandas() + pd_result = scalars_pandas_df_index[col_names].groupby("bool_col").corr() + + pd.testing.assert_frame_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + +def test_dataframe_groupby_cov(scalars_df_index, scalars_pandas_df_index): + col_names = ["int64_too", "float64_col", "int64_col", "bool_col"] + bf_result = scalars_df_index[col_names].groupby("bool_col").cov().to_pandas() + pd_result = scalars_pandas_df_index[col_names].groupby("bool_col").cov() + + pd.testing.assert_frame_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + @pytest.mark.parametrize( ("ordered"), [ @@ -135,7 +205,7 @@ def test_dataframe_groupby_agg_string( pd_result = scalars_pandas_df_index[col_names].groupby("string_col").agg("count") bf_result_computed = bf_result.to_pandas(ordered=ordered) - assert_pandas_df_equal( + assert_frame_equal( pd_result, bf_result_computed, check_dtype=False, ignore_order=not ordered ) @@ -151,16 +221,21 @@ def test_dataframe_groupby_agg_size_string(scalars_df_index, scalars_pandas_df_i def test_dataframe_groupby_agg_list(scalars_df_index, scalars_pandas_df_index): col_names = ["int64_too", "float64_col", "int64_col", "bool_col", "string_col"] bf_result = ( - scalars_df_index[col_names].groupby("string_col").agg(["count", "min", "size"]) + scalars_df_index[col_names].groupby("string_col").agg(["count", np.min, "size"]) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col") - .agg(["count", "min", "size"]) + .agg(["count", np.min, "size"]) ) bf_result_computed = bf_result.to_pandas() - pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) + # some inconsistency between versions, so normalize to bigframes behavior + pd_result = pd_result.rename({"amin": "min"}, axis="columns") + bf_result_computed = bf_result_computed.rename({"amin": "min"}, axis="columns") + pd.testing.assert_frame_equal( + pd_result, bf_result_computed, check_dtype=False, check_index_type=False + ) def test_dataframe_groupby_agg_list_w_column_multi_index( @@ -173,8 +248,8 @@ def test_dataframe_groupby_agg_list_w_column_multi_index( pd_df = scalars_pandas_df_index[columns].copy() pd_df.columns = multi_columns - bf_result = bf_df.groupby(level=0).agg(["count", "min", "size"]) - pd_result = pd_df.groupby(level=0).agg(["count", "min", "size"]) + bf_result = bf_df.groupby(level=0).agg(["count", np.min, "size"]) + pd_result = pd_df.groupby(level=0).agg(["count", np.min, "size"]) bf_result_computed = bf_result.to_pandas() pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) @@ -194,12 +269,16 @@ def test_dataframe_groupby_agg_dict_with_list( bf_result = ( scalars_df_index[col_names] .groupby("string_col", as_index=as_index) - .agg({"int64_too": ["mean", "max"], "string_col": "count", "bool_col": "size"}) + .agg( + {"int64_too": [np.mean, np.max], "string_col": "count", "bool_col": "size"} + ) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col", as_index=as_index) - .agg({"int64_too": ["mean", "max"], "string_col": "count", "bool_col": "size"}) + .agg( + {"int64_too": [np.mean, np.max], "string_col": "count", "bool_col": "size"} + ) ) bf_result_computed = bf_result.to_pandas() @@ -213,12 +292,12 @@ def test_dataframe_groupby_agg_dict_no_lists(scalars_df_index, scalars_pandas_df bf_result = ( scalars_df_index[col_names] .groupby("string_col") - .agg({"int64_too": "mean", "string_col": "count"}) + .agg({"int64_too": np.mean, "string_col": "count"}) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col") - .agg({"int64_too": "mean", "string_col": "count"}) + .agg({"int64_too": np.mean, "string_col": "count"}) ) bf_result_computed = bf_result.to_pandas() @@ -231,7 +310,7 @@ def test_dataframe_groupby_agg_named(scalars_df_index, scalars_pandas_df_index): scalars_df_index[col_names] .groupby("string_col") .agg( - agg1=bpd.NamedAgg("int64_too", "sum"), + agg1=bpd.NamedAgg("int64_too", np.sum), agg2=bpd.NamedAgg("float64_col", "max"), ) ) @@ -239,7 +318,8 @@ def test_dataframe_groupby_agg_named(scalars_df_index, scalars_pandas_df_index): scalars_pandas_df_index[col_names] .groupby("string_col") .agg( - agg1=pd.NamedAgg("int64_too", "sum"), agg2=pd.NamedAgg("float64_col", "max") + agg1=pd.NamedAgg("int64_too", np.sum), + agg2=pd.NamedAgg("float64_col", "max"), ) ) bf_result_computed = bf_result.to_pandas() @@ -253,14 +333,14 @@ def test_dataframe_groupby_agg_kw_tuples(scalars_df_index, scalars_pandas_df_ind scalars_df_index[col_names] .groupby("string_col") .agg( - agg1=("int64_too", "sum"), + agg1=("int64_too", np.sum), agg2=("float64_col", "max"), ) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col") - .agg(agg1=("int64_too", "sum"), agg2=("float64_col", "max")) + .agg(agg1=("int64_too", np.sum), agg2=("float64_col", "max")) ) bf_result_computed = bf_result.to_pandas() @@ -316,14 +396,14 @@ def test_dataframe_groupby_multi_sum( @pytest.mark.parametrize( - ("operator"), + ("operator", "dropna"), [ - (lambda x: x.cumsum(numeric_only=True)), - (lambda x: x.cummax(numeric_only=True)), - (lambda x: x.cummin(numeric_only=True)), + (lambda x: x.cumsum(numeric_only=True), True), + (lambda x: x.cummax(numeric_only=True), True), + (lambda x: x.cummin(numeric_only=True), False), # Pre-pandas 2.2 doesn't always proeduce float. - (lambda x: x.cumprod().astype("Float64")), - (lambda x: x.shift(periods=2)), + (lambda x: x.cumprod().astype("Float64"), False), + (lambda x: x.shift(periods=2), True), ], ids=[ "cumsum", @@ -334,16 +414,44 @@ def test_dataframe_groupby_multi_sum( ], ) def test_dataframe_groupby_analytic( - scalars_df_index, scalars_pandas_df_index, operator + scalars_df_index, + scalars_pandas_df_index, + operator, + dropna, ): col_names = ["float64_col", "int64_col", "bool_col", "string_col"] - bf_result = operator(scalars_df_index[col_names].groupby("string_col")) - pd_result = operator(scalars_pandas_df_index[col_names].groupby("string_col")) + bf_result = operator( + scalars_df_index[col_names].groupby("string_col", dropna=dropna) + ) + pd_result = operator( + scalars_pandas_df_index[col_names].groupby("string_col", dropna=dropna) + ) bf_result_computed = bf_result.to_pandas() pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) +@pytest.mark.parametrize( + ("ascending", "dropna"), + [ + (True, True), + (False, False), + ], +) +def test_dataframe_groupby_cumcount( + scalars_df_index, scalars_pandas_df_index, ascending, dropna +): + bf_result = scalars_df_index.groupby("string_col", dropna=dropna).cumcount( + ascending + ) + pd_result = scalars_pandas_df_index.groupby("string_col", dropna=dropna).cumcount( + ascending + ) + bf_result_computed = bf_result.to_pandas() + + pd.testing.assert_series_equal(pd_result, bf_result_computed, check_dtype=False) + + def test_dataframe_groupby_size_as_index_false( scalars_df_index, scalars_pandas_df_index ): @@ -401,7 +509,7 @@ def test_dataframe_groupby_diff(scalars_df_index, scalars_pandas_df_index, order pd_result = scalars_pandas_df_index[col_names].groupby("string_col").diff(-1) bf_result_computed = bf_result.to_pandas(ordered=ordered) - assert_pandas_df_equal( + assert_frame_equal( pd_result, bf_result_computed, check_dtype=False, ignore_order=not ordered ) @@ -480,19 +588,120 @@ def test_dataframe_groupby_nonnumeric_with_mean(): ) pd_result = df.groupby(["key1", "key2"]).mean() - with bpd.option_context("bigquery.location", "US"): - bf_result = bpd.DataFrame(df).groupby(["key1", "key2"]).mean().to_pandas() + bf_result = bpd.DataFrame(df).groupby(["key1", "key2"]).mean().to_pandas() pd.testing.assert_frame_equal( pd_result, bf_result, check_index_type=False, check_dtype=False ) +@pytest.mark.parametrize( + ("subset", "normalize", "ascending", "dropna", "as_index"), + [ + (None, True, True, True, True), + (["int64_too", "int64_col"], False, False, False, False), + ], +) +def test_dataframe_groupby_value_counts( + scalars_df_index, + scalars_pandas_df_index, + subset, + normalize, + ascending, + dropna, + as_index, +): + if pd.__version__.startswith("1."): + pytest.skip("pandas 1.x produces different column labels.") + col_names = ["float64_col", "int64_col", "bool_col", "int64_too"] + bf_result = ( + scalars_df_index[col_names] + .groupby("bool_col", as_index=as_index) + .value_counts( + subset=subset, normalize=normalize, ascending=ascending, dropna=dropna + ) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df_index[col_names] + .groupby("bool_col", as_index=as_index) + .value_counts( + subset=subset, normalize=normalize, ascending=ascending, dropna=dropna + ) + ) + + if as_index: + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + else: + pd_result.index = pd_result.index.astype("Int64") + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("numeric_only", "min_count"), + [ + (False, 4), + (True, 0), + ], +) +def test_dataframe_groupby_first( + scalars_df_index, scalars_pandas_df_index, numeric_only, min_count +): + # min_count seems to not work properly on older pandas + pytest.importorskip("pandas", minversion="2.0.0") + # bytes, dates not handling min_count properly in pandas + bf_result = ( + scalars_df_index.drop(columns=["bytes_col", "date_col"]) + .groupby(scalars_df_index.int64_col % 2) + .first(numeric_only=numeric_only, min_count=min_count) + ).to_pandas() + pd_result = ( + scalars_pandas_df_index.drop(columns=["bytes_col", "date_col"]) + .groupby(scalars_pandas_df_index.int64_col % 2) + .first(numeric_only=numeric_only, min_count=min_count) + ) + pd.testing.assert_frame_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ("numeric_only", "min_count"), + [ + (True, 2), + (False, -1), + ], +) +def test_dataframe_groupby_last( + scalars_df_index, scalars_pandas_df_index, numeric_only, min_count +): + bf_result = ( + scalars_df_index.groupby(scalars_df_index.int64_col % 2).last( + numeric_only=numeric_only, min_count=min_count + ) + ).to_pandas() + pd_result = scalars_pandas_df_index.groupby( + scalars_pandas_df_index.int64_col % 2 + ).last(numeric_only=numeric_only, min_count=min_count) + pd.testing.assert_frame_equal( + pd_result, + bf_result, + ) + + # ============== # Series.groupby # ============== +def test_series_groupby_len(scalars_df_index, scalars_pandas_df_index): + bf_result = len(scalars_df_index.groupby("bool_col")["int64_col"]) + pd_result = len(scalars_pandas_df_index.groupby("bool_col")["int64_col"]) + + assert bf_result == pd_result + + @pytest.mark.parametrize( ("agg"), [ @@ -520,12 +729,12 @@ def test_series_groupby_agg_list(scalars_df_index, scalars_pandas_df_index): bf_result = ( scalars_df_index["int64_col"] .groupby(scalars_df_index["string_col"]) - .agg(["sum", "mean", "size"]) + .agg(["sum", np.mean, "size"]) ) pd_result = ( scalars_pandas_df_index["int64_col"] .groupby(scalars_pandas_df_index["string_col"]) - .agg(["sum", "mean", "size"]) + .agg(["sum", np.mean, "size"]) ) bf_result_computed = bf_result.to_pandas() @@ -534,6 +743,61 @@ def test_series_groupby_agg_list(scalars_df_index, scalars_pandas_df_index): ) +@pytest.mark.parametrize( + ("na_option", "method", "ascending", "pct"), + [ + ("keep", "average", True, False), + ( + "top", + "min", + False, + True, + ), + ( + "bottom", + "max", + False, + True, + ), + ( + "top", + "first", + False, + True, + ), + ( + "bottom", + "dense", + False, + False, + ), + ], +) +def test_series_groupby_rank( + scalars_df_index, scalars_pandas_df_index, na_option, method, ascending, pct +): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + col_names = ["int64_col", "string_col"] + bf_result = ( + scalars_df_index[col_names] + .groupby("string_col")["int64_col"] + .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) + ).to_pandas() + pd_result = ( + ( + scalars_pandas_df_index[col_names] + .groupby("string_col")["int64_col"] + .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) + ) + .astype("float64") + .astype("Float64") + ) + pd.testing.assert_series_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + @pytest.mark.parametrize("dropna", [True, False]) def test_series_groupby_head(scalars_df_index, scalars_pandas_df_index, dropna): bf_result = ( @@ -607,3 +871,83 @@ def test_series_groupby_quantile(scalars_df_index, scalars_pandas_df_index, q): pd.testing.assert_series_equal( pd_result, bf_result, check_dtype=False, check_index_type=False ) + + +@pytest.mark.parametrize( + ("normalize", "ascending", "dropna"), + [ + ( + True, + True, + True, + ), + ( + False, + False, + False, + ), + ], +) +def test_series_groupby_value_counts( + scalars_df_index, + scalars_pandas_df_index, + normalize, + ascending, + dropna, +): + if pd.__version__.startswith("1."): + pytest.skip("pandas 1.x produces different column labels.") + bf_result = ( + scalars_df_index.groupby("bool_col")["string_col"] + .value_counts(normalize=normalize, ascending=ascending, dropna=dropna) + .to_pandas() + ) + pd_result = scalars_pandas_df_index.groupby("bool_col")["string_col"].value_counts( + normalize=normalize, ascending=ascending, dropna=dropna + ) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("numeric_only", "min_count"), + [ + (True, 2), + (False, -1), + ], +) +def test_series_groupby_first( + scalars_df_index, scalars_pandas_df_index, numeric_only, min_count +): + bf_result = ( + scalars_df_index.groupby("string_col")["int64_col"].first( + numeric_only=numeric_only, min_count=min_count + ) + ).to_pandas() + pd_result = scalars_pandas_df_index.groupby("string_col")["int64_col"].first( + numeric_only=numeric_only, min_count=min_count + ) + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ("numeric_only", "min_count"), + [ + (False, 4), + (True, 0), + ], +) +def test_series_groupby_last( + scalars_df_index, scalars_pandas_df_index, numeric_only, min_count +): + bf_result = ( + scalars_df_index.groupby("string_col")["int64_col"].last( + numeric_only=numeric_only, min_count=min_count + ) + ).to_pandas() + pd_result = scalars_pandas_df_index.groupby("string_col")["int64_col"].last( + numeric_only=numeric_only, min_count=min_count + ) + pd.testing.assert_series_equal(pd_result, bf_result) diff --git a/tests/system/small/test_index.py b/tests/system/small/test_index.py index 4d01bc5ee9..0ec1fb6143 100644 --- a/tests/system/small/test_index.py +++ b/tests/system/small/test_index.py @@ -12,22 +12,130 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re + import numpy import pandas as pd import pytest +from bigframes import dtypes import bigframes.pandas as bpd -from tests.system.utils import assert_pandas_index_equal_ignore_index_type +from bigframes.testing.utils import assert_pandas_index_equal_ignore_index_type def test_index_construct_from_list(): bf_result = bpd.Index( [3, 14, 159], dtype=pd.Int64Dtype(), name="my_index" ).to_pandas() + pd_result: pd.Index = pd.Index([3, 14, 159], dtype=pd.Int64Dtype(), name="my_index") pd.testing.assert_index_equal(bf_result, pd_result) +@pytest.mark.parametrize("key, expected_loc", [("a", 0), ("b", 1), ("c", 2)]) +def test_get_loc_should_return_int_for_unique_index(key, expected_loc): + """Behavior: get_loc on a unique index returns an integer position.""" + # The pandas result is used as the known-correct value. + # We assert our implementation matches it and the expected type. + bf_index = bpd.Index(["a", "b", "c"]) + + result = bf_index.get_loc(key) + + assert result == expected_loc + assert isinstance(result, int) + + +def test_get_loc_should_return_slice_for_monotonic_duplicates(): + """Behavior: get_loc on a monotonic string index with duplicates returns a slice.""" + bf_index = bpd.Index(["a", "b", "b", "c"]) + pd_index = pd.Index(["a", "b", "b", "c"]) + + bf_result = bf_index.get_loc("b") + pd_result = pd_index.get_loc("b") + + assert isinstance(bf_result, slice) + assert bf_result == pd_result # Should be slice(1, 3, None) + + +def test_get_loc_should_return_slice_for_monotonic_numeric_duplicates(): + """Behavior: get_loc on a monotonic numeric index with duplicates returns a slice.""" + bf_index = bpd.Index([1, 2, 2, 3]) + pd_index = pd.Index([1, 2, 2, 3]) + + bf_result = bf_index.get_loc(2) + pd_result = pd_index.get_loc(2) + + assert isinstance(bf_result, slice) + assert bf_result == pd_result # Should be slice(1, 3, None) + + +def test_get_loc_should_return_mask_for_non_monotonic_duplicates(): + """Behavior: get_loc on a non-monotonic string index returns a boolean array.""" + bf_index = bpd.Index(["a", "b", "c", "b"]) + pd_index = pd.Index(["a", "b", "c", "b"]) + + pd_result = pd_index.get_loc("b") + bf_result = bf_index.get_loc("b") + + assert not isinstance(bf_result, (int, slice)) + + if hasattr(bf_result, "to_numpy"): + bf_array = bf_result.to_numpy() + else: + bf_array = bf_result.to_pandas().to_numpy() + numpy.testing.assert_array_equal(bf_array, pd_result) + + +def test_get_loc_should_return_mask_for_non_monotonic_numeric_duplicates(): + """Behavior: get_loc on a non-monotonic numeric index returns a boolean array.""" + bf_index = bpd.Index([1, 2, 3, 2]) + pd_index = pd.Index([1, 2, 3, 2]) + + pd_result = pd_index.get_loc(2) + bf_result = bf_index.get_loc(2) + + assert not isinstance(bf_result, (int, slice)) + + if hasattr(bf_result, "to_numpy"): + bf_array = bf_result.to_numpy() + else: + bf_array = bf_result.to_pandas().to_numpy() + numpy.testing.assert_array_equal(bf_array, pd_result) + + +def test_get_loc_should_raise_error_for_missing_key(): + """Behavior: get_loc raises KeyError when a string key is not found.""" + bf_index = bpd.Index(["a", "b", "c"]) + + with pytest.raises(KeyError): + bf_index.get_loc("d") + + +def test_get_loc_should_raise_error_for_missing_numeric_key(): + """Behavior: get_loc raises KeyError when a numeric key is not found.""" + bf_index = bpd.Index([1, 2, 3]) + + with pytest.raises(KeyError): + bf_index.get_loc(4) + + +def test_get_loc_should_work_for_single_element_index(): + """Behavior: get_loc on a single-element index returns 0.""" + assert bpd.Index(["a"]).get_loc("a") == pd.Index(["a"]).get_loc("a") + + +def test_get_loc_should_return_slice_when_all_elements_are_duplicates(): + """Behavior: get_loc returns a full slice if all elements match the key.""" + bf_index = bpd.Index(["a", "a", "a"]) + pd_index = pd.Index(["a", "a", "a"]) + + bf_result = bf_index.get_loc("a") + pd_result = pd_index.get_loc("a") + + assert isinstance(bf_result, slice) + assert bf_result == pd_result # Should be slice(0, 3, None) + + def test_index_construct_from_series(): bf_result = bpd.Index( bpd.Series([3, 14, 159], dtype=pd.Float64Dtype(), name="series_name"), @@ -58,6 +166,26 @@ def test_index_construct_from_index(): pd.testing.assert_index_equal(bf_result, pd_result) +@pytest.mark.parametrize( + ("json_type"), + [ + pytest.param(dtypes.JSON_DTYPE), + pytest.param("json"), + ], +) +def test_index_construct_w_json_dtype(json_type): + data = [ + "1", + "false", + '["a", {"b": 1}, null]', + None, + ] + index = bpd.Index(data, dtype=json_type) + + assert index.dtype == dtypes.JSON_DTYPE + assert index[1] == "false" + + def test_get_index(scalars_df_index, scalars_pandas_df_index): index = scalars_df_index.index bf_result = index.to_pandas() @@ -374,7 +502,19 @@ def test_index_drop_duplicates(scalars_df_index, scalars_pandas_df_index, keep): ) -def test_index_isin(scalars_df_index, scalars_pandas_df_index): +@pytest.mark.parametrize( + ("key",), + [("hello",), (2,), (123123321,), (2.0,), (False,), ((2,),), (pd.NA,)], +) +def test_index_contains(scalars_df_index, scalars_pandas_df_index, key): + col_name = "int64_col" + bf_result = key in scalars_df_index.set_index(col_name).index + pd_result = key in scalars_pandas_df_index.set_index(col_name).index + + assert bf_result == pd_result + + +def test_index_isin_list(scalars_df_index, scalars_pandas_df_index): col_name = "int64_col" bf_series = ( scalars_df_index.set_index(col_name).index.isin([2, 55555, 4]).to_pandas() @@ -388,6 +528,38 @@ def test_index_isin(scalars_df_index, scalars_pandas_df_index): ) +def test_index_isin_bf_series(scalars_df_index, scalars_pandas_df_index, session): + col_name = "int64_col" + bf_series = ( + scalars_df_index.set_index(col_name) + .index.isin(bpd.Series([2, 55555, 4], session=session)) + .to_pandas() + ) + pd_result_array = scalars_pandas_df_index.set_index(col_name).index.isin( + [2, 55555, 4] + ) + pd.testing.assert_index_equal( + pd.Index(pd_result_array).set_names(col_name), + bf_series, + ) + + +def test_index_isin_bf_index(scalars_df_index, scalars_pandas_df_index, session): + col_name = "int64_col" + bf_series = ( + scalars_df_index.set_index(col_name) + .index.isin(bpd.Index([2, 55555, 4], session=session)) + .to_pandas() + ) + pd_result_array = scalars_pandas_df_index.set_index(col_name).index.isin( + [2, 55555, 4] + ) + pd.testing.assert_index_equal( + pd.Index(pd_result_array).set_names(col_name), + bf_series, + ) + + def test_multiindex_name_is_none(session): df = pd.DataFrame( { @@ -425,3 +597,127 @@ def test_multiindex_repr_includes_all_names(session): ) index = session.read_pandas(df).set_index(["A", "B"]).index assert "names=['A', 'B']" in repr(index) + + +def test_index_item(session): + # Test with a single item + bf_idx_single = bpd.Index([42], session=session) + pd_idx_single = pd.Index([42]) + assert bf_idx_single.item() == pd_idx_single.item() + + +def test_index_item_with_multiple(session): + # Test with multiple items + bf_idx_multiple = bpd.Index([1, 2, 3], session=session) + pd_idx_multiple = pd.Index([1, 2, 3]) + + try: + pd_idx_multiple.item() + except ValueError as e: + expected_message = str(e) + else: + raise AssertionError("Expected ValueError from pandas, but didn't get one") + + with pytest.raises(ValueError, match=re.escape(expected_message)): + bf_idx_multiple.item() + + +def test_index_item_with_empty(session): + # Test with an empty Index + bf_idx_empty = bpd.Index([], dtype="Int64", session=session) + pd_idx_empty: pd.Index = pd.Index([], dtype="Int64") + + try: + pd_idx_empty.item() + except ValueError as e: + expected_message = str(e) + else: + raise AssertionError("Expected ValueError from pandas, but didn't get one") + + with pytest.raises(ValueError, match=re.escape(expected_message)): + bf_idx_empty.item() + + +def test_index_to_list(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.index.to_list() + pd_result = scalars_pandas_df_index.index.to_list() + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("key", "value"), + [ + (0, "string_value"), + (1, 42), + ("label", None), + (-1, 3.14), + ], +) +def test_index_setitem_different_types(scalars_dfs, key, value): + """Tests that custom Index setitem raises TypeError.""" + scalars_df, _ = scalars_dfs + index = scalars_df.index + + with pytest.raises(TypeError, match="Index does not support mutable operations"): + index[key] = value + + +def test_custom_index_setitem_error(): + """Tests that custom Index setitem raises TypeError.""" + custom_index = bpd.Index([1, 2, 3, 4, 5], name="custom") + + with pytest.raises(TypeError, match="Index does not support mutable operations"): + custom_index[2] = 999 + + +def test_index_eq_const(scalars_df_index, scalars_pandas_df_index): + bf_result = (scalars_df_index.index == 3).to_pandas() + pd_result = scalars_pandas_df_index.index == 3 + assert bf_result == pd.Index(pd_result) + + +def test_index_eq_aligned_index(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + bpd.Index(scalars_df_index.int64_col) + == bpd.Index(scalars_df_index.int64_col.abs()) + ).to_pandas() + pd_result = pd.Index(scalars_pandas_df_index.int64_col) == pd.Index( + scalars_pandas_df_index.int64_col.abs() + ) + assert bf_result == pd.Index(pd_result) + + +def test_index_str_accessor_unary(scalars_df_index, scalars_pandas_df_index): + bf_index = scalars_df_index.set_index("string_col").index + pd_index = scalars_pandas_df_index.set_index("string_col").index + + bf_result = bf_index.str.pad(30, side="both", fillchar="~").to_pandas() + pd_result = pd_index.str.pad(30, side="both", fillchar="~") + + pd.testing.assert_index_equal(bf_result, pd_result) + + +def test_index_str_accessor_binary(scalars_df_index, scalars_pandas_df_index): + if pd.__version__.startswith("1."): + pytest.skip("doesn't work in pandas 1.x.") + bf_index = scalars_df_index.set_index("string_col").index + pd_index = scalars_pandas_df_index.set_index("string_col").index + + bf_result = bf_index.str.cat(bf_index.str[:4]).to_pandas() + pd_result = pd_index.str.cat(pd_index.str[:4]) + + pd.testing.assert_index_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("pat"), + [(r"(ell)(lo)"), (r"(?Ph..)"), (r"(?Pe.*o)([g-l]+)")], +) +def test_index_str_extract(scalars_df_index, scalars_pandas_df_index, pat): + bf_index = scalars_df_index.set_index("string_col").index + pd_index = scalars_pandas_df_index.set_index("string_col").index + + bf_result = bf_index.str.extract(pat).to_pandas() + pd_result = pd_index.str.extract(pat) + + pd.testing.assert_frame_equal(pd_result, bf_result, check_index_type=False) diff --git a/tests/system/small/test_index_io.py b/tests/system/small/test_index_io.py new file mode 100644 index 0000000000..306b15e67a --- /dev/null +++ b/tests/system/small/test_index_io.py @@ -0,0 +1,58 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pandas as pd + +import bigframes + + +def test_to_pandas_override_global_option(scalars_df_index): + with bigframes.option_context("compute.allow_large_results", True): + + bf_index = scalars_df_index.index + + # Direct call to_pandas uses global default setting (allow_large_results=True), + bf_index.to_pandas() + table_id = bf_index._query_job.destination.table_id + assert table_id is not None + + # When allow_large_results=False, a query_job object should not be created. + # Therefore, the table_id should remain unchanged. + bf_index.to_pandas(allow_large_results=False) + assert bf_index._query_job.destination.table_id == table_id + + +def test_to_pandas_dry_run(scalars_df_index): + index = scalars_df_index.index + + result = index.to_pandas(dry_run=True) + + assert isinstance(result, pd.Series) + assert len(result) > 0 + + +def test_to_numpy_override_global_option(scalars_df_index): + with bigframes.option_context("compute.allow_large_results", True): + + bf_index = scalars_df_index.index + + # Direct call to_numpy uses global default setting (allow_large_results=True), + # table has 'bqdf' prefix. + bf_index.to_numpy() + table_id = bf_index._query_job.destination.table_id + assert table_id is not None + + # When allow_large_results=False, a query_job object should not be created. + # Therefore, the table_id should remain unchanged. + bf_index.to_numpy(allow_large_results=False) + assert bf_index._query_job.destination.table_id == table_id diff --git a/tests/system/small/test_ipython.py b/tests/system/small/test_ipython.py index be98ce0067..2d23390718 100644 --- a/tests/system/small/test_ipython.py +++ b/tests/system/small/test_ipython.py @@ -26,4 +26,4 @@ def test_repr_cache(scalars_df_index): results = display_formatter.format(test_df) assert results[0].keys() == {"text/plain", "text/html"} assert test_df._block.retrieve_repr_request_results.cache_info().misses >= 1 - assert test_df._block.retrieve_repr_request_results.cache_info().hits >= 1 + assert test_df._block.retrieve_repr_request_results.cache_info().hits == 0 diff --git a/tests/system/small/test_large_local_data.py b/tests/system/small/test_large_local_data.py new file mode 100644 index 0000000000..39885ea853 --- /dev/null +++ b/tests/system/small/test_large_local_data.py @@ -0,0 +1,55 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import numpy as np +import pandas as pd +import pytest + +import bigframes +from bigframes.testing.utils import assert_frame_equal + +large_dataframe = pd.DataFrame(np.random.rand(10000, 10), dtype="Float64") +large_dataframe.index = large_dataframe.index.astype("Int64") + + +def test_read_pandas_defer_noop(session: bigframes.Session): + pytest.importorskip("pandas", minversion="2.0.0") + bf_df = session.read_pandas(large_dataframe, write_engine="_deferred") + + assert_frame_equal(large_dataframe, bf_df.to_pandas()) + + +def test_read_pandas_defer_cumsum(session: bigframes.Session): + pytest.importorskip("pandas", minversion="2.0.0") + bf_df = session.read_pandas(large_dataframe, write_engine="_deferred") + bf_df = bf_df.cumsum() + + assert_frame_equal(large_dataframe.cumsum(), bf_df.to_pandas()) + + +def test_read_pandas_defer_cache_cumsum_cumsum(session: bigframes.Session): + pytest.importorskip("pandas", minversion="2.0.0") + bf_df = session.read_pandas(large_dataframe, write_engine="_deferred") + bf_df = bf_df.cumsum().cache().cumsum() + + assert_frame_equal(large_dataframe.cumsum().cumsum(), bf_df.to_pandas()) + + +def test_read_pandas_defer_peek(session: bigframes.Session): + pytest.importorskip("pandas", minversion="2.0.0") + bf_df = session.read_pandas(large_dataframe, write_engine="_deferred") + bf_result = bf_df.peek(15) + + assert len(bf_result) == 15 + assert_frame_equal(large_dataframe.loc[bf_result.index], bf_result) diff --git a/tests/system/small/test_multiindex.py b/tests/system/small/test_multiindex.py index 1c78ac63d9..a28e02a54f 100644 --- a/tests/system/small/test_multiindex.py +++ b/tests/system/small/test_multiindex.py @@ -17,7 +17,23 @@ import pytest import bigframes.pandas as bpd -from tests.system.utils import assert_pandas_df_equal, skip_legacy_pandas +from bigframes.testing.utils import assert_frame_equal + +# Sample MultiIndex for testing DataFrames where() method. +_MULTI_INDEX = pandas.MultiIndex.from_tuples( + [ + (0, "a"), + (1, "b"), + (2, "c"), + (0, "d"), + (1, "e"), + (2, "f"), + (0, "g"), + (1, "h"), + (2, "i"), + ], + names=["A", "B"], +) def test_multi_index_from_arrays(): @@ -45,8 +61,9 @@ def test_multi_index_from_arrays(): pandas.testing.assert_index_equal(bf_idx.to_pandas(), pd_idx) -@skip_legacy_pandas def test_read_pandas_multi_index_axes(): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") index = pandas.MultiIndex.from_arrays( [ pandas.Index([4, 99], dtype=pandas.Int64Dtype()), @@ -84,20 +101,70 @@ def test_set_multi_index(scalars_df_index, scalars_pandas_df_index): pandas.testing.assert_frame_equal(bf_result, pd_result) -def test_reset_multi_index(scalars_df_index, scalars_pandas_df_index): +@pytest.mark.parametrize( + ("level", "drop"), + [ + (None, True), + (None, False), + (1, True), + ("bool_col", True), + (["float64_col", "int64_too"], True), + ([2, 0], False), + (0, True), + ], +) +def test_df_reset_multi_index(scalars_df_index, scalars_pandas_df_index, level, drop): bf_result = ( - scalars_df_index.set_index(["bool_col", "int64_too"]).reset_index().to_pandas() + scalars_df_index.set_index(["bool_col", "int64_too", "float64_col"]) + .reset_index(level=level, drop=drop) + .to_pandas() ) pd_result = scalars_pandas_df_index.set_index( - ["bool_col", "int64_too"] - ).reset_index() + ["bool_col", "int64_too", "float64_col"] + ).reset_index(level=level, drop=drop) # Pandas uses int64 instead of Int64 (nullable) dtype. - pd_result.index = pd_result.index.astype(pandas.Int64Dtype()) + if pd_result.index.dtype != bf_result.index.dtype: + pd_result.index = pd_result.index.astype(bf_result.index.dtype) pandas.testing.assert_frame_equal(bf_result, pd_result) +@pytest.mark.parametrize( + ("level", "drop"), + [ + (None, True), + (None, False), + (1, True), + ("bool_col", True), + (["float64_col", "int64_too"], True), + ([2, 0], False), + ], +) +def test_series_reset_multi_index( + scalars_df_index, scalars_pandas_df_index, level, drop +): + bf_result = ( + scalars_df_index.set_index(["bool_col", "int64_too", "float64_col"])[ + "string_col" + ] + .reset_index(level=level, drop=drop) + .to_pandas() + ) + pd_result = scalars_pandas_df_index.set_index( + ["bool_col", "int64_too", "float64_col"] + )["string_col"].reset_index(level=level, drop=drop) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + if pd_result.index.dtype != bf_result.index.dtype: + pd_result.index = pd_result.index.astype(pandas.Int64Dtype()) + + if drop: + pandas.testing.assert_series_equal(bf_result, pd_result) + else: + pandas.testing.assert_frame_equal(bf_result, pd_result) + + def test_series_multi_index_idxmin(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.set_index(["bool_col", "int64_too"])[ "float64_col" @@ -516,7 +583,7 @@ def test_multi_index_dataframe_join(scalars_dfs, how): (["bool_col", "rowindex_2"]) )[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -537,7 +604,141 @@ def test_multi_index_dataframe_join_on(scalars_dfs, how): pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) pd_df_b = pd_df[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, on="rowindex_2", how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) + + +def test_multi_index_dataframe_where_series_cond_none_other( + scalars_df_index, scalars_pandas_df_index +): + columns = ["int64_col", "float64_col"] + + # Create multi-index dataframe. + dataframe_bf = bpd.DataFrame( + scalars_df_index[columns].values, + index=_MULTI_INDEX, + columns=scalars_df_index[columns].columns, + ) + dataframe_pd = pandas.DataFrame( + scalars_pandas_df_index[columns].values, + index=_MULTI_INDEX, + columns=scalars_pandas_df_index[columns].columns, + ) + dataframe_bf.columns.name = "test_name" + dataframe_pd.columns.name = "test_name" + + # When condition is series and other is None. + series_cond_bf = dataframe_bf["int64_col"] > 0 + series_cond_pd = dataframe_pd["int64_col"] > 0 + + bf_result = dataframe_bf.where(series_cond_bf).to_pandas() + pd_result = dataframe_pd.where(series_cond_pd) + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + check_index_type=False, + check_dtype=False, + ) + # Assert the index is still MultiIndex after the operation. + assert isinstance(bf_result.index, pandas.MultiIndex), "Expected a MultiIndex" + assert isinstance(pd_result.index, pandas.MultiIndex), "Expected a MultiIndex" + + +def test_multi_index_dataframe_where_series_cond_dataframe_other( + scalars_df_index, scalars_pandas_df_index +): + columns = ["int64_col", "int64_too"] + + # Create multi-index dataframe. + dataframe_bf = bpd.DataFrame( + scalars_df_index[columns].values, + index=_MULTI_INDEX, + columns=scalars_df_index[columns].columns, + ) + dataframe_pd = pandas.DataFrame( + scalars_pandas_df_index[columns].values, + index=_MULTI_INDEX, + columns=scalars_pandas_df_index[columns].columns, + ) + + # When condition is series and other is dataframe. + series_cond_bf = dataframe_bf["int64_col"] > 1000.0 + series_cond_pd = dataframe_pd["int64_col"] > 1000.0 + dataframe_other_bf = dataframe_bf * 100.0 + dataframe_other_pd = dataframe_pd * 100.0 + + bf_result = dataframe_bf.where(series_cond_bf, dataframe_other_bf).to_pandas() + pd_result = dataframe_pd.where(series_cond_pd, dataframe_other_pd) + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + check_index_type=False, + check_dtype=False, + ) + + +def test_multi_index_dataframe_where_dataframe_cond_constant_other( + scalars_df_index, scalars_pandas_df_index +): + columns = ["int64_col", "float64_col"] + + # Create multi-index dataframe. + dataframe_bf = bpd.DataFrame( + scalars_df_index[columns].values, + index=_MULTI_INDEX, + columns=scalars_df_index[columns].columns, + ) + dataframe_pd = pandas.DataFrame( + scalars_pandas_df_index[columns].values, + index=_MULTI_INDEX, + columns=scalars_pandas_df_index[columns].columns, + ) + + # When condition is dataframe and other is a constant. + dataframe_cond_bf = dataframe_bf > 0 + dataframe_cond_pd = dataframe_pd > 0 + other = 0 + + bf_result = dataframe_bf.where(dataframe_cond_bf, other).to_pandas() + pd_result = dataframe_pd.where(dataframe_cond_pd, other) + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + check_index_type=False, + check_dtype=False, + ) + + +def test_multi_index_dataframe_where_dataframe_cond_dataframe_other( + scalars_df_index, scalars_pandas_df_index +): + columns = ["int64_col", "int64_too", "float64_col"] + + # Create multi-index dataframe. + dataframe_bf = bpd.DataFrame( + scalars_df_index[columns].values, + index=_MULTI_INDEX, + columns=scalars_df_index[columns].columns, + ) + dataframe_pd = pandas.DataFrame( + scalars_pandas_df_index[columns].values, + index=_MULTI_INDEX, + columns=scalars_pandas_df_index[columns].columns, + ) + + # When condition is dataframe and other is dataframe. + dataframe_cond_bf = dataframe_bf < 1000.0 + dataframe_cond_pd = dataframe_pd < 1000.0 + dataframe_other_bf = dataframe_bf * -1.0 + dataframe_other_pd = dataframe_pd * -1.0 + + bf_result = dataframe_bf.where(dataframe_cond_bf, dataframe_other_bf).to_pandas() + pd_result = dataframe_pd.where(dataframe_cond_pd, dataframe_other_pd) + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + check_index_type=False, + check_dtype=False, + ) @pytest.mark.parametrize( @@ -729,16 +930,30 @@ def test_column_multi_index_rename(scalars_df_index, scalars_pandas_df_index): pandas.testing.assert_frame_equal(bf_result, pd_result) -def test_column_multi_index_reset_index(scalars_df_index, scalars_pandas_df_index): +@pytest.mark.parametrize( + ("names", "col_fill", "col_level"), + [ + (None, "", "l2"), + (("new_name"), "fill", 1), + ("new_name", "fill", 0), + ], +) +def test_column_multi_index_reset_index( + scalars_df_index, scalars_pandas_df_index, names, col_fill, col_level +): columns = ["int64_too", "int64_col", "float64_col"] - multi_columns = pandas.MultiIndex.from_tuples(zip(["a", "b", "a"], ["a", "b", "b"])) + multi_columns = pandas.MultiIndex.from_tuples( + zip(["a", "b", "a"], ["a", "b", "b"]), names=["l1", "l2"] + ) bf_df = scalars_df_index[columns].copy() bf_df.columns = multi_columns pd_df = scalars_pandas_df_index[columns].copy() pd_df.columns = multi_columns - bf_result = bf_df.reset_index().to_pandas() - pd_result = pd_df.reset_index() + bf_result = bf_df.reset_index( + names=names, col_fill=col_fill, col_level=col_level + ).to_pandas() + pd_result = pd_df.reset_index(names=names, col_fill=col_fill, col_level=col_level) # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pandas.Int64Dtype()) @@ -759,8 +974,9 @@ def test_column_multi_index_binary_op(scalars_df_index, scalars_pandas_df_index) pandas.testing.assert_series_equal(bf_result, pd_result) -@skip_legacy_pandas def test_column_multi_index_any(): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") columns = pandas.MultiIndex.from_tuples( [("col0", "col00"), ("col0", "col00"), ("col1", "col11")] ) @@ -1236,3 +1452,36 @@ def test_column_multi_index_w_na_stack(scalars_df_index, scalars_pandas_df_index # Pandas produces pd.NA, where bq dataframes produces NaN pd_result["c"] = pd_result["c"].replace(pandas.NA, np.nan) pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("key",), + [ + ("hello",), + (2,), + (123123321,), + (2.0,), + (pandas.NA,), + (False,), + ((2,),), + ((2, False),), + ((2.0, False),), + ((2, True),), + ], +) +def test_multi_index_contains(scalars_df_index, scalars_pandas_df_index, key): + col_name = ["int64_col", "bool_col"] + bf_result = key in scalars_df_index.set_index(col_name).index + pd_result = key in scalars_pandas_df_index.set_index(col_name).index + + assert bf_result == pd_result + + +def test_multiindex_eq_const(scalars_df_index, scalars_pandas_df_index): + col_name = ["int64_col", "bool_col"] + bf_result = scalars_df_index.set_index(col_name).index == (2, False) + pd_result = scalars_pandas_df_index.set_index(col_name).index == (2, False) + + pandas.testing.assert_index_equal( + pandas.Index(pd_result, dtype="boolean"), bf_result.to_pandas() + ) diff --git a/tests/system/small/test_null_index.py b/tests/system/small/test_null_index.py index 6da4c6ff9c..4aa7ba8c77 100644 --- a/tests/system/small/test_null_index.py +++ b/tests/system/small/test_null_index.py @@ -13,12 +13,13 @@ # limitations under the License. +import io + import pandas as pd import pytest import bigframes.exceptions import bigframes.pandas as bpd -from tests.system.utils import skip_legacy_pandas def test_null_index_to_gbq(session, scalars_df_null_index, dataset_id_not_created): @@ -45,6 +46,38 @@ def test_null_index_materialize(scalars_df_null_index, scalars_pandas_df_default ) +def test_null_index_info(scalars_df_null_index): + expected = ( + "\n" + "NullIndex\n" + "Data columns (total 14 columns):\n" + " # Column Non-Null Count Dtype\n" + "--- ------------- ---------------- ------------------------------\n" + " 0 bool_col 8 non-null boolean\n" + " 1 bytes_col 6 non-null binary[pyarrow]\n" + " 2 date_col 7 non-null date32[day][pyarrow]\n" + " 3 datetime_col 6 non-null timestamp[us][pyarrow]\n" + " 4 geography_col 4 non-null geometry\n" + " 5 int64_col 8 non-null Int64\n" + " 6 int64_too 9 non-null Int64\n" + " 7 numeric_col 6 non-null decimal128(38, 9)[pyarrow]\n" + " 8 float64_col 7 non-null Float64\n" + " 9 rowindex_2 9 non-null Int64\n" + " 10 string_col 8 non-null string\n" + " 11 time_col 6 non-null time64[us][pyarrow]\n" + " 12 timestamp_col 6 non-null timestamp[us, tz=UTC][pyarrow]\n" + " 13 duration_col 7 non-null duration[us][pyarrow]\n" + "dtypes: Float64(1), Int64(3), binary[pyarrow](1), boolean(1), date32[day][pyarrow](1), decimal128(38, 9)[pyarrow](1), duration[us][pyarrow](1), geometry(1), string(1), time64[us][pyarrow](1), timestamp[us, tz=UTC][pyarrow](1), timestamp[us][pyarrow](1)\n" + "memory usage: 1269 bytes\n" + ) + + bf_result = io.StringIO() + + scalars_df_null_index.drop(columns="rowindex").info(buf=bf_result) + + assert expected == bf_result.getvalue() + + def test_null_index_series_repr(scalars_df_null_index, scalars_pandas_df_default_index): bf_result = scalars_df_null_index["int64_too"].head(5).__repr__() pd_result = ( @@ -126,8 +159,9 @@ def test_null_index_groupby_aggregate( pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) -@skip_legacy_pandas def test_null_index_analytic(scalars_df_null_index, scalars_pandas_df_default_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") bf_result = scalars_df_null_index["int64_col"].cumsum().to_pandas() pd_result = scalars_pandas_df_default_index["int64_col"].cumsum() pd.testing.assert_series_equal( @@ -173,7 +207,6 @@ def test_null_index_merge_left_null_index_object( assert got.shape == expected.shape -@skip_legacy_pandas @pytest.mark.parametrize( ("expr",), [ @@ -185,6 +218,8 @@ def test_null_index_merge_left_null_index_object( def test_null_index_df_eval( scalars_df_null_index, scalars_pandas_df_default_index, expr ): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") bf_result = scalars_df_null_index.eval(expr).to_pandas() pd_result = scalars_pandas_df_default_index.eval(expr) @@ -237,8 +272,9 @@ def test_null_index_merge_two_null_index_objects( assert got.shape == expected.shape -@skip_legacy_pandas def test_null_index_stack(scalars_df_null_index, scalars_pandas_df_default_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") stacking_cols = ["int64_col", "int64_too"] bf_result = scalars_df_null_index[stacking_cols].stack().to_pandas() pd_result = ( @@ -394,3 +430,7 @@ def test_null_index_index_property(scalars_df_null_index): def test_null_index_transpose(scalars_df_null_index): with pytest.raises(bigframes.exceptions.NullIndexError): _ = scalars_df_null_index.T + + +def test_null_index_contains(scalars_df_null_index): + assert 3 not in scalars_df_null_index diff --git a/tests/system/small/test_numpy.py b/tests/system/small/test_numpy.py index 37a707b9d0..490f927114 100644 --- a/tests/system/small/test_numpy.py +++ b/tests/system/small/test_numpy.py @@ -37,6 +37,8 @@ ("log10",), ("sqrt",), ("abs",), + ("isnan",), + ("isfinite",), ], ) def test_series_ufuncs(floats_pd, floats_bf, opname): diff --git a/tests/system/small/test_pandas.py b/tests/system/small/test_pandas.py index da78432cdb..a1c0dc9851 100644 --- a/tests/system/small/test_pandas.py +++ b/tests/system/small/test_pandas.py @@ -16,11 +16,12 @@ import typing import pandas as pd +import pyarrow as pa import pytest import pytz import bigframes.pandas as bpd -from tests.system.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal @pytest.mark.parametrize( @@ -36,7 +37,17 @@ def test_concat_dataframe(scalars_dfs, ordered): bf_result = bf_result.to_pandas(ordered=ordered) pd_result = pd.concat(11 * [scalars_pandas_df]) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) + + +def test_concat_dataframe_w_struct_cols(nested_structs_df, nested_structs_pandas_df): + """Avoid regressions for internal issue 407107482""" + empty_bf_df = bpd.DataFrame(session=nested_structs_df._block.session) + bf_result = bpd.concat((empty_bf_df, nested_structs_df), ignore_index=True) + bf_result = bf_result.to_pandas() + pd_result = pd.concat((pd.DataFrame(), nested_structs_pandas_df), ignore_index=True) + pd_result.index = pd_result.index.astype("Int64") + pd.testing.assert_frame_equal(bf_result, pd_result) def test_concat_series(scalars_dfs): @@ -295,7 +306,7 @@ def test_merge(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @pytest.mark.parametrize( @@ -329,10 +340,10 @@ def test_merge_left_on_right_on(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) -def test_pd_merge_cross(scalars_dfs): +def test_merge_cross(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs left_columns = ["int64_col", "float64_col", "int64_too"] right_columns = ["int64_col", "bool_col", "string_col", "rowindex_2"] @@ -384,128 +395,363 @@ def test_merge_series(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) -def test_cut(scalars_dfs): +def test_merge_w_common_columns(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs + left_columns = ["int64_col", "int64_too"] + right_columns = ["int64_col", "bool_col"] - pd_result = pd.cut(scalars_pandas_df["float64_col"], 5, labels=False) - bf_result = bpd.cut(scalars_df["float64_col"], 5, labels=False) + df = bpd.merge( + scalars_df[left_columns], scalars_df[right_columns], "inner", sort=True + ) - # make sure the result is a supported dtype - assert bf_result.dtype == bpd.Int64Dtype() - pd_result = pd_result.astype("Int64") - pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + pd_result = pd.merge( + scalars_pandas_df[left_columns], + scalars_pandas_df[right_columns], + "inner", + sort=True, + ) + assert_frame_equal(df.to_pandas(), pd_result, ignore_order=True) + + +def test_merge_raises_error_when_no_common_columns(scalars_dfs): + scalars_df, _ = scalars_dfs + left_columns = ["float64_col", "int64_too"] + right_columns = ["int64_col", "bool_col"] + + left = scalars_df[left_columns] + right = scalars_df[right_columns] + + with pytest.raises( + ValueError, + match="No common columns to perform merge on.", + ): + bpd.merge(left, right, "inner") + + +def test_merge_raises_error_when_left_right_on_set(scalars_dfs): + scalars_df, _ = scalars_dfs + left_columns = ["int64_col", "int64_too"] + right_columns = ["int64_col", "bool_col"] + + left = scalars_df[left_columns] + right = scalars_df[right_columns] + + with pytest.raises(ValueError): + bpd.merge( + left, + right, + "inner", + left_on="int64_too", + right_on="int64_col", + on="int64_col", + ) -def test_cut_default_labels(scalars_dfs): +def test_crosstab_aligned_series(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs - pd_result = pd.cut(scalars_pandas_df["float64_col"], 5) - bf_result = bpd.cut(scalars_df["float64_col"], 5).to_pandas() + pd_result = pd.crosstab( + scalars_pandas_df["int64_col"], scalars_pandas_df["int64_too"] + ) + bf_result = bpd.crosstab( + scalars_df["int64_col"], scalars_df["int64_too"] + ).to_pandas() - # Convert to match data format - pd_result_converted = pd.Series( - [ - {"left_exclusive": interval.left, "right_inclusive": interval.right} + assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +def test_crosstab_nondefault_func(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = pd.crosstab( + scalars_pandas_df["int64_col"], + scalars_pandas_df["int64_too"], + values=scalars_pandas_df["float64_col"], + aggfunc="mean", + ) + bf_result = bpd.crosstab( + scalars_df["int64_col"], + scalars_df["int64_too"], + values=scalars_df["float64_col"], + aggfunc="mean", + ).to_pandas() + + assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +def test_crosstab_multi_cols(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = pd.crosstab( + [scalars_pandas_df["int64_col"], scalars_pandas_df["bool_col"]], + [scalars_pandas_df["int64_too"], scalars_pandas_df["string_col"]], + rownames=["a", "b"], + colnames=["c", "d"], + ) + bf_result = bpd.crosstab( + [scalars_df["int64_col"], scalars_df["bool_col"]], + [scalars_df["int64_too"], scalars_df["string_col"]], + rownames=["a", "b"], + colnames=["c", "d"], + ).to_pandas() + + assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +def test_crosstab_unaligned_series(scalars_dfs, session): + scalars_df, scalars_pandas_df = scalars_dfs + other_pd_series = pd.Series( + [10, 20, 10, 30, 10], index=[5, 4, 1, 2, 3], dtype="Int64", name="nums" + ) + other_bf_series = session.Series( + [10, 20, 10, 30, 10], index=[5, 4, 1, 2, 3], name="nums" + ) + + pd_result = pd.crosstab(scalars_pandas_df["int64_col"], other_pd_series) + bf_result = bpd.crosstab(scalars_df["int64_col"], other_bf_series).to_pandas() + + assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +def _convert_pandas_category(pd_s: pd.Series): + """ + Transforms a pandas Series with Categorical dtype into a bigframes-compatible + Series representing intervals." + """ + # When `labels=False` + if pd.api.types.is_integer_dtype(pd_s.dtype) or pd.api.types.is_float_dtype( + pd_s.dtype + ): + return pd_s.astype("Int64") + + if not isinstance(pd_s.dtype, pd.CategoricalDtype): + raise ValueError( + f"Input must be a pandas Series with categorical data: {pd_s.dtype}" + ) + + if pd.api.types.is_object_dtype(pd_s.cat.categories.dtype): + return pd_s.astype(pd.StringDtype(storage="pyarrow")) + + if not isinstance(pd_s.cat.categories.dtype, pd.IntervalDtype): + raise ValueError( + f"Must be a IntervalDtype with categorical data: {pd_s.cat.categories.dtype}" + ) + + if pd_s.cat.categories.dtype.closed == "left": # type: ignore + left_key = "left_inclusive" + right_key = "right_exclusive" + else: + left_key = "left_exclusive" + right_key = "right_inclusive" + + subtype = pd_s.cat.categories.dtype.subtype # type: ignore + if pd.api.types.is_float_dtype(subtype): + interval_dtype = pa.float64() + elif pd.api.types.is_integer_dtype(subtype): + interval_dtype = pa.int64() + else: + raise ValueError(f"Unknown category type: {subtype}") + + dtype = pd.ArrowDtype( + pa.struct( + [ + pa.field(left_key, interval_dtype, nullable=True), + pa.field(right_key, interval_dtype, nullable=True), + ] + ) + ) + + if len(pd_s.dtype.categories) == 0: + data = [pd.NA] * len(pd_s) + else: + data = [ + {left_key: interval.left, right_key: interval.right} # type: ignore if pd.notna(val) else pd.NA - for val, interval in zip( - pd_result, pd_result.cat.categories[pd_result.cat.codes] - ) - ], - name=pd_result.name, - ) + for val, interval in zip(pd_s, pd_s.cat.categories[pd_s.cat.codes]) # type: ignore + ] - pd.testing.assert_series_equal( - bf_result, pd_result_converted, check_index=False, check_dtype=False + return pd.Series( + data=data, + name=pd_s.name, + dtype=dtype, + index=pd_s.index.astype("Int64"), ) +def test_cut_for_array(): + """Avoid regressions for internal issue 329866195""" + sc = [30, 80, 40, 90, 60, 45, 95, 75, 55, 100, 65, 85] + x = [20, 40, 60, 80, 100] + + pd_result: pd.Series = pd.Series(pd.cut(sc, x)) + bf_result = bpd.cut(sc, x) + + pd_result = _convert_pandas_category(pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + @pytest.mark.parametrize( - ("breaks",), + ("right", "labels"), [ - ([0, 5, 10, 15, 20, 100, 1000],), # ints - ([0.5, 10.5, 15.5, 20.5, 100.5, 1000.5],), # floats - ([0, 5, 10.5, 15.5, 20, 100, 1000.5],), # mixed + pytest.param(True, None, id="right_w_none_labels"), + pytest.param(True, False, id="right_w_false_labels"), + pytest.param(False, None, id="left_w_none_labels"), + pytest.param(False, False, id="left_w_false_labels"), ], ) -def test_cut_numeric_breaks(scalars_dfs, breaks): +def test_cut_by_int_bins(scalars_dfs, labels, right): scalars_df, scalars_pandas_df = scalars_dfs - pd_result = pd.cut(scalars_pandas_df["float64_col"], breaks) - bf_result = bpd.cut(scalars_df["float64_col"], breaks).to_pandas() + pd_result = pd.cut(scalars_pandas_df["float64_col"], 5, labels=labels, right=right) + bf_result = bpd.cut(scalars_df["float64_col"], 5, labels=labels, right=right) - # Convert to match data format - pd_result_converted = pd.Series( - [ - {"left_exclusive": interval.left, "right_inclusive": interval.right} - if pd.notna(val) - else pd.NA - for val, interval in zip( - pd_result, pd_result.cat.categories[pd_result.cat.codes] - ) - ], - name=pd_result.name, - ) + pd_result = _convert_pandas_category(pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) - pd.testing.assert_series_equal( - bf_result, pd_result_converted, check_index=False, check_dtype=False - ) + +def test_cut_by_int_bins_w_labels(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + labels = ["A", "B", "C", "D", "E"] + pd_result = pd.cut(scalars_pandas_df["float64_col"], 5, labels=labels) + bf_result = bpd.cut(scalars_df["float64_col"], 5, labels=labels) + + pd_result = _convert_pandas_category(pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) @pytest.mark.parametrize( - ("bins",), + ("breaks", "right", "labels"), [ - (-1,), # negative integer bins argument - ([],), # empty iterable of bins - (["notabreak"],), # iterable of wrong type - ([1],), # numeric breaks with only one numeric - # this is supported by pandas but not by - # the bigquery operation and a bigframes workaround - # is not yet available. Should return column - # of structs with all NaN values. + pytest.param( + [0, 5, 10, 15, 20, 100, 1000], + True, + None, + id="int_breaks_w_right_closed_and_none_labels", + ), + pytest.param( + [0, 5, 10, 15, 20, 100, 1000], + False, + False, + id="int_breaks_w_left_closed_and_false_labels", + ), + pytest.param( + [0.5, 10.5, 15.5, 20.5, 100.5, 1000.5], + False, + None, + id="float_breaks_w_left_closed_and_none_labels", + ), + pytest.param( + [0, 5, 10.5, 15.5, 20, 100, 1000.5], + True, + False, + id="mixed_types_breaks_w_right_closed_and_false_labels", + ), ], ) -def test_cut_errors(scalars_dfs, bins): - scalars_df, _ = scalars_dfs +def test_cut_by_numeric_breaks(scalars_dfs, breaks, right, labels): + scalars_df, scalars_pandas_df = scalars_dfs - with pytest.raises(ValueError): - bpd.cut(scalars_df["float64_col"], bins) + pd_result = pd.cut( + scalars_pandas_df["float64_col"], breaks, right=right, labels=labels + ) + bf_result = bpd.cut( + scalars_df["float64_col"], breaks, right=right, labels=labels + ).to_pandas() + + pd_result_converted = _convert_pandas_category(pd_result) + pd.testing.assert_series_equal(bf_result, pd_result_converted) + + +def test_cut_by_numeric_breaks_w_labels(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bins = [0, 5, 10, 15, 20] + labels = ["A", "B", "C", "D"] + pd_result = pd.cut(scalars_pandas_df["float64_col"], bins, labels=labels) + bf_result = bpd.cut(scalars_df["float64_col"], bins, labels=labels) + + pd_result = _convert_pandas_category(pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) @pytest.mark.parametrize( - ("bins",), + ("bins", "right", "labels"), [ - ([(-5, 2), (2, 3), (-3000, -10)],), - (pd.IntervalIndex.from_tuples([(1, 2), (2, 3), (4, 5)]),), + pytest.param( + [(-5, 2), (2, 3), (-3000, -10)], True, None, id="tuple_right_w_none_labels" + ), + pytest.param( + [(-5, 2), (2, 3), (-3000, -10)], + False, + False, + id="tuple_left_w_false_labels", + ), + pytest.param( + pd.IntervalIndex.from_tuples([(1, 2), (2, 3), (4, 5)]), + True, + False, + id="interval_right_w_none_labels", + ), + pytest.param( + pd.IntervalIndex.from_tuples([(1, 2), (2, 3), (4, 5)]), + False, + None, + id="interval_left_w_false_labels", + ), ], ) -def test_cut_with_interval(scalars_dfs, bins): +def test_cut_by_interval_bins(scalars_dfs, bins, right, labels): scalars_df, scalars_pandas_df = scalars_dfs - bf_result = bpd.cut(scalars_df["int64_too"], bins, labels=False).to_pandas() + bf_result = bpd.cut( + scalars_df["int64_too"], bins, labels=labels, right=right + ).to_pandas() if isinstance(bins, list): bins = pd.IntervalIndex.from_tuples(bins) - pd_result = pd.cut(scalars_pandas_df["int64_too"], bins, labels=False) + pd_result = pd.cut(scalars_pandas_df["int64_too"], bins, labels=labels, right=right) - # Convert to match data format - pd_result_converted = pd.Series( - [ - {"left_exclusive": interval.left, "right_inclusive": interval.right} - if pd.notna(val) - else pd.NA - for val, interval in zip( - pd_result, pd_result.cat.categories[pd_result.cat.codes] - ) - ], - name=pd_result.name, - ) + pd_result_converted = _convert_pandas_category(pd_result) + pd.testing.assert_series_equal(bf_result, pd_result_converted) - pd.testing.assert_series_equal( - bf_result, pd_result_converted, check_index=False, check_dtype=False - ) + +def test_cut_by_interval_bins_w_labels(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bins = pd.IntervalIndex.from_tuples([(1, 2), (2, 3), (4, 5)]) + labels = ["A", "B", "C", "D", "E"] + pd_result = pd.cut(scalars_pandas_df["float64_col"], bins, labels=labels) + bf_result = bpd.cut(scalars_df["float64_col"], bins, labels=labels) + + pd_result = _convert_pandas_category(pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +@pytest.mark.parametrize( + ("bins", "labels"), + [ + pytest.param([], None, id="empty_breaks"), + pytest.param([1], False, id="single_int_breaks"), + pytest.param(pd.IntervalIndex.from_tuples([]), None, id="empty_interval_index"), + ], +) +def test_cut_by_edge_cases_bins(scalars_dfs, bins, labels): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = bpd.cut(scalars_df["int64_too"], bins, labels=labels).to_pandas() + pd_result = pd.cut(scalars_pandas_df["int64_too"], bins, labels=labels) + + pd_result_converted = _convert_pandas_category(pd_result) + pd.testing.assert_series_equal(bf_result, pd_result_converted) + + +def test_cut_empty_array_raises_error(): + bf_df = bpd.Series([]) + with pytest.raises(ValueError, match="Cannot cut empty array"): + bpd.cut(bf_df, bins=5) @pytest.mark.parametrize( diff --git a/tests/system/small/test_pandas_options.py b/tests/system/small/test_pandas_options.py index c580f926c9..7a750ddfd3 100644 --- a/tests/system/small/test_pandas_options.py +++ b/tests/system/small/test_pandas_options.py @@ -18,23 +18,13 @@ import warnings import google.api_core.exceptions -import google.auth -import google.auth.exceptions +import pandas.testing import pytest -import bigframes.core.global_session +import bigframes.exceptions import bigframes.pandas as bpd -@pytest.fixture(autouse=True) -def reset_default_session_and_location(): - # Note: This starts a thread-local session and closes it once the test - # finishes. - with bpd.option_context("bigquery.location", None): - bpd.options.bigquery.location = None - yield - - @pytest.mark.parametrize( ("read_method", "query_prefix"), [ @@ -58,7 +48,9 @@ def test_read_gbq_start_sets_session_location( dataset_id_permanent, read_method, query_prefix, + reset_default_session_and_location, ): + # Form query as a table name or a SQL depending on the test scenario query_tokyo = test_data_tables_tokyo["scalars"] query = test_data_tables["scalars"] @@ -138,6 +130,7 @@ def test_read_gbq_after_session_start_must_comply_with_default_location( dataset_id_permanent_tokyo, read_method, query_prefix, + reset_default_session_and_location, ): # Form query as a table name or a SQL depending on the test scenario query_tokyo = test_data_tables_tokyo["scalars"] @@ -191,6 +184,7 @@ def test_read_gbq_must_comply_with_set_location_US( dataset_id_permanent_tokyo, read_method, query_prefix, + reset_default_session_and_location, ): # Form query as a table name or a SQL depending on the test scenario query_tokyo = test_data_tables_tokyo["scalars"] @@ -241,6 +235,7 @@ def test_read_gbq_must_comply_with_set_location_non_US( dataset_id_permanent, read_method, query_prefix, + reset_default_session_and_location, ): # Form query as a table name or a SQL depending on the test scenario query_tokyo = test_data_tables_tokyo["scalars"] @@ -269,7 +264,9 @@ def test_read_gbq_must_comply_with_set_location_non_US( assert df is not None -def test_credentials_need_reauthentication(monkeypatch): +def test_credentials_need_reauthentication( + monkeypatch, reset_default_session_and_location +): # Use a simple test query to verify that default session works to interact # with BQ. test_query = "SELECT 1" @@ -281,16 +278,29 @@ def test_credentials_need_reauthentication(monkeypatch): # Call get_global_session() *after* read_gbq so that our location detection # has a chance to work. session = bpd.get_global_session() - assert session.bqclient._credentials.valid + assert session.bqclient._http.credentials.valid + + # We look at the thread-local session because of the + # reset_default_session_and_location fixture and that this test mutates + # state that might otherwise be used by tests running in parallel. + current_session = ( + bigframes.core.global_session._global_session_state.thread_local_session + ) + assert current_session is not None + + # Force a temp table to be created, so there is something to cleanup. + current_session._anon_dataset_manager.create_temp_table(schema=()) with monkeypatch.context() as m: # Simulate expired credentials to trigger the credential refresh flow - m.setattr(session.bqclient._credentials, "expiry", datetime.datetime.utcnow()) - assert not session.bqclient._credentials.valid + m.setattr( + session.bqclient._http.credentials, "expiry", datetime.datetime.utcnow() + ) + assert not session.bqclient._http.credentials.valid # Simulate an exception during the credential refresh flow m.setattr( - session.bqclient._credentials, + session.bqclient._http.credentials, "refresh", mock.Mock(side_effect=google.auth.exceptions.RefreshError()), ) @@ -304,15 +314,6 @@ def test_credentials_need_reauthentication(monkeypatch): with pytest.raises(google.auth.exceptions.RefreshError): bpd.read_gbq(test_query) - # Now verify that closing the session works We look at the - # thread-local session because of the - # reset_default_session_and_location fixture and that this test mutates - # state that might otherwise be used by tests running in parallel. - assert ( - bigframes.core.global_session._global_session_state.thread_local_session - is not None - ) - with warnings.catch_warnings(record=True) as warned: bpd.close_session() # CleanupFailedWarning: can't clean up @@ -327,3 +328,45 @@ def test_credentials_need_reauthentication(monkeypatch): # Now verify that use is able to start over df = bpd.read_gbq(test_query) assert df is not None + + +def test_max_rows_normal_execution_within_limit( + scalars_df_index, scalars_pandas_df_index +): + """Test queries execute normally when the number of rows is within the limit.""" + with bpd.option_context("compute.maximum_result_rows", 10): + df = scalars_df_index.head(10) + result = df.to_pandas() + + expected = scalars_pandas_df_index.head(10) + pandas.testing.assert_frame_equal(result, expected) + + with bpd.option_context("compute.maximum_result_rows", 10), bpd.option_context( + "display.repr_mode", "head" + ): + df = scalars_df_index.head(10) + assert repr(df) is not None + + # We should be able to get away with only a single row for shape. + with bpd.option_context("compute.maximum_result_rows", 1): + shape = scalars_df_index.shape + assert shape == scalars_pandas_df_index.shape + + # 0 is not recommended, as it would stop aggregations and many other + # necessary operations, but we shouldn't need even 1 row for to_gbq(). + with bpd.option_context("compute.maximum_result_rows", 0): + destination = scalars_df_index.to_gbq() + assert destination is not None + + +def test_max_rows_exceeds_limit(scalars_df_index): + """Test to_pandas() raises MaximumRowsDownloadedExceeded when the limit is exceeded.""" + with bpd.option_context("compute.maximum_result_rows", 5), pytest.raises( + bigframes.exceptions.MaximumResultRowsExceeded, match="5" + ): + scalars_df_index.to_pandas() + + with bpd.option_context("compute.maximum_result_rows", 5), pytest.raises( + bigframes.exceptions.MaximumResultRowsExceeded, match="5" + ): + next(iter(scalars_df_index.to_pandas_batches())) diff --git a/tests/system/small/test_polars_execution.py b/tests/system/small/test_polars_execution.py new file mode 100644 index 0000000000..1b58dc9d12 --- /dev/null +++ b/tests/system/small/test_polars_execution.py @@ -0,0 +1,74 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import math + +import pytest + +import bigframes +import bigframes.bigquery +from bigframes.testing.utils import assert_frame_equal + +polars = pytest.importorskip("polars") + + +@pytest.fixture(scope="module") +def session_w_polars(): + context = bigframes.BigQueryOptions(location="US", enable_polars_execution=True) + session = bigframes.Session(context=context) + yield session + session.close() # close generated session at cleanup time + + +def test_polar_execution_sorted(session_w_polars, scalars_pandas_df_index): + execution_count_before = session_w_polars._metrics.execution_count + bf_df = session_w_polars.read_pandas(scalars_pandas_df_index) + + pd_result = scalars_pandas_df_index.sort_index(ascending=False)[ + ["int64_too", "bool_col"] + ] + bf_result = bf_df.sort_index(ascending=False)[["int64_too", "bool_col"]].to_pandas() + + assert session_w_polars._metrics.execution_count == execution_count_before + assert_frame_equal(bf_result, pd_result) + + +def test_polar_execution_sorted_filtered(session_w_polars, scalars_pandas_df_index): + execution_count_before = session_w_polars._metrics.execution_count + bf_df = session_w_polars.read_pandas(scalars_pandas_df_index) + + pd_result = scalars_pandas_df_index.sort_index(ascending=False).dropna( + subset=["int64_col", "string_col"] + ) + bf_result = ( + bf_df.sort_index(ascending=False) + .dropna(subset=["int64_col", "string_col"]) + .to_pandas() + ) + + assert session_w_polars._metrics.execution_count == execution_count_before + assert_frame_equal(bf_result, pd_result) + + +def test_polar_execution_unsupported_sql_fallback( + session_w_polars, scalars_pandas_df_index +): + execution_count_before = session_w_polars._metrics.execution_count + bf_df = session_w_polars.read_pandas(scalars_pandas_df_index) + + bf_df["geo_area"] = bigframes.bigquery.st_length(bf_df.geography_col) + bf_result = bf_df.to_pandas() + + # geo fns not supported by polar engine yet, so falls back to bq execution + assert session_w_polars._metrics.execution_count == (execution_count_before + 1) + assert math.isclose(bf_result.geo_area.sum(), 70.52332050, rel_tol=0.00001) diff --git a/tests/system/small/test_progress_bar.py b/tests/system/small/test_progress_bar.py index 73a9743e2f..d726bfde2c 100644 --- a/tests/system/small/test_progress_bar.py +++ b/tests/system/small/test_progress_bar.py @@ -17,12 +17,13 @@ import numpy as np import pandas as pd +import pytest import bigframes as bf import bigframes.formatting_helpers as formatting_helpers from bigframes.session import MAX_INLINE_DF_BYTES -job_load_message_regex = r"\w+ job [\w-]+ is \w+\." +job_load_message_regex = r"Query" EXPECTED_DRY_RUN_MESSAGE = "Computation deferred. Computation will process" @@ -32,7 +33,7 @@ def test_progress_bar_dataframe( capsys.readouterr() # clear output with bf.option_context("display.progress_bar", "terminal"): - penguins_df_default_index.to_pandas() + penguins_df_default_index.to_pandas(allow_large_results=True) assert_loading_msg_exist(capsys.readouterr().out) assert penguins_df_default_index.query_job is not None @@ -43,7 +44,7 @@ def test_progress_bar_series(penguins_df_default_index: bf.dataframe.DataFrame, capsys.readouterr() # clear output with bf.option_context("display.progress_bar", "terminal"): - series.to_pandas() + series.to_pandas(allow_large_results=True) assert_loading_msg_exist(capsys.readouterr().out) assert series.query_job is not None @@ -58,6 +59,19 @@ def test_progress_bar_scalar(penguins_df_default_index: bf.dataframe.DataFrame, assert_loading_msg_exist(capsys.readouterr().out) +def test_progress_bar_scalar_allow_large_results( + penguins_df_default_index: bf.dataframe.DataFrame, capsys +): + capsys.readouterr() # clear output + + with bf.option_context( + "display.progress_bar", "terminal", "compute.allow_large_results", "True" + ): + penguins_df_default_index["body_mass_g"].head(10).mean() + + assert_loading_msg_exist(capsys.readouterr().out) + + def test_progress_bar_extract_jobs( penguins_df_default_index: bf.dataframe.DataFrame, gcs_folder, capsys ): @@ -86,41 +100,23 @@ def test_progress_bar_load_jobs( capsys.readouterr() # clear output session.read_csv(path) - assert_loading_msg_exist(capsys.readouterr().out) + assert_loading_msg_exist(capsys.readouterr().out, pattern="Load") -def assert_loading_msg_exist(capystOut: str, pattern=job_load_message_regex): - numLoadingMsg = 0 - lines = capystOut.split("\n") +def assert_loading_msg_exist(capstdout: str, pattern=job_load_message_regex): + num_loading_msg = 0 + lines = capstdout.split("\n") lines = [line for line in lines if len(line) > 0] assert len(lines) > 0 for line in lines: - if re.match(pattern, line) is not None: - numLoadingMsg += 1 - assert numLoadingMsg > 0 - - -def test_query_job_repr_html(penguins_df_default_index: bf.dataframe.DataFrame): - with bf.option_context("display.progress_bar", "terminal"): - penguins_df_default_index.to_pandas() - query_job_repr = formatting_helpers.repr_query_job_html( - penguins_df_default_index.query_job - ).value - - string_checks = [ - "Job Id", - "Destination Table", - "Slot Time", - "Bytes Processed", - "Cache hit", - ] - for string in string_checks: - assert string in query_job_repr + if re.search(pattern, line) is not None: + num_loading_msg += 1 + assert num_loading_msg > 0 def test_query_job_repr(penguins_df_default_index: bf.dataframe.DataFrame): - penguins_df_default_index.to_pandas() + penguins_df_default_index.to_pandas(allow_large_results=True) query_job_repr = formatting_helpers.repr_query_job( penguins_df_default_index.query_job ) @@ -151,3 +147,23 @@ def test_query_job_dry_run_series(penguins_df_default_index: bf.dataframe.DataFr with bf.option_context("display.repr_mode", "deferred"): series_result = repr(penguins_df_default_index["body_mass_g"]) assert EXPECTED_DRY_RUN_MESSAGE in series_result + + +def test_repr_anywidget_dataframe(penguins_df_default_index: bf.dataframe.DataFrame): + pytest.importorskip("anywidget") + with bf.option_context("display.repr_mode", "anywidget"): + actual_repr = repr(penguins_df_default_index) + assert "species" in actual_repr + assert "island" in actual_repr + assert "[344 rows x 7 columns]" in actual_repr + + +def test_repr_anywidget_index(penguins_df_default_index: bf.dataframe.DataFrame): + pytest.importorskip("anywidget") + with bf.option_context("display.repr_mode", "anywidget"): + index = penguins_df_default_index.index + actual_repr = repr(index) + # In non-interactive environments, should still get a useful summary. + assert "Index" in actual_repr + assert "0, 1, 2, 3, 4" in actual_repr + assert "dtype='Int64'" in actual_repr diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 00f47c754e..a95c9623e5 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -13,27 +13,29 @@ # limitations under the License. import datetime as dt +import json import math import re import tempfile import db_dtypes # type: ignore import geopandas as gpd # type: ignore +import google.api_core.exceptions import numpy from packaging.version import Version import pandas as pd import pyarrow as pa # type: ignore import pytest -import shapely # type: ignore +import shapely.geometry # type: ignore +import bigframes.dtypes as dtypes import bigframes.features import bigframes.pandas import bigframes.series as series -from tests.system.utils import ( - assert_pandas_df_equal, +from bigframes.testing.utils import ( + assert_frame_equal, assert_series_equal, get_first_file_from_wildcard, - skip_legacy_pandas, ) @@ -139,7 +141,6 @@ def test_series_construct_reindex(): # BigQuery DataFrame default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - pd.testing.assert_series_equal(bf_result, pd_result) @@ -200,6 +201,17 @@ def test_series_construct_nan(): pd.testing.assert_series_equal(bf_result, pd_result) +def test_series_construct_scalar_w_bf_index(): + bf_result = series.Series( + "hello", index=bigframes.pandas.Index([1, 2, 3]) + ).to_pandas() + pd_result = pd.Series("hello", index=pd.Index([1, 2, 3], dtype="Int64")) + + pd_result = pd_result.astype("string[pyarrow]") + + pd.testing.assert_series_equal(bf_result, pd_result) + + def test_series_construct_from_list_escaped_strings(): """Check that special characters are supported.""" strings = [ @@ -218,7 +230,11 @@ def test_series_construct_from_list_escaped_strings(): def test_series_construct_geodata(): pd_series = pd.Series( - [shapely.Point(1, 1), shapely.Point(2, 2), shapely.Point(3, 3)], + [ + shapely.geometry.Point(1, 1), + shapely.geometry.Point(2, 2), + shapely.geometry.Point(3, 3), + ], dtype=gpd.array.GeometryDtype(), ) @@ -302,24 +318,74 @@ def test_series_construct_w_dtype_for_array_struct(): ) -def test_series_construct_w_dtype_for_json(): +def test_series_construct_local_unordered_has_sequential_index(unordered_session): + series = bigframes.pandas.Series( + ["Sun", "Mon", "Tues", "Wed", "Thurs", "Fri", "Sat"], session=unordered_session + ) + expected: pd.Index = pd.Index([0, 1, 2, 3, 4, 5, 6], dtype=pd.Int64Dtype()) + pd.testing.assert_index_equal(series.index.to_pandas(), expected) + + +@pytest.mark.parametrize( + ("json_type"), + [ + pytest.param(dtypes.JSON_DTYPE), + pytest.param("json"), + ], +) +def test_series_construct_w_json_dtype(json_type): data = [ - 1, - "str", - False, - ["a", {"b": 1}, None], + "1", + '"str"', + "false", + '["a", {"b": 1}, null]', None, - {"a": {"b": [1, 2, 3], "c": True}}, + '{"a": {"b": [1, 2, 3], "c": true}}', ] - s = bigframes.pandas.Series(data, dtype=db_dtypes.JSONDtype()) + s = bigframes.pandas.Series(data, dtype=json_type) - assert s[0] == 1 - assert s[1] == "str" - assert s[2] is False - assert s[3][0] == "a" - assert s[3][1]["b"] == 1 + assert s.dtype == dtypes.JSON_DTYPE + assert s[0] == "1" + assert s[1] == '"str"' + assert s[2] == "false" + assert s[3] == '["a",{"b":1},null]' assert pd.isna(s[4]) - assert s[5]["a"] == {"b": [1, 2, 3], "c": True} + assert s[5] == '{"a":{"b":[1,2,3],"c":true}}' + + +def test_series_construct_w_nested_json_dtype(): + list_data = [ + [{"key": "1"}], + [{"key": None}], + [{"key": '["1","3","5"]'}], + [{"key": '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}'}], + ] + pa_array = pa.array(list_data, type=pa.list_(pa.struct([("key", pa.string())]))) + + db_json_arrow_dtype = db_dtypes.JSONArrowType() + s = bigframes.pandas.Series( + pd.arrays.ArrowExtensionArray(pa_array), # type: ignore + dtype=pd.ArrowDtype( + pa.list_(pa.struct([("key", db_json_arrow_dtype)])), + ), + ) + + assert s[0][0]["key"] == "1" + assert not s[1][0]["key"] + assert s[2][0]["key"] == '["1","3","5"]' + assert s[3][0]["key"] == '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}' + + # Test with pyarrow.json_(pa.string()) if available. + if hasattr(pa, "JsonType"): + pyarrow_json_dtype = pa.json_(pa.string()) + s2 = bigframes.pandas.Series( + pd.arrays.ArrowExtensionArray(pa_array), # type: ignore + dtype=pd.ArrowDtype( + pa.list_(pa.struct([("key", pyarrow_json_dtype)])), + ), + ) + + pd.testing.assert_series_equal(s.to_pandas(), s2.to_pandas()) def test_series_keys(scalars_dfs): @@ -383,7 +449,7 @@ def test_get_column(scalars_dfs, col_name, expected_dtype): def test_get_column_w_json(json_df, json_pandas_df): series = json_df["json_col"] series_pandas = series.to_pandas() - assert series.dtype == db_dtypes.JSONDtype() + assert series.dtype == pd.ArrowDtype(db_dtypes.JSONArrowType()) assert series_pandas.shape[0] == json_pandas_df.shape[0] @@ -393,6 +459,22 @@ def test_series_get_column_default(scalars_dfs): assert result == "default_val" +@pytest.mark.parametrize( + ("key",), + [ + ("hello",), + (2,), + ("int64_col",), + (None,), + ], +) +def test_series_contains(scalars_df_index, scalars_pandas_df_index, key): + bf_result = key in scalars_df_index["int64_col"] + pd_result = key in scalars_pandas_df_index["int64_col"] + + assert bf_result == pd_result + + def test_series_equals_identical(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.int64_col.equals(scalars_df_index.int64_col) pd_result = scalars_pandas_df_index.int64_col.equals( @@ -487,6 +569,69 @@ def test_series___getitem___with_default_index(scalars_dfs): assert bf_result == pd_result +@pytest.mark.parametrize( + ("index_col", "key", "value"), + ( + ("int64_too", 2, "new_string_value"), + ("string_col", "Hello, World!", "updated_value"), + ("int64_too", 0, None), + ), +) +def test_series___setitem__(scalars_dfs, index_col, key, value): + col_name = "string_col" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name].copy() + + bf_series[key] = value + pd_series[key] = value + + pd.testing.assert_series_equal(bf_series.to_pandas(), pd_series) + + +@pytest.mark.parametrize( + ("key", "value"), + ( + (0, 999), + (1, 888), + (0, None), + (-2345, 777), + ), +) +def test_series___setitem___with_int_key_numeric(scalars_dfs, key, value): + col_name = "int64_col" + index_col = "int64_too" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name].copy() + + bf_series[key] = value + pd_series[key] = value + + pd.testing.assert_series_equal(bf_series.to_pandas(), pd_series) + + +def test_series___setitem___with_default_index(scalars_dfs): + col_name = "float64_col" + key = 2 + value = 123.456 + scalars_df, scalars_pandas_df = scalars_dfs + + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name].copy() + + bf_series[key] = value + pd_series[key] = value + + assert bf_series.to_pandas().iloc[key] == pd_series.iloc[key] + + @pytest.mark.parametrize( ("col_name",), ( @@ -606,15 +751,29 @@ def test_series_replace_list_scalar(scalars_dfs): ) +def test_series_replace_nans_with_pd_na(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name].replace({pd.NA: "UNKNOWN"}).to_pandas() + pd_result = scalars_pandas_df[col_name].replace({pd.NA: "UNKNOWN"}) + + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + @pytest.mark.parametrize( ("replacement_dict",), ( ({"Hello, World!": "Howdy, Planet!", "T": "R"},), ({},), + ({0: "Hello, World!"},), ), ids=[ "non-empty", "empty", + "off-type", ], ) def test_series_replace_dict(scalars_dfs, replacement_dict): @@ -641,6 +800,10 @@ def test_series_replace_dict(scalars_dfs, replacement_dict): ), ) def test_series_interpolate(method): + pytest.importorskip("scipy") + if method == "pad" and pd.__version__.startswith("3."): + pytest.skip("pandas 3.0 dropped method='pad'") + values = [None, 1, 2, None, None, 16, None] index = [-3.2, 11.4, 3.56, 4, 4.32, 5.55, 76.8] pd_series = pd.Series(values, index) @@ -652,11 +815,12 @@ def test_series_interpolate(method): bf_result = bf_series.interpolate(method=method).to_pandas() # pd uses non-null types, while bf uses nullable types - pd.testing.assert_series_equal( + assert_series_equal( pd_result, bf_result, check_index_type=False, check_dtype=False, + nulls_are_nan=True, ) @@ -870,7 +1034,8 @@ def test_series_int_int_operators_scalar( bf_result = maybe_reversed_op(scalars_df["int64_col"], other_scalar).to_pandas() pd_result = maybe_reversed_op(scalars_pandas_df["int64_col"], other_scalar) - assert_series_equal(pd_result, bf_result) + # don't check dtype, as pandas is a bit unstable here across versions, esp floordiv + assert_series_equal(pd_result, bf_result, check_dtype=False) def test_series_pow_scalar(scalars_dfs): @@ -1010,8 +1175,9 @@ def test_series_corr(scalars_dfs): assert math.isclose(pd_result, bf_result) -@skip_legacy_pandas def test_series_autocorr(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_result = scalars_df["float64_col"].autocorr(2) pd_result = scalars_pandas_df["float64_col"].autocorr(2) @@ -1214,6 +1380,44 @@ def test_reset_index_drop(scalars_df_index, scalars_pandas_df_index): pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) +def test_series_reset_index_allow_duplicates(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_col"].copy() + bf_series.index.name = "int64_col" + df = bf_series.reset_index(allow_duplicates=True, drop=False) + assert df.index.name is None + + bf_result = df.to_pandas() + + pd_series = scalars_pandas_df_index["int64_col"].copy() + pd_series.index.name = "int64_col" + pd_result = pd_series.reset_index(allow_duplicates=True, drop=False) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_series_reset_index_duplicates_error(scalars_df_index): + scalars_df_index = scalars_df_index["int64_col"].copy() + scalars_df_index.index.name = "int64_col" + with pytest.raises(ValueError): + scalars_df_index.reset_index(allow_duplicates=False, drop=False) + + +def test_series_reset_index_inplace(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.sort_index(ascending=False)["float64_col"] + bf_result.reset_index(drop=True, inplace=True) + pd_result = scalars_pandas_df_index.sort_index(ascending=False)["float64_col"] + pd_result.reset_index(drop=True, inplace=True) + + # BigQuery DataFrames default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + @pytest.mark.parametrize( ("name",), [ @@ -1342,6 +1546,24 @@ def test_isin_bigframes_values(scalars_dfs, col_name, test_set, session): ) +def test_isin_bigframes_index(scalars_dfs, session): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = ( + scalars_df["string_col"] + .isin(bigframes.pandas.Index(["Hello, World!", "Hi", "こんにちは"], session=session)) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df["string_col"] + .isin(pd.Index(["Hello, World!", "Hi", "こんにちは"])) + .astype("boolean") + ) + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + @pytest.mark.parametrize( ( "col_name", @@ -1538,6 +1760,23 @@ def test_indexing_using_selected_series(scalars_dfs): ) +@pytest.mark.parametrize( + ("indices"), + [ + ([1, 3, 5]), + ([5, -3, -5, -6]), + ([-2, -4, -6]), + ], +) +def test_take(scalars_dfs, indices): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df.take(indices).to_pandas() + pd_result = scalars_pandas_df.take(indices) + + assert_frame_equal(bf_result, pd_result) + + def test_nested_filter(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs string_col = scalars_df["string_col"] @@ -1643,8 +1882,9 @@ def test_binop_right_filtered(scalars_dfs): (pd.Series([-1.4, 2.3, None], index=[44, 2, 1]),), ], ) -@skip_legacy_pandas def test_series_binop_w_other_types(scalars_dfs, other): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_result = (scalars_df["int64_col"].head(3) + other).to_pandas() @@ -1664,8 +1904,9 @@ def test_series_binop_w_other_types(scalars_dfs, other): (pd.Series([-1.4, 2.3, None], index=[44, 2, 1]),), ], ) -@skip_legacy_pandas def test_series_reverse_binop_w_other_types(scalars_dfs, other): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs bf_result = (other + scalars_df["int64_col"].head(3)).to_pandas() @@ -1677,8 +1918,9 @@ def test_series_reverse_binop_w_other_types(scalars_dfs, other): ) -@skip_legacy_pandas def test_series_combine_first(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs int64_col = scalars_df["int64_col"].head(7) float64_col = scalars_df["float64_col"].tail(7) @@ -1718,10 +1960,22 @@ def test_mean(scalars_dfs): assert math.isclose(pd_result, bf_result) -def test_median(scalars_dfs): +@pytest.mark.parametrize( + ("col_name"), + [ + "int64_col", + # Non-numeric column + "bytes_col", + "date_col", + "datetime_col", + "time_col", + "timestamp_col", + "string_col", + ], +) +def test_median(scalars_dfs, col_name): scalars_df, scalars_pandas_df = scalars_dfs - col_name = "int64_col" - bf_result = scalars_df[col_name].median() + bf_result = scalars_df[col_name].median(exact=False) pd_max = scalars_pandas_df[col_name].max() pd_min = scalars_pandas_df[col_name].min() # Median is approximate, so just check for plausibility. @@ -1731,7 +1985,7 @@ def test_median(scalars_dfs): def test_median_exact(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs col_name = "int64_col" - bf_result = scalars_df[col_name].median(exact=True) + bf_result = scalars_df[col_name].median() pd_result = scalars_pandas_df[col_name].median() assert math.isclose(pd_result, bf_result) @@ -1764,7 +2018,10 @@ def test_series_small_repr(scalars_dfs): col_name = "int64_col" bf_series = scalars_df[col_name] pd_series = scalars_pandas_df[col_name] - assert repr(bf_series) == pd_series.to_string(length=False, dtype=True, name=True) + with bigframes.pandas.option_context("display.repr_mode", "head"): + assert repr(bf_series) == pd_series.to_string( + length=False, dtype=True, name=True + ) def test_sum(scalars_dfs): @@ -2086,7 +2343,7 @@ def test_drop_duplicates(scalars_df_index, scalars_pandas_df_index, keep, col_na ], ) def test_unique(scalars_df_index, scalars_pandas_df_index, col_name): - bf_uniq = scalars_df_index[col_name].unique().to_numpy() + bf_uniq = scalars_df_index[col_name].unique().to_numpy(na_value=None) pd_uniq = scalars_pandas_df_index[col_name].unique() numpy.array_equal(pd_uniq, bf_uniq) @@ -2267,11 +2524,33 @@ def test_head_then_series_operation(scalars_dfs): def test_series_peek(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs + peek_result = scalars_df["float64_col"].peek(n=3, force=False) + + pd.testing.assert_series_equal( + peek_result, + scalars_pandas_df["float64_col"].reindex_like(peek_result), + ) + assert len(peek_result) == 3 + + +def test_series_peek_with_large_results_not_allowed(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + session = scalars_df._block.session + slot_millis_sum = session.slot_millis_sum + peek_result = scalars_df["float64_col"].peek( + n=3, force=False, allow_large_results=False + ) + + # The metrics won't be fully updated when we call query_and_wait. + print(session.slot_millis_sum - slot_millis_sum) + assert session.slot_millis_sum - slot_millis_sum < 500 pd.testing.assert_series_equal( peek_result, scalars_pandas_df["float64_col"].reindex_like(peek_result), ) + assert len(peek_result) == 3 def test_series_peek_multi_index(scalars_dfs): @@ -2299,8 +2578,9 @@ def test_series_peek_filtered(scalars_dfs): ) -@skip_legacy_pandas def test_series_peek_force(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs cumsum_df = scalars_df[["int64_col", "int64_too"]].cumsum() @@ -2314,8 +2594,9 @@ def test_series_peek_force(scalars_dfs): ) -@skip_legacy_pandas def test_series_peek_force_float(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df, scalars_pandas_df = scalars_dfs cumsum_df = scalars_df[["int64_col", "float64_col"]].cumsum() @@ -2452,7 +2733,7 @@ def test_diff(scalars_df_index, scalars_pandas_df_index, periods): def test_series_pct_change(scalars_df_index, scalars_pandas_df_index, periods): bf_result = scalars_df_index["int64_col"].pct_change(periods=periods).to_pandas() # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA - pd_result = scalars_pandas_df_index["int64_col"].pct_change(periods=periods) + pd_result = scalars_pandas_df_index["int64_col"].ffill().pct_change(periods=periods) pd.testing.assert_series_equal( bf_result, @@ -2479,10 +2760,48 @@ def test_series_nsmallest(scalars_df_index, scalars_pandas_df_index, keep): ) -def test_rank_ints(scalars_df_index, scalars_pandas_df_index): +@pytest.mark.parametrize( + ("na_option", "method", "ascending", "numeric_only", "pct"), + [ + ("keep", "average", True, True, False), + ("top", "min", False, False, True), + ("bottom", "max", False, False, False), + ("top", "first", False, False, True), + ("bottom", "dense", False, False, False), + ], +) +def test_series_rank( + scalars_df_index, + scalars_pandas_df_index, + na_option, + method, + ascending, + numeric_only, + pct, +): col_name = "int64_too" - bf_result = scalars_df_index[col_name].rank().to_pandas() - pd_result = scalars_pandas_df_index[col_name].rank().astype(pd.Float64Dtype()) + bf_result = ( + scalars_df_index[col_name] + .rank( + na_option=na_option, + method=method, + ascending=ascending, + numeric_only=numeric_only, + pct=pct, + ) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df_index[col_name] + .rank( + na_option=na_option, + method=method, + ascending=ascending, + numeric_only=numeric_only, + pct=pct, + ) + .astype(pd.Float64Dtype()) + ) pd.testing.assert_series_equal( bf_result, @@ -2532,8 +2851,9 @@ def test_cumsum_nested(scalars_df_index, scalars_pandas_df_index): ) -@skip_legacy_pandas def test_nested_analytic_ops_align(scalars_df_index, scalars_pandas_df_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") col_name = "float64_col" # set non-unique index to check implicit alignment bf_series = scalars_df_index.set_index("bool_col")[col_name].fillna(0.0) @@ -2909,6 +3229,26 @@ def test_where_with_default(scalars_df_index, scalars_pandas_df_index): ) +def test_where_with_callable(scalars_df_index, scalars_pandas_df_index): + def _is_positive(x): + return x > 0 + + # Both cond and other are callable. + bf_result = ( + scalars_df_index["int64_col"] + .where(cond=_is_positive, other=lambda x: x * 10) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].where( + cond=_is_positive, other=lambda x: x * 10 + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + @pytest.mark.parametrize( ("ordered"), [ @@ -2930,6 +3270,17 @@ def test_clip(scalars_df_index, scalars_pandas_df_index, ordered): assert_series_equal(bf_result, pd_result, ignore_order=not ordered) +def test_clip_int_with_float_bounds(scalars_df_index, scalars_pandas_df_index): + col_bf = scalars_df_index["int64_too"] + bf_result = col_bf.clip(-100, 3.14151593).to_pandas() + + col_pd = scalars_pandas_df_index["int64_too"] + # pandas doesn't work with Int64 and clip with floats + pd_result = col_pd.astype("int64").clip(-100, 3.14151593).astype("Float64") + + assert_series_equal(bf_result, pd_result) + + def test_clip_filtered_two_sided(scalars_df_index, scalars_pandas_df_index): col_bf = scalars_df_index["int64_col"].iloc[::2] lower_bf = scalars_df_index["int64_too"].iloc[2:] - 1 @@ -3072,7 +3423,7 @@ def test_to_frame(scalars_dfs): bf_result = scalars_df["int64_col"].to_frame().to_pandas() pd_result = scalars_pandas_df["int64_col"].to_frame() - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_to_frame_no_name(scalars_dfs): @@ -3081,7 +3432,7 @@ def test_to_frame_no_name(scalars_dfs): bf_result = scalars_df["int64_col"].rename(None).to_frame().to_pandas() pd_result = scalars_pandas_df["int64_col"].rename(None).to_frame() - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_to_json(gcs_folder, scalars_df_index, scalars_pandas_df_index): @@ -3124,8 +3475,9 @@ def test_series_to_json_local_str(scalars_df_index, scalars_pandas_df_index): assert bf_result == pd_result -@skip_legacy_pandas def test_series_to_json_local_file(scalars_df_index, scalars_pandas_df_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: scalars_df_index.int64_col.to_json(bf_result_file) scalars_pandas_df_index.int64_col.to_json(pd_result_file) @@ -3269,6 +3621,19 @@ def test_sort_values(scalars_df_index, scalars_pandas_df_index, ascending, na_po ) +def test_series_sort_values_inplace(scalars_df_index, scalars_pandas_df_index): + # Test needs values to be unique + bf_series = scalars_df_index["int64_col"].copy() + bf_series.sort_values(ascending=False, inplace=True) + bf_result = bf_series.to_pandas() + pd_result = scalars_pandas_df_index["int64_col"].sort_values(ascending=False) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + @pytest.mark.parametrize( ("ascending"), [ @@ -3288,6 +3653,18 @@ def test_sort_index(scalars_df_index, scalars_pandas_df_index, ascending): ) +def test_series_sort_index_inplace(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_too"].copy() + bf_series.sort_index(ascending=False, inplace=True) + bf_result = bf_series.to_pandas() + pd_result = scalars_pandas_df_index["int64_too"].sort_index(ascending=False) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + def test_mask_default_value(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs @@ -3299,7 +3676,7 @@ def test_mask_default_value(scalars_dfs): pd_col_masked = pd_col.mask(pd_col % 2 == 1) pd_result = pd_col.to_frame().assign(int64_col_masked=pd_col_masked) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_mask_custom_value(scalars_dfs): @@ -3317,7 +3694,27 @@ def test_mask_custom_value(scalars_dfs): # odd so should be left as is, but it is being masked in pandas. # Accidentally the bigframes bahavior matches, but it should be updated # after the resolution of https://github.com/pandas-dev/pandas/issues/52955 - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) + + +def test_mask_with_callable(scalars_df_index, scalars_pandas_df_index): + def _ten_times(x): + return x * 10 + + # Both cond and other are callable. + bf_result = ( + scalars_df_index["int64_col"] + .mask(cond=lambda x: x > 0, other=_ten_times) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].mask( + cond=lambda x: x > 0, other=_ten_times + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) @pytest.mark.parametrize( @@ -3379,9 +3776,11 @@ def foo(x): ("int64_col", pd.ArrowDtype(pa.timestamp("us"))), ("int64_col", pd.ArrowDtype(pa.timestamp("us", tz="UTC"))), ("int64_col", "time64[us][pyarrow]"), + ("int64_col", pd.ArrowDtype(db_dtypes.JSONArrowType())), ("bool_col", "Int64"), ("bool_col", "string[pyarrow]"), ("bool_col", "Float64"), + ("bool_col", pd.ArrowDtype(db_dtypes.JSONArrowType())), ("string_col", "binary[pyarrow]"), ("bytes_col", "string[pyarrow]"), # pandas actually doesn't let folks convert to/from naive timestamp and @@ -3416,8 +3815,9 @@ def foo(x): # https://cloud.google.com/bigquery/docs/reference/standard-sql/conversion_functions ], ) -@skip_legacy_pandas def test_astype(scalars_df_index, scalars_pandas_df_index, column, to_type, errors): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") bf_result = scalars_df_index[column].astype(to_type, errors=errors).to_pandas() pd_result = scalars_pandas_df_index[column].astype(to_type) pd.testing.assert_series_equal(bf_result, pd_result) @@ -3445,19 +3845,24 @@ def test_astype_safe(session): pd.testing.assert_series_equal(result, exepcted) -def test_series_astype_error_error(session): +def test_series_astype_w_invalid_error(session): input = pd.Series(["hello", "world", "3.11", "4000"]) with pytest.raises(ValueError): session.read_pandas(input).astype("Float64", errors="bad_value") -@skip_legacy_pandas def test_astype_numeric_to_int(scalars_df_index, scalars_pandas_df_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") column = "numeric_col" to_type = "Int64" bf_result = scalars_df_index[column].astype(to_type).to_pandas() - # Round to the nearest whole number to avoid TypeError - pd_result = scalars_pandas_df_index[column].round(0).astype(to_type) + # Truncate to int to avoid TypeError + pd_result = ( + scalars_pandas_df_index[column] + .apply(lambda x: None if pd.isna(x) else math.trunc(x)) + .astype(to_type) + ) pd.testing.assert_series_equal(bf_result, pd_result) @@ -3469,10 +3874,11 @@ def test_astype_numeric_to_int(scalars_df_index, scalars_pandas_df_index): ("time_col", "int64[pyarrow]"), ], ) -@skip_legacy_pandas def test_date_time_astype_int( scalars_df_index, scalars_pandas_df_index, column, to_type ): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") bf_result = scalars_df_index[column].astype(to_type).to_pandas() pd_result = scalars_pandas_df_index[column].astype(to_type) pd.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) @@ -3578,6 +3984,131 @@ def test_timestamp_astype_string(): assert bf_result.dtype == "string[pyarrow]" +@pytest.mark.parametrize("errors", ["raise", "null"]) +def test_float_astype_json(errors): + data = ["1.25", "2500000000", None, "-12323.24"] + bf_series = series.Series(data, dtype=dtypes.FLOAT_DTYPE) + + bf_result = bf_series.astype(dtypes.JSON_DTYPE, errors=errors) + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected_result = pd.Series(data, dtype=dtypes.JSON_DTYPE) + expected_result.index = expected_result.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected_result) + + +def test_float_astype_json_str(): + data = ["1.25", "2500000000", None, "-12323.24"] + bf_series = series.Series(data, dtype=dtypes.FLOAT_DTYPE) + + bf_result = bf_series.astype("json") + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected_result = pd.Series(data, dtype=dtypes.JSON_DTYPE) + expected_result.index = expected_result.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected_result) + + +@pytest.mark.parametrize("errors", ["raise", "null"]) +def test_string_astype_json(errors): + data = [ + "1", + None, + '["1","3","5"]', + '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}', + ] + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) + + bf_result = bf_series.astype(dtypes.JSON_DTYPE, errors=errors) + assert bf_result.dtype == dtypes.JSON_DTYPE + + pd_result = bf_series.to_pandas().astype(dtypes.JSON_DTYPE) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +def test_string_astype_json_in_safe_mode(): + data = ["this is not a valid json string"] + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) + bf_result = bf_series.astype(dtypes.JSON_DTYPE, errors="null") + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected = pd.Series([None], dtype=dtypes.JSON_DTYPE) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) + + +def test_string_astype_json_raise_error(): + data = ["this is not a valid json string"] + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) + with pytest.raises( + google.api_core.exceptions.BadRequest, + match="syntax error while parsing value", + ): + bf_series.astype(dtypes.JSON_DTYPE, errors="raise").to_pandas() + + +@pytest.mark.parametrize("errors", ["raise", "null"]) +@pytest.mark.parametrize( + ("data", "to_type"), + [ + pytest.param(["1", "10.0", None], dtypes.INT_DTYPE, id="to_int"), + pytest.param(["0.0001", "2500000000", None], dtypes.FLOAT_DTYPE, id="to_float"), + pytest.param(["true", "false", None], dtypes.BOOL_DTYPE, id="to_bool"), + pytest.param(['"str"', None], dtypes.STRING_DTYPE, id="to_string"), + pytest.param( + ['"str"', None], + dtypes.TIME_DTYPE, + id="invalid", + marks=pytest.mark.xfail(raises=TypeError), + ), + ], +) +def test_json_astype_others(data, to_type, errors): + bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE) + + bf_result = bf_series.astype(to_type, errors=errors) + assert bf_result.dtype == to_type + + load_data = [json.loads(item) if item is not None else None for item in data] + expected = pd.Series(load_data, dtype=to_type) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) + + +@pytest.mark.parametrize( + ("data", "to_type"), + [ + pytest.param(["10.2", None], dtypes.INT_DTYPE, id="to_int"), + pytest.param(["false", None], dtypes.FLOAT_DTYPE, id="to_float"), + pytest.param(["10.2", None], dtypes.BOOL_DTYPE, id="to_bool"), + pytest.param(["true", None], dtypes.STRING_DTYPE, id="to_string"), + ], +) +def test_json_astype_others_raise_error(data, to_type): + bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE) + with pytest.raises(google.api_core.exceptions.BadRequest): + bf_series.astype(to_type, errors="raise").to_pandas() + + +@pytest.mark.parametrize( + ("data", "to_type"), + [ + pytest.param(["10.2", None], dtypes.INT_DTYPE, id="to_int"), + pytest.param(["false", None], dtypes.FLOAT_DTYPE, id="to_float"), + pytest.param(["10.2", None], dtypes.BOOL_DTYPE, id="to_bool"), + pytest.param(["true", None], dtypes.STRING_DTYPE, id="to_string"), + ], +) +def test_json_astype_others_in_safe_mode(data, to_type): + bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE) + bf_result = bf_series.astype(to_type, errors="null") + assert bf_result.dtype == to_type + + expected = pd.Series([None, None], dtype=to_type) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) + + @pytest.mark.parametrize( "index", [0, 5, -2], @@ -3589,9 +4120,7 @@ def test_iloc_single_integer(scalars_df_index, scalars_pandas_df_index, index): assert bf_result == pd_result -def test_iloc_single_integer_out_of_bound_error( - scalars_df_index, scalars_pandas_df_index -): +def test_iloc_single_integer_out_of_bound_error(scalars_df_index): with pytest.raises(IndexError, match="single positional indexer is out-of-bounds"): scalars_df_index.string_col.iloc[99] @@ -3616,7 +4145,7 @@ def test_loc_bool_series_default_index( scalars_pandas_df_default_index.bool_col ] - assert_pandas_df_equal( + assert_frame_equal( bf_result.to_frame(), pd_result.to_frame(), ) @@ -3861,15 +4390,17 @@ def test_series_bool_interpretation_error(scalars_df_index): def test_query_job_setters(scalars_dfs): - job_ids = set() - df, _ = scalars_dfs - series = df["int64_col"] - assert series.query_job is not None - repr(series) - job_ids.add(series.query_job.job_id) - series.to_pandas() - job_ids.add(series.query_job.job_id) - assert len(job_ids) == 2 + # if allow_large_results=False, might not create query job + with bigframes.option_context("compute.allow_large_results", True): + job_ids = set() + df, _ = scalars_dfs + series = df["int64_col"] + assert series.query_job is not None + repr(series) + job_ids.add(series.query_job.job_id) + series.to_pandas() + job_ids.add(series.query_job.job_id) + assert len(job_ids) == 2 @pytest.mark.parametrize( @@ -4029,13 +4560,16 @@ def test_apply_lambda(scalars_dfs, col, lambda_): bf_result = bf_col.apply(lambda_, by_row=False).to_pandas() pd_col = scalars_pandas_df[col] - if pd.__version__.startswith("2.2"): + if pd.__version__[:3] in ("2.2", "2.3"): pd_result = pd_col.apply(lambda_, by_row=False) else: pd_result = pd_col.apply(lambda_) # ignore dtype check, which are Int64 and object respectively - assert_series_equal(bf_result, pd_result, check_dtype=False) + # Some columns implicitly convert to floating point. Use check_exact=False to ensure we're "close enough" + assert_series_equal( + bf_result, pd_result, check_dtype=False, check_exact=False, rtol=0.001 + ) @pytest.mark.parametrize( @@ -4119,13 +4653,16 @@ def foo(x): pd_col = scalars_pandas_df["int64_col"] - if pd.__version__.startswith("2.2"): + if pd.__version__[:3] in ("2.2", "2.3"): pd_result = pd_col.apply(foo, by_row=False) else: pd_result = pd_col.apply(foo) # ignore dtype check, which are Int64 and object respectively - assert_series_equal(bf_result, pd_result, check_dtype=False) + # Some columns implicitly convert to floating point. Use check_exact=False to ensure we're "close enough" + assert_series_equal( + bf_result, pd_result, check_dtype=False, check_exact=False, rtol=0.001 + ) @pytest.mark.parametrize( @@ -4281,6 +4818,21 @@ def test_series_explode_w_aggregate(): assert s.explode().sum() == pd_s.explode().sum() +def test_series_construct_empty_array(): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + s = bigframes.pandas.Series([[]]) + expected = pd.Series( + [[]], + dtype=pd.ArrowDtype(pa.list_(pa.float64())), + index=pd.Index([0], dtype=pd.Int64Dtype()), + ) + pd.testing.assert_series_equal( + expected, + s.to_pandas(), + ) + + @pytest.mark.parametrize( ("data"), [ @@ -4299,7 +4851,6 @@ def test_series_explode_null(data): ) -@skip_legacy_pandas @pytest.mark.parametrize( ("append", "level", "col", "rule"), [ @@ -4309,24 +4860,26 @@ def test_series_explode_null(data): pytest.param(True, "timestamp_col", "timestamp_col", "1YE"), ], ) -def test__resample(scalars_df_index, scalars_pandas_df_index, append, level, col, rule): +def test_resample(scalars_df_index, scalars_pandas_df_index, append, level, col, rule): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") scalars_df_index = scalars_df_index.set_index(col, append=append)["int64_col"] scalars_pandas_df_index = scalars_pandas_df_index.set_index(col, append=append)[ "int64_col" ] - bf_result = scalars_df_index._resample(rule=rule, level=level).min().to_pandas() + bf_result = scalars_df_index.resample(rule=rule, level=level).min().to_pandas() pd_result = scalars_pandas_df_index.resample(rule=rule, level=level).min() pd.testing.assert_series_equal(bf_result, pd_result) def test_series_struct_get_field_by_attribute( - nested_structs_df, nested_structs_pandas_df, nested_structs_pandas_type + nested_structs_df, nested_structs_pandas_df ): if Version(pd.__version__) < Version("2.2.0"): pytest.skip("struct accessor is not supported before pandas 2.2") bf_series = nested_structs_df["person"] - df_series = nested_structs_pandas_df["person"].astype(nested_structs_pandas_type) + df_series = nested_structs_pandas_df["person"] pd.testing.assert_series_equal( bf_series.address.city.to_pandas(), @@ -4355,3 +4908,51 @@ def test_series_struct_class_attributes_shadow_struct_fields(nested_structs_df): series = nested_structs_df["person"] assert series.name == "person" + + +def test_series_to_pandas_dry_run(scalars_df_index): + bf_series = scalars_df_index["int64_col"] + + result = bf_series.to_pandas(dry_run=True) + + assert isinstance(result, pd.Series) + assert len(result) > 0 + + +def test_series_item(session): + # Test with a single item + bf_s_single = bigframes.pandas.Series([42], session=session) + pd_s_single = pd.Series([42]) + assert bf_s_single.item() == pd_s_single.item() + + +def test_series_item_with_multiple(session): + # Test with multiple items + bf_s_multiple = bigframes.pandas.Series([1, 2, 3], session=session) + pd_s_multiple = pd.Series([1, 2, 3]) + + try: + pd_s_multiple.item() + except ValueError as e: + expected_message = str(e) + else: + raise AssertionError("Expected ValueError from pandas, but didn't get one") + + with pytest.raises(ValueError, match=re.escape(expected_message)): + bf_s_multiple.item() + + +def test_series_item_with_empty(session): + # Test with an empty Series + bf_s_empty = bigframes.pandas.Series([], dtype="Int64", session=session) + pd_s_empty = pd.Series([], dtype="Int64") + + try: + pd_s_empty.item() + except ValueError as e: + expected_message = str(e) + else: + raise AssertionError("Expected ValueError from pandas, but didn't get one") + + with pytest.raises(ValueError, match=re.escape(expected_message)): + bf_s_empty.item() diff --git a/tests/system/small/test_series_io.py b/tests/system/small/test_series_io.py new file mode 100644 index 0000000000..426679d37d --- /dev/null +++ b/tests/system/small/test_series_io.py @@ -0,0 +1,127 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import numpy +import numpy.testing +import pandas as pd +import pytest + +import bigframes +import bigframes.series + + +def test_to_pandas_override_global_option(scalars_df_index): + with bigframes.option_context("compute.allow_large_results", True): + + bf_series = scalars_df_index["int64_col"] + + # Direct call to_pandas uses global default setting (allow_large_results=True) + bf_series.to_pandas() + table_id = bf_series._query_job.destination.table_id + assert table_id is not None + + session = bf_series._block.session + execution_count = session._metrics.execution_count + + # When allow_large_results=False, a query_job object should not be created. + # Therefore, the table_id should remain unchanged. + bf_series.to_pandas(allow_large_results=False) + assert bf_series._query_job.destination.table_id == table_id + assert session._metrics.execution_count - execution_count == 1 + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + pytest.param( + {"sampling_method": "head"}, + r"DEPRECATED[\S\s]*sampling_method[\S\s]*Series.sample", + id="sampling_method", + ), + pytest.param( + {"random_state": 10}, + r"DEPRECATED[\S\s]*random_state[\S\s]*Series.sample", + id="random_state", + ), + pytest.param( + {"max_download_size": 10}, + r"DEPRECATED[\S\s]*max_download_size[\S\s]*Series.to_pandas_batches", + id="max_download_size", + ), + ], +) +def test_to_pandas_warns_deprecated_parameters(scalars_df_index, kwargs, message): + s: bigframes.series.Series = scalars_df_index["int64_col"] + with pytest.warns(FutureWarning, match=message): + s.to_pandas( + # limits only apply for allow_large_result=True + allow_large_results=True, + **kwargs, + ) + + +@pytest.mark.parametrize( + ("page_size", "max_results", "allow_large_results"), + [ + pytest.param(None, None, True), + pytest.param(2, None, False), + pytest.param(None, 1, True), + pytest.param(2, 5, False), + pytest.param(3, 6, True), + pytest.param(3, 100, False), + pytest.param(100, 100, True), + ], +) +def test_to_pandas_batches(scalars_dfs, page_size, max_results, allow_large_results): + scalars_df, scalars_pandas_df = scalars_dfs + bf_series = scalars_df["int64_col"] + pd_series = scalars_pandas_df["int64_col"] + + total_rows = 0 + expected_total_rows = ( + min(max_results, len(pd_series)) if max_results else len(pd_series) + ) + + hit_last_page = False + for s in bf_series.to_pandas_batches( + page_size=page_size, + max_results=max_results, + allow_large_results=allow_large_results, + ): + assert not hit_last_page + + actual_rows = s.shape[0] + expected_rows = ( + min(page_size, expected_total_rows) if page_size else expected_total_rows + ) + + assert actual_rows <= expected_rows + if actual_rows < expected_rows: + assert page_size + hit_last_page = True + + pd.testing.assert_series_equal( + s, pd_series[total_rows : total_rows + actual_rows] + ) + total_rows += actual_rows + + assert total_rows == expected_total_rows + + +def test_to_numpy(scalars_dfs): + bf_df, pd_df = scalars_dfs + + bf_result = numpy.array(bf_df["int64_too"], dtype="int64") + pd_result = numpy.array(pd_df["int64_too"], dtype="int64") + + numpy.testing.assert_array_equal(bf_result, pd_result) diff --git a/tests/system/small/test_session.py b/tests/system/small/test_session.py index 0c8da52774..698f531d57 100644 --- a/tests/system/small/test_session.py +++ b/tests/system/small/test_session.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import io +import json import random import re import tempfile @@ -22,19 +23,83 @@ import warnings import bigframes_vendored.pandas.io.gbq as vendored_pandas_gbq -import db_dtypes # type: ignore +import db_dtypes # type:ignore import google import google.cloud.bigquery as bigquery import numpy as np import pandas as pd +import pandas.arrays as arrays +import pyarrow as pa import pytest import bigframes -import bigframes.core.indexes.base import bigframes.dataframe import bigframes.dtypes import bigframes.ml.linear_model -from tests.system import utils +import bigframes.session.execution_spec +from bigframes.testing import utils + +all_write_engines = pytest.mark.parametrize( + "write_engine", + [ + "default", + "bigquery_inline", + "bigquery_load", + "bigquery_streaming", + "bigquery_write", + ], +) + + +@pytest.fixture(scope="module") +def df_and_local_csv(scalars_df_index): + # The auto detects of BigQuery load job have restrictions to detect the bytes, + # datetime, numeric and geometry types, so they're skipped here. + drop_columns = [ + "bytes_col", + "datetime_col", + "numeric_col", + "geography_col", + "duration_col", + ] + scalars_df_index = scalars_df_index.drop(columns=drop_columns) + + with tempfile.TemporaryDirectory() as dir: + # Prepares local CSV file for reading + path = dir + "/test_read_csv_w_local_csv.csv" + scalars_df_index.to_csv(path, index=True) + yield scalars_df_index, path + + +@pytest.fixture(scope="module") +def df_and_gcs_csv(scalars_df_index, gcs_folder): + # The auto detects of BigQuery load job have restrictions to detect the bytes, + # datetime, numeric and geometry types, so they're skipped here. + drop_columns = [ + "bytes_col", + "datetime_col", + "numeric_col", + "geography_col", + "duration_col", + ] + scalars_df_index = scalars_df_index.drop(columns=drop_columns) + + path = gcs_folder + "test_read_csv_w_gcs_csv*.csv" + read_path = utils.get_first_file_from_wildcard(path) + scalars_df_index.to_csv(path, index=True) + return scalars_df_index, read_path + + +@pytest.fixture(scope="module") +def df_and_gcs_csv_for_two_columns(scalars_df_index, gcs_folder): + # Some tests require only two columns to be present in the CSV file. + selected_cols = ["bool_col", "int64_col"] + scalars_df_index = scalars_df_index[selected_cols] + + path = gcs_folder + "df_and_gcs_csv_for_two_columns*.csv" + read_path = utils.get_first_file_from_wildcard(path) + scalars_df_index.to_csv(path, index=True) + return scalars_df_index, read_path def test_read_gbq_tokyo( @@ -44,13 +109,20 @@ def test_read_gbq_tokyo( tokyo_location: str, ): df = session_tokyo.read_gbq(scalars_table_tokyo, index_col=["rowindex"]) - result = df.sort_index().to_pandas() + df.sort_index(inplace=True) expected = scalars_pandas_df_index - result = session_tokyo._executor.execute(df._block.expr) - assert result.query_job.location == tokyo_location + # use_explicit_destination=True, otherwise might use path with no query_job + exec_result = session_tokyo._executor.execute( + df._block.expr, + bigframes.session.execution_spec.ExecutionSpec( + bigframes.session.execution_spec.CacheSpec(()), promise_under_10gb=False + ), + ) + assert exec_result.query_job is not None + assert exec_result.query_job.location == tokyo_location - assert len(expected) == result.total_rows + assert len(expected) == exec_result.batches().approx_total_rows @pytest.mark.parametrize( @@ -90,9 +162,7 @@ def test_read_gbq_w_unknown_column( ): with pytest.raises( ValueError, - match=re.escape( - "Column 'int63_col' of `columns` not found in this table. Did you mean 'int64_col'?" - ), + match=re.escape("Column 'int63_col' is not found. Did you mean 'int64_col'?"), ): session.read_gbq( scalars_table_id, @@ -130,9 +200,10 @@ def test_read_gbq_w_unknown_index_col( CONCAT(t.string_col, "_2") AS my_strings, t.int64_col > 0 AS my_bools, FROM `{scalars_table_id}` AS t + ORDER BY my_strings """, ["my_strings"], - id="string_index", + id="string_index_w_order_by", ), pytest.param( "SELECT GENERATE_UUID() AS uuid, 0 AS my_value FROM UNNEST(GENERATE_ARRAY(1, 20))", @@ -359,18 +430,63 @@ def test_read_gbq_w_max_results( assert bf_result.shape[0] == max_results -def test_read_gbq_w_script_no_select(session, dataset_id: str): - ddl = f""" - CREATE TABLE `{dataset_id}.test_read_gbq_w_ddl` ( - `col_a` INT64, - `col_b` STRING - ); +@pytest.mark.parametrize( + ("sql_template", "expected_statement_type"), + ( + pytest.param( + """ + CREATE OR REPLACE TABLE `{dataset_id}.test_read_gbq_w_ddl` ( + `col_a` INT64, + `col_b` STRING + ); + """, + "CREATE_TABLE", + id="ddl-create-table", + ), + pytest.param( + # From https://cloud.google.com/bigquery/docs/boosted-tree-classifier-tutorial + """ + CREATE OR REPLACE VIEW `{dataset_id}.test_read_gbq_w_create_view` + AS + SELECT + age, + workclass, + marital_status, + education_num, + occupation, + hours_per_week, + income_bracket, + CASE + WHEN MOD(functional_weight, 10) < 8 THEN 'training' + WHEN MOD(functional_weight, 10) = 8 THEN 'evaluation' + WHEN MOD(functional_weight, 10) = 9 THEN 'prediction' + END AS dataframe + FROM + `bigquery-public-data.ml_datasets.census_adult_income`; + """, + "CREATE_VIEW", + id="ddl-create-view", + ), + pytest.param( + """ + CREATE OR REPLACE TABLE `{dataset_id}.test_read_gbq_w_dml` ( + `col_a` INT64, + `col_b` STRING + ); - INSERT INTO `{dataset_id}.test_read_gbq_w_ddl` - VALUES (123, 'hello world'); - """ - df = session.read_gbq(ddl).to_pandas() - assert df["statement_type"][0] == "SCRIPT" + INSERT INTO `{dataset_id}.test_read_gbq_w_dml` + VALUES (123, 'hello world'); + """, + "SCRIPT", + id="dml", + ), + ), +) +def test_read_gbq_w_script_no_select( + session, dataset_id: str, sql_template: str, expected_statement_type: str +): + df = session.read_gbq(sql_template.format(dataset_id=dataset_id)).to_pandas() + assert df["statement_type"][0] == expected_statement_type def test_read_gbq_twice_with_same_timestamp(session, penguins_table_id): @@ -393,11 +509,13 @@ def test_read_gbq_twice_with_same_timestamp(session, penguins_table_id): @pytest.mark.parametrize( "source_table", [ - "bigframes-dev.thelook_ecommerce.orders", + # Wildcard tables + "bigquery-public-data.noaa_gsod.gsod194*", + # Materialized views "bigframes-dev.bigframes_tests_sys.base_table_mat_view", ], ) -def test_read_gbq_on_linked_dataset_warns(session, source_table): +def test_read_gbq_warns_time_travel_disabled(session, source_table): with warnings.catch_warnings(record=True) as warned: session.read_gbq(source_table, use_cache=False) assert len(warned) == 1 @@ -535,7 +653,7 @@ def test_read_gbq_wildcard( "query": { "useQueryCache": True, "maximumBytesBilled": "1000000000", - "timeoutMs": 10000, + "timeoutMs": 120_000, } }, pytest.param( @@ -548,7 +666,7 @@ def test_read_gbq_wildcard( pytest.param( {"query": {"useQueryCache": False, "maximumBytesBilled": "100"}}, marks=pytest.mark.xfail( - raises=google.api_core.exceptions.InternalServerError, + raises=google.api_core.exceptions.BadRequest, reason="Expected failure when the query exceeds the maximum bytes billed limit.", ), ), @@ -598,6 +716,144 @@ def test_read_gbq_external_table(session: bigframes.Session): assert df["i1"].max() == 99 +def test_read_gbq_w_json(session): + sql = """ + SELECT 0 AS id, JSON_OBJECT('boolean', True) AS json_col, + UNION ALL + SELECT 1, JSON_OBJECT('int', 100), + UNION ALL + SELECT 2, JSON_OBJECT('float', 0.98), + UNION ALL + SELECT 3, JSON_OBJECT('string', 'hello world'), + UNION ALL + SELECT 4, JSON_OBJECT('array', [8, 9, 10]), + UNION ALL + SELECT 5, JSON_OBJECT('null', null), + UNION ALL + SELECT 6, JSON_OBJECT('b', 2, 'a', 1), + UNION ALL + SELECT + 7, + JSON_OBJECT( + 'dict', + JSON_OBJECT( + 'int', 1, + 'array', [JSON_OBJECT('foo', 1), JSON_OBJECT('bar', 'hello')] + ) + ), + """ + df = session.read_gbq(sql, index_col="id") + + assert df.dtypes["json_col"] == pd.ArrowDtype(db_dtypes.JSONArrowType()) + + assert df["json_col"][0] == '{"boolean":true}' + assert df["json_col"][1] == '{"int":100}' + assert df["json_col"][2] == '{"float":0.98}' + assert df["json_col"][3] == '{"string":"hello world"}' + assert df["json_col"][4] == '{"array":[8,9,10]}' + assert df["json_col"][5] == '{"null":null}' + + # Verifies JSON strings preserve array order, regardless of dictionary key order. + assert df["json_col"][6] == '{"a":1,"b":2}' + assert df["json_col"][7] == '{"dict":{"array":[{"foo":1},{"bar":"hello"}],"int":1}}' + + +def test_read_gbq_w_json_and_compare_w_pandas_json(session): + df = session.read_gbq("SELECT JSON_OBJECT('foo', 10, 'bar', TRUE) AS json_col") + assert df.dtypes["json_col"] == pd.ArrowDtype(db_dtypes.JSONArrowType()) + + # These JSON strings are compatible with BigQuery's JSON storage, + pd_df = pd.DataFrame( + {"json_col": ['{"bar":true,"foo":10}']}, + dtype=pd.ArrowDtype(db_dtypes.JSONArrowType()), + ) + pd_df.index = pd_df.index.astype("Int64") + pd.testing.assert_series_equal(df.dtypes, pd_df.dtypes) + pd.testing.assert_series_equal(df["json_col"].to_pandas(), pd_df["json_col"]) + + +def test_read_gbq_w_json_in_struct(session): + """Avoid regressions for internal issue 381148539.""" + sql = """ + SELECT 0 AS id, STRUCT(JSON_OBJECT('boolean', True) AS data, 1 AS number) AS struct_col + UNION ALL + SELECT 1, STRUCT(JSON_OBJECT('int', 100), 2), + UNION ALL + SELECT 2, STRUCT(JSON_OBJECT('float', 0.98), 3), + UNION ALL + SELECT 3, STRUCT(JSON_OBJECT('string', 'hello world'), 4), + UNION ALL + SELECT 4, STRUCT(JSON_OBJECT('array', [8, 9, 10]), 5), + UNION ALL + SELECT 5, STRUCT(JSON_OBJECT('null', null), 6), + UNION ALL + SELECT + 6, + STRUCT(JSON_OBJECT( + 'dict', + JSON_OBJECT( + 'int', 1, + 'array', [JSON_OBJECT('foo', 1), JSON_OBJECT('bar', 'hello')] + ) + ), 7), + """ + df = session.read_gbq(sql, index_col="id") + + assert isinstance(df.dtypes["struct_col"], pd.ArrowDtype) + assert isinstance(df.dtypes["struct_col"].pyarrow_dtype, pa.StructType) + + data = df["struct_col"].struct.field("data") + assert data.dtype == pd.ArrowDtype(db_dtypes.JSONArrowType()) + + assert data[0] == '{"boolean":true}' + assert data[1] == '{"int":100}' + assert data[2] == '{"float":0.98}' + assert data[3] == '{"string":"hello world"}' + assert data[4] == '{"array":[8,9,10]}' + assert data[5] == '{"null":null}' + assert data[6] == '{"dict":{"array":[{"foo":1},{"bar":"hello"}],"int":1}}' + + +def test_read_gbq_w_json_in_array(session): + sql = """ + SELECT + 0 AS id, + [ + JSON_OBJECT('boolean', True), + JSON_OBJECT('int', 100), + JSON_OBJECT('float', 0.98), + JSON_OBJECT('string', 'hello world'), + JSON_OBJECT('array', [8, 9, 10]), + JSON_OBJECT('null', null), + JSON_OBJECT( + 'dict', + JSON_OBJECT( + 'int', 1, + 'array', [JSON_OBJECT('bar', 'hello'), JSON_OBJECT('foo', 1)] + ) + ) + ] AS array_col, + """ + df = session.read_gbq(sql, index_col="id") + + assert isinstance(df.dtypes["array_col"], pd.ArrowDtype) + assert isinstance(df.dtypes["array_col"].pyarrow_dtype, pa.ListType) + + data = df["array_col"] + assert data.list.len()[0] == 7 + assert data.list[0].dtype == pd.ArrowDtype(db_dtypes.JSONArrowType()) + + assert data[0] == [ + '{"boolean":true}', + '{"int":100}', + '{"float":0.98}', + '{"string":"hello world"}', + '{"array":[8,9,10]}', + '{"null":null}', + '{"dict":{"array":[{"bar":"hello"},{"foo":1}],"int":1}}', + ] + + def test_read_gbq_model(session, penguins_linear_model_name): model = session.read_gbq_model(penguins_linear_model_name) assert isinstance(model, bigframes.ml.linear_model.LinearRegression) @@ -641,7 +897,7 @@ def test_read_pandas_inline_respects_location(): session = bigframes.Session(options) df = session.read_pandas(pd.DataFrame([[1, 2, 3], [4, 5, 6]])) - repr(df) + df.to_gbq() assert df.query_job is not None @@ -683,37 +939,40 @@ def test_read_pandas_tokyo( tokyo_location: str, ): df = session_tokyo.read_pandas(scalars_pandas_df_index) - result = df.to_pandas() + df.to_gbq() expected = scalars_pandas_df_index - result = session_tokyo._executor.execute(df._block.expr) + result = session_tokyo._executor.execute( + df._block.expr, + bigframes.session.execution_spec.ExecutionSpec( + bigframes.session.execution_spec.CacheSpec(()), promise_under_10gb=False + ), + ) + assert result.query_job is not None assert result.query_job.location == tokyo_location - assert len(expected) == result.total_rows + assert len(expected) == result.batches().approx_total_rows -@pytest.mark.parametrize( - "write_engine", - ["default", "bigquery_inline", "bigquery_load", "bigquery_streaming"], -) +@all_write_engines def test_read_pandas_timedelta_dataframes(session, write_engine): - expected_df = pd.DataFrame({"my_col": pd.to_timedelta([1, 2, 3], unit="d")}) - - actual_result = ( - session.read_pandas(expected_df, write_engine=write_engine) - .to_pandas() - .astype("timedelta64[ns]") + pytest.importorskip( + "pandas", + minversion="2.0.0", + reason="old versions don't support local casting to arrow duration", ) + pandas_df = pd.DataFrame({"my_col": pd.to_timedelta([1, 2, 3], unit="d")}) - if write_engine == "bigquery_streaming": - expected_df.index = pd.Index([pd.NA] * 3, dtype="Int64") - pd.testing.assert_frame_equal(actual_result, expected_df, check_index_type=False) + actual_result = session.read_pandas( + pandas_df, write_engine=write_engine + ).to_pandas() + expected_result = pandas_df.astype(bigframes.dtypes.TIMEDELTA_DTYPE) + expected_result.index = expected_result.index.astype(bigframes.dtypes.INT_DTYPE) + pd.testing.assert_frame_equal(actual_result, expected_result) -@pytest.mark.parametrize( - "write_engine", - ["default", "bigquery_inline", "bigquery_load", "bigquery_streaming"], -) + +@all_write_engines def test_read_pandas_timedelta_series(session, write_engine): expected_series = pd.Series(pd.to_timedelta([1, 2, 3], unit="d")) @@ -723,17 +982,12 @@ def test_read_pandas_timedelta_series(session, write_engine): .astype("timedelta64[ns]") ) - if write_engine == "bigquery_streaming": - expected_series.index = pd.Index([pd.NA] * 3, dtype="Int64") pd.testing.assert_series_equal( actual_result, expected_series, check_index_type=False ) -@pytest.mark.parametrize( - "write_engine", - ["default", "bigquery_inline", "bigquery_load"], -) +@all_write_engines def test_read_pandas_timedelta_index(session, write_engine): expected_index = pd.to_timedelta( [1, 2, 3], unit="d" @@ -748,47 +1002,34 @@ def test_read_pandas_timedelta_index(session, write_engine): pd.testing.assert_index_equal(actual_result, expected_index) -@pytest.mark.parametrize( - ("write_engine"), - [ - pytest.param("default"), - pytest.param("bigquery_load"), - pytest.param("bigquery_streaming"), - pytest.param("bigquery_inline", marks=pytest.mark.xfail(raises=ValueError)), - ], -) +@all_write_engines def test_read_pandas_json_dataframes(session, write_engine): json_data = [ - 1, + "1", None, - ["1", "3", "5"], - {"a": 1, "b": ["x", "y"], "c": {"z": False, "x": []}}, + '["1","3","5"]', + '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}', ] expected_df = pd.DataFrame( - {"my_col": pd.Series(json_data, dtype=db_dtypes.JSONDtype())} + {"my_col": pd.Series(json_data, dtype=bigframes.dtypes.JSON_DTYPE)} ) actual_result = session.read_pandas( expected_df, write_engine=write_engine ).to_pandas() - if write_engine == "bigquery_streaming": - expected_df.index = pd.Index([pd.NA] * 4, dtype="Int64") pd.testing.assert_frame_equal(actual_result, expected_df, check_index_type=False) -@pytest.mark.parametrize( - "write_engine", - ["default", "bigquery_load"], -) +@all_write_engines def test_read_pandas_json_series(session, write_engine): json_data = [ - 1, + "1", None, - ["1", "3", "5"], - {"a": 1, "b": ["x", "y"], "c": {"z": False, "x": []}}, + '[1,"3",null,{"a":null}]', + '{"a":1,"b":["x","y"],"c":{"x":[],"y":null,"z":false}}', ] - expected_series = pd.Series(json_data, dtype=db_dtypes.JSONDtype()) + expected_series = pd.Series(json_data, dtype=bigframes.dtypes.JSON_DTYPE) actual_result = session.read_pandas( expected_series, write_engine=write_engine @@ -798,119 +1039,191 @@ def test_read_pandas_json_series(session, write_engine): ) -@pytest.mark.parametrize( - ("write_engine"), - [ - pytest.param("default"), - pytest.param("bigquery_load"), - ], -) +@all_write_engines +def test_read_pandas_json_series_w_invalid_json(session, write_engine): + json_data = [ + "False", # Should be "false" + ] + pd_s = pd.Series(json_data, dtype=bigframes.dtypes.JSON_DTYPE) + + with pytest.raises(json.JSONDecodeError): + session.read_pandas(pd_s, write_engine=write_engine) + + +@all_write_engines def test_read_pandas_json_index(session, write_engine): json_data = [ - 1, + "1", None, - ["1", "3", "5"], - {"a": 1, "b": ["x", "y"], "c": {"z": False, "x": []}}, + '["1","3","5"]', + '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}', ] - expected_index = pd.Index(json_data, dtype=db_dtypes.JSONDtype()) + expected_index: pd.Index = pd.Index(json_data, dtype=bigframes.dtypes.JSON_DTYPE) actual_result = session.read_pandas( expected_index, write_engine=write_engine ).to_pandas() pd.testing.assert_index_equal(actual_result, expected_index) -@utils.skip_legacy_pandas @pytest.mark.parametrize( - ("write_engine",), - ( - ("default",), - ("bigquery_inline",), - ("bigquery_load",), - ("bigquery_streaming",), - ), + ("write_engine"), + [ + pytest.param("bigquery_load"), + ], ) -def test_read_csv_gcs_default_engine(session, scalars_dfs, gcs_folder, write_engine): - scalars_df, _ = scalars_dfs - path = gcs_folder + "test_read_csv_gcs_default_engine_w_index*.csv" - read_path = utils.get_first_file_from_wildcard(path) - scalars_df.to_csv(path, index=False) - dtype = scalars_df.dtypes.to_dict() - dtype.pop("geography_col") - df = session.read_csv( - read_path, - # Convert default pandas dtypes to match BigQuery DataFrames dtypes. - dtype=dtype, - write_engine=write_engine, +def test_read_pandas_w_nested_json_fails(session, write_engine): + data = [ + [{"json_field": "1"}], + [{"json_field": None}], + [{"json_field": '["1","3","5"]'}], + [{"json_field": '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}'}], + ] + # PyArrow currently lacks support for creating structs or lists containing extension types. + # See issue: https://github.com/apache/arrow/issues/45262 + pa_array = pa.array(data, type=pa.list_(pa.struct([("json_field", pa.string())]))) + pd_s = pd.Series( + arrays.ArrowExtensionArray(pa_array), # type: ignore + dtype=pd.ArrowDtype( + pa.list_(pa.struct([("json_field", bigframes.dtypes.JSON_ARROW_TYPE)])) + ), ) + with pytest.raises(NotImplementedError, match="Nested JSON types, found in column"): + session.read_pandas(pd_s, write_engine=write_engine) - # TODO(chelsealin): If we serialize the index, can more easily compare values. - pd.testing.assert_index_equal(df.columns, scalars_df.columns) - # The auto detects of BigQuery load job have restrictions to detect the bytes, - # numeric and geometry types, so they're skipped here. - df = df.drop(columns=["bytes_col", "numeric_col", "geography_col"]) - scalars_df = scalars_df.drop(columns=["bytes_col", "numeric_col", "geography_col"]) - assert df.shape[0] == scalars_df.shape[0] - pd.testing.assert_series_equal(df.dtypes, scalars_df.dtypes) +@pytest.mark.parametrize( + ("write_engine"), + [ + pytest.param("default"), + pytest.param("bigquery_inline"), + pytest.param("bigquery_streaming"), + pytest.param("bigquery_write"), + ], +) +def test_read_pandas_w_nested_json(session, write_engine): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + data = [ + [{"json_field": "1"}], + [{"json_field": None}], + [{"json_field": '["1","3","5"]'}], + [{"json_field": '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}'}], + ] + pa_array = pa.array(data, type=pa.list_(pa.struct([("json_field", pa.string())]))) + pd_s = pd.Series( + arrays.ArrowExtensionArray(pa_array), # type: ignore + dtype=pd.ArrowDtype( + pa.list_(pa.struct([("json_field", bigframes.dtypes.JSON_ARROW_TYPE)])) + ), + ) + bq_s = ( + session.read_pandas(pd_s, write_engine=write_engine) + .to_pandas() + .reset_index(drop=True) + ) + pd.testing.assert_series_equal(bq_s, pd_s) -def test_read_csv_gcs_bq_engine(session, scalars_dfs, gcs_folder): - scalars_df, _ = scalars_dfs - path = gcs_folder + "test_read_csv_gcs_bq_engine_w_index*.csv" - scalars_df.to_csv(path, index=False) - df = session.read_csv( - path, - engine="bigquery", - index_col=bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64, +@pytest.mark.parametrize( + ("write_engine"), + [ + pytest.param("default"), + pytest.param("bigquery_inline"), + pytest.param("bigquery_load"), + pytest.param("bigquery_streaming"), + ], +) +def test_read_pandas_w_nested_invalid_json(session, write_engine): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + data = [ + [{"json_field": "NULL"}], # Should be "null" + ] + pa_array = pa.array(data, type=pa.list_(pa.struct([("json_field", pa.string())]))) + pd_s = pd.Series( + arrays.ArrowExtensionArray(pa_array), # type: ignore + dtype=pd.ArrowDtype( + pa.list_(pa.struct([("json_field", bigframes.dtypes.JSON_ARROW_TYPE)])) + ), ) - # TODO(chelsealin): If we serialize the index, can more easily compare values. - pd.testing.assert_index_equal(df.columns, scalars_df.columns) + with pytest.raises(json.JSONDecodeError): + session.read_pandas(pd_s, write_engine=write_engine) - # The auto detects of BigQuery load job have restrictions to detect the bytes, - # datetime, numeric and geometry types, so they're skipped here. - df = df.drop(columns=["bytes_col", "datetime_col", "numeric_col", "geography_col"]) - scalars_df = scalars_df.drop( - columns=["bytes_col", "datetime_col", "numeric_col", "geography_col"] + +@pytest.mark.parametrize( + ("write_engine"), + [ + pytest.param("bigquery_load"), + ], +) +def test_read_pandas_w_nested_json_index_fails(session, write_engine): + data = [ + [{"json_field": "1"}], + [{"json_field": None}], + [{"json_field": '["1","3","5"]'}], + [{"json_field": '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}'}], + ] + # PyArrow currently lacks support for creating structs or lists containing extension types. + # See issue: https://github.com/apache/arrow/issues/45262 + pa_array = pa.array(data, type=pa.list_(pa.struct([("json_field", pa.string())]))) + pd_idx: pd.Index = pd.Index( + arrays.ArrowExtensionArray(pa_array), # type: ignore + dtype=pd.ArrowDtype( + pa.list_(pa.struct([("json_field", bigframes.dtypes.JSON_ARROW_TYPE)])) + ), ) - assert df.shape[0] == scalars_df.shape[0] - pd.testing.assert_series_equal(df.dtypes, scalars_df.dtypes) + with pytest.raises(NotImplementedError, match="Nested JSON types, found in"): + session.read_pandas(pd_idx, write_engine=write_engine) @pytest.mark.parametrize( - "sep", + ("write_engine"), [ - pytest.param(",", id="default_sep"), - pytest.param("\t", id="custom_sep"), + pytest.param("default"), + pytest.param("bigquery_inline"), + pytest.param("bigquery_streaming"), + pytest.param("bigquery_write"), ], ) -@utils.skip_legacy_pandas -def test_read_csv_local_default_engine(session, scalars_dfs, sep): - scalars_df, scalars_pandas_df = scalars_dfs - with tempfile.TemporaryDirectory() as dir: - path = dir + "/test_read_csv_local_default_engine.csv" - # Using the pandas to_csv method because the BQ one does not support local write. - scalars_pandas_df.to_csv(path, index=False, sep=sep) - dtype = scalars_df.dtypes.to_dict() - dtype.pop("geography_col") - df = session.read_csv( - path, - sep=sep, - # Convert default pandas dtypes to match BigQuery DataFrames dtypes. - dtype=dtype, - ) +def test_read_pandas_w_nested_json_index(session, write_engine): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + data = [ + [{"json_field": "1"}], + [{"json_field": None}], + [{"json_field": '["1","3","5"]'}], + [{"json_field": '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}'}], + ] + pa_array = pa.array(data, type=pa.list_(pa.struct([("name", pa.string())]))) + pd_idx: pd.Index = pd.Index( + arrays.ArrowExtensionArray(pa_array), # type: ignore + dtype=pd.ArrowDtype( + pa.list_(pa.struct([("name", bigframes.dtypes.JSON_ARROW_TYPE)])) + ), + ) + bq_idx = session.read_pandas(pd_idx, write_engine=write_engine).to_pandas() + pd.testing.assert_index_equal(bq_idx, pd_idx) + - # TODO(chelsealin): If we serialize the index, can more easily compare values. - pd.testing.assert_index_equal(df.columns, scalars_df.columns) +@all_write_engines +def test_read_csv_for_gcs_file_w_write_engine(session, df_and_gcs_csv, write_engine): + scalars_df, path = df_and_gcs_csv + + # Compares results for pandas and bigframes engines + pd_df = session.read_csv( + path, + index_col="rowindex", + write_engine=write_engine, + dtype=scalars_df.dtypes.to_dict(), + ) + pd.testing.assert_frame_equal(pd_df.to_pandas(), scalars_df.to_pandas()) - # The auto detects of BigQuery load job have restrictions to detect the bytes, - # numeric and geometry types, so they're skipped here. - df = df.drop(columns=["bytes_col", "numeric_col", "geography_col"]) - scalars_df = scalars_df.drop( - columns=["bytes_col", "numeric_col", "geography_col"] + if write_engine in ("default", "bigquery_load"): + bf_df = session.read_csv( + path, engine="bigquery", index_col="rowindex", write_engine=write_engine ) - assert df.shape[0] == scalars_df.shape[0] - pd.testing.assert_series_equal(df.dtypes, scalars_df.dtypes) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) @pytest.mark.parametrize( @@ -920,272 +1233,524 @@ def test_read_csv_local_default_engine(session, scalars_dfs, sep): pytest.param("\t", id="custom_sep"), ], ) -def test_read_csv_local_bq_engine(session, scalars_dfs, sep): - scalars_df, scalars_pandas_df = scalars_dfs - with tempfile.TemporaryDirectory() as dir: - path = dir + "/test_read_csv_local_bq_engine.csv" - # Using the pandas to_csv method because the BQ one does not support local write. - scalars_pandas_df.to_csv(path, index=False, sep=sep) - df = session.read_csv(path, engine="bigquery", sep=sep) - - # TODO(chelsealin): If we serialize the index, can more easily compare values. - pd.testing.assert_index_equal(df.columns, scalars_df.columns) - - # The auto detects of BigQuery load job have restrictions to detect the bytes, - # datetime, numeric and geometry types, so they're skipped here. - df = df.drop( - columns=["bytes_col", "datetime_col", "numeric_col", "geography_col"] - ) - scalars_df = scalars_df.drop( - columns=["bytes_col", "datetime_col", "numeric_col", "geography_col"] - ) - assert df.shape[0] == scalars_df.shape[0] - pd.testing.assert_series_equal(df.dtypes, scalars_df.dtypes) - +def test_read_csv_for_local_file_w_sep(session, df_and_local_csv, sep): + scalars_df, _ = df_and_local_csv -def test_read_csv_localbuffer_bq_engine(session, scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs with tempfile.TemporaryDirectory() as dir: - path = dir + "/test_read_csv_local_bq_engine.csv" - # Using the pandas to_csv method because the BQ one does not support local write. - scalars_pandas_df.to_csv(path, index=False) + # Prepares local CSV file for reading + path = dir + "/test_read_csv_for_local_file_w_sep.csv" + scalars_df.to_csv(path, index=True, sep=sep) + + # Compares results for pandas and bigframes engines + with open(path, "rb") as buffer: + bf_df = session.read_csv( + buffer, engine="bigquery", index_col="rowindex", sep=sep + ) with open(path, "rb") as buffer: - df = session.read_csv(buffer, engine="bigquery") + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv( + buffer, index_col="rowindex", sep=sep, dtype=scalars_df.dtypes.to_dict() + ) + pd.testing.assert_frame_equal(bf_df.to_pandas(), scalars_df.to_pandas()) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) - # TODO(chelsealin): If we serialize the index, can more easily compare values. - pd.testing.assert_index_equal(df.columns, scalars_df.columns) - # The auto detects of BigQuery load job have restrictions to detect the bytes, - # datetime, numeric and geometry types, so they're skipped here. - df = df.drop( - columns=["bytes_col", "datetime_col", "numeric_col", "geography_col"] +@pytest.mark.parametrize( + "index_col", + [ + pytest.param(None, id="none"), + pytest.param(False, id="false"), + pytest.param([], id="empty_list"), + ], +) +def test_read_csv_for_index_col_w_false(session, df_and_local_csv, index_col): + # Compares results for pandas and bigframes engines + scalars_df, path = df_and_local_csv + with open(path, "rb") as buffer: + bf_df = session.read_csv( + buffer, + engine="bigquery", + index_col=index_col, ) - scalars_df = scalars_df.drop( - columns=["bytes_col", "datetime_col", "numeric_col", "geography_col"] + with open(path, "rb") as buffer: + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv( + buffer, index_col=index_col, dtype=scalars_df.dtypes.to_dict() ) - assert df.shape[0] == scalars_df.shape[0] - pd.testing.assert_series_equal(df.dtypes, scalars_df.dtypes) + assert bf_df.shape == pd_df.shape -def test_read_csv_bq_engine_supports_index_col_false( - session, scalars_df_index, gcs_folder -): - path = gcs_folder + "test_read_csv_bq_engine_supports_index_col_false*.csv" - read_path = utils.get_first_file_from_wildcard(path) - scalars_df_index.to_csv(path) + # BigFrames requires `sort_index()` because BigQuery doesn't preserve row IDs + # (b/280889935) or guarantee row ordering. + bf_df = bf_df.set_index("rowindex").sort_index() + pd_df = pd_df.set_index("rowindex") + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) - df = session.read_csv( - read_path, - # Normally, pandas uses the first column as the index. index_col=False - # turns off that behavior. - index_col=False, + +@pytest.mark.parametrize( + "index_col", + [ + pytest.param("rowindex", id="single_str"), + pytest.param(["rowindex", "bool_col"], id="multi_str"), + pytest.param(0, id="single_int"), + pytest.param([0, 2], id="multi_int"), + pytest.param([0, "bool_col"], id="mix_types"), + ], +) +def test_read_csv_for_index_col(session, df_and_gcs_csv, index_col): + scalars_pandas_df, path = df_and_gcs_csv + bf_df = session.read_csv(path, engine="bigquery", index_col=index_col) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv( + path, index_col=index_col, dtype=scalars_pandas_df.dtypes.to_dict() ) - assert df.shape[0] == scalars_df_index.shape[0] - # We use a default index because of index_col=False, so the previous index - # column is just loaded as a column. - assert len(df.columns) == len(scalars_df_index.columns) + 1 + assert bf_df.shape == pd_df.shape + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) @pytest.mark.parametrize( - ("kwargs", "match"), + ("index_col", "error_type", "error_msg"), [ pytest.param( - {"chunksize": 5}, - "'chunksize' and 'iterator' arguments are not supported.", - id="with_chunksize", + True, ValueError, "The value of index_col couldn't be 'True'", id="true" ), + pytest.param(100, ValueError, "out of bounds", id="single_int"), + pytest.param([0, 200], ValueError, "out of bounds", id="multi_int"), pytest.param( - {"iterator": True}, - "'chunksize' and 'iterator' arguments are not supported.", - id="with_iterator", + [0.1], TypeError, "it must contain either strings", id="invalid_iterable" + ), + pytest.param( + 3.14, TypeError, "Unsupported type for index_col", id="unsupported_type" ), ], ) -def test_read_csv_default_engine_throws_not_implemented_error( - session, - scalars_df_index, - gcs_folder, - kwargs, - match, +def test_read_csv_raises_error_for_invalid_index_col( + session, df_and_gcs_csv, index_col, error_type, error_msg ): - path = ( - gcs_folder - + "test_read_csv_gcs_default_engine_throws_not_implemented_error*.csv" - ) - read_path = utils.get_first_file_from_wildcard(path) - scalars_df_index.to_csv(path) - with pytest.raises(NotImplementedError, match=match): - session.read_csv(read_path, **kwargs) + _, path = df_and_gcs_csv + with pytest.raises( + error_type, + match=error_msg, + ): + session.read_csv(path, engine="bigquery", index_col=index_col) -def test_read_csv_gcs_default_engine_w_header(session, scalars_df_index, gcs_folder): - path = gcs_folder + "test_read_csv_gcs_default_engine_w_header*.csv" - read_path = utils.get_first_file_from_wildcard(path) - scalars_df_index.to_csv(path) +def test_read_csv_for_gcs_wildcard_path(session, df_and_gcs_csv): + scalars_pandas_df, path = df_and_gcs_csv + path = path.replace(".csv", "*.csv") - # Skips header=N rows, normally considers the N+1th row as the header, but overridden by - # passing the `names` argument. In this case, pandas will skip the N+1th row too, take - # the column names from `names`, and begin reading data from the N+2th row. - df = session.read_csv( - read_path, - header=2, - names=scalars_df_index.columns.to_list(), + index_col = "rowindex" + bf_df = session.read_csv(path, engine="bigquery", index_col=index_col) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + # Also, `expand=True` is needed to read from wildcard paths. See details: + # https://github.com/fsspec/gcsfs/issues/616, + if not pd.__version__.startswith("1."): + storage_options = {"expand": True} + else: + storage_options = None + pd_df = session.read_csv( + path, + index_col=index_col, + dtype=scalars_pandas_df.dtypes.to_dict(), + storage_options=storage_options, ) - assert df.shape[0] == scalars_df_index.shape[0] - 2 - assert len(df.columns) == len(scalars_df_index.columns) + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) -def test_read_csv_gcs_bq_engine_w_header(session, scalars_df_index, gcs_folder): - path = gcs_folder + "test_read_csv_gcs_bq_engine_w_header*.csv" - scalars_df_index.to_csv(path, index=False) - # Skip the header and the first 2 data rows. Note that one line of header - # also got added while writing the csv through `to_csv`, so we would have to - # pass headers=3 in the `read_csv` to skip reading the header and two rows. - # Without provided schema, the column names would be like `bool_field_0`, - # `string_field_1` and etc. - df = session.read_csv(path, header=3, engine="bigquery") - assert df.shape[0] == scalars_df_index.shape[0] - 2 - assert len(df.columns) == len(scalars_df_index.columns) +def test_read_csv_for_names(session, df_and_gcs_csv_for_two_columns): + _, path = df_and_gcs_csv_for_two_columns + names = ["a", "b", "c"] + bf_df = session.read_csv(path, engine="bigquery", names=names) -def test_read_csv_local_default_engine_w_header(session, scalars_pandas_df_index): - with tempfile.TemporaryDirectory() as dir: - path = dir + "/test_read_csv_local_default_engine_w_header.csv" - # Using the pandas to_csv method because the BQ one does not support local write. - scalars_pandas_df_index.to_csv(path, index=False) + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv(path, names=names, dtype=bf_df.dtypes.to_dict()) - # Skips header=N rows. Normally row N+1 would be the header now, but overridden by - # passing the `names` argument. In this case, pandas will skip row N+1 too, infer - # the column names from `names`, and begin reading data from row N+2. - df = session.read_csv( - path, - header=2, - names=scalars_pandas_df_index.columns.to_list(), - ) - assert df.shape[0] == scalars_pandas_df_index.shape[0] - 2 - assert len(df.columns) == len(scalars_pandas_df_index.columns) + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() + # BigFrames requires `sort_index()` because BigQuery doesn't preserve row IDs + # (b/280889935) or guarantee row ordering. + bf_df = bf_df.set_index(names[0]).sort_index() + pd_df = pd_df.set_index(names[0]) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) -def test_read_csv_local_bq_engine_w_header(session, scalars_pandas_df_index): - with tempfile.TemporaryDirectory() as dir: - path = dir + "/test_read_csv_local_bq_engine_w_header.csv" - # Using the pandas to_csv method because the BQ one does not support local write. - scalars_pandas_df_index.to_csv(path, index=False) - # Skip the header and the first 2 data rows. Note that one line of - # header also got added while writing the csv through `to_csv`, so we - # would have to pass headers=3 in the `read_csv` to skip reading the - # header and two rows. Without provided schema, the column names would - # be like `bool_field_0`, `string_field_1` and etc. - df = session.read_csv(path, header=3, engine="bigquery") - assert df.shape[0] == scalars_pandas_df_index.shape[0] - 2 - assert len(df.columns) == len(scalars_pandas_df_index.columns) +def test_read_csv_for_names_more_than_columns_can_raise_error( + session, df_and_gcs_csv_for_two_columns +): + _, path = df_and_gcs_csv_for_two_columns + names = ["a", "b", "c", "d"] + with pytest.raises( + ValueError, + match="Too many columns specified: expected 3 and found 4", + ): + session.read_csv(path, engine="bigquery", names=names) + +def test_read_csv_for_names_less_than_columns(session, df_and_gcs_csv_for_two_columns): + _, path = df_and_gcs_csv_for_two_columns -def test_read_csv_gcs_default_engine_w_index_col_name( - session, scalars_df_default_index, gcs_folder + names = ["b", "c"] + bf_df = session.read_csv(path, engine="bigquery", names=names) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv(path, names=names, dtype=bf_df.dtypes.to_dict()) + + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() + + # Pandas's index name is None, while BigFrames's index name is "rowindex". + pd_df.index.name = "rowindex" + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + + +def test_read_csv_for_names_less_than_columns_raise_error_when_index_col_set( + session, df_and_gcs_csv_for_two_columns ): - path = gcs_folder + "test_read_csv_gcs_default_engine_w_index_col_name*.csv" - read_path = utils.get_first_file_from_wildcard(path) - scalars_df_default_index.to_csv(path) + _, path = df_and_gcs_csv_for_two_columns - df = session.read_csv(read_path, index_col="rowindex") - scalars_df_default_index = scalars_df_default_index.set_index( - "rowindex" - ).sort_index() - pd.testing.assert_index_equal(df.columns, scalars_df_default_index.columns) - assert df.index.name == "rowindex" + names = ["b", "c"] + with pytest.raises( + KeyError, + match="ensure the number of `names` matches the number of columns in your data.", + ): + session.read_csv(path, engine="bigquery", names=names, index_col="rowindex") -def test_read_csv_gcs_default_engine_w_index_col_index( - session, scalars_df_default_index, gcs_folder +@pytest.mark.parametrize( + "index_col", + [ + pytest.param("a", id="single_str"), + pytest.param(["a", "b"], id="multi_str"), + pytest.param(0, id="single_int"), + ], +) +def test_read_csv_for_names_and_index_col( + session, df_and_gcs_csv_for_two_columns, index_col ): - path = gcs_folder + "test_read_csv_gcs_default_engine_w_index_col_index*.csv" - read_path = utils.get_first_file_from_wildcard(path) - scalars_df_default_index.to_csv(path) + _, path = df_and_gcs_csv_for_two_columns + names = ["a", "b", "c"] + bf_df = session.read_csv(path, engine="bigquery", index_col=index_col, names=names) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv( + path, index_col=index_col, names=names, dtype=bf_df.dtypes.to_dict() + ) - index_col = scalars_df_default_index.columns.to_list().index("rowindex") - df = session.read_csv(read_path, index_col=index_col) - scalars_df_default_index = scalars_df_default_index.set_index( - "rowindex" - ).sort_index() - pd.testing.assert_index_equal(df.columns, scalars_df_default_index.columns) - assert df.index.name == "rowindex" + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() + pd.testing.assert_frame_equal( + bf_df.to_pandas(), pd_df.to_pandas(), check_index_type=False + ) -def test_read_csv_local_default_engine_w_index_col_name( - session, scalars_pandas_df_default_index +@pytest.mark.parametrize( + "usecols", + [ + pytest.param(["a", "b", "c"], id="same"), + pytest.param(["a", "c"], id="less_than_names"), + ], +) +def test_read_csv_for_names_and_usecols( + session, usecols, df_and_gcs_csv_for_two_columns ): - with tempfile.TemporaryDirectory() as dir: - path = dir + "/test_read_csv_local_default_engine_w_index_col_name" - # Using the pandas to_csv method because the BQ one does not support local write. - scalars_pandas_df_default_index.to_csv(path, index=False) - - df = session.read_csv(path, index_col="rowindex") - scalars_pandas_df_default_index = scalars_pandas_df_default_index.set_index( - "rowindex" - ).sort_index() - pd.testing.assert_index_equal( - df.columns, scalars_pandas_df_default_index.columns - ) - assert df.index.name == "rowindex" + _, path = df_and_gcs_csv_for_two_columns + + names = ["a", "b", "c"] + bf_df = session.read_csv(path, engine="bigquery", names=names, usecols=usecols) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv( + path, names=names, usecols=usecols, dtype=bf_df.dtypes.to_dict() + ) + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() -def test_read_csv_local_default_engine_w_index_col_index( - session, scalars_pandas_df_default_index + # BigFrames requires `sort_index()` because BigQuery doesn't preserve row IDs + # (b/280889935) or guarantee row ordering. + bf_df = bf_df.set_index(names[0]).sort_index() + pd_df = pd_df.set_index(names[0]) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + + +def test_read_csv_for_names_and_invalid_usecols( + session, df_and_gcs_csv_for_two_columns ): - with tempfile.TemporaryDirectory() as dir: - path = dir + "/test_read_csv_local_default_engine_w_index_col_index" - # Using the pandas to_csv method because the BQ one does not support local write. - scalars_pandas_df_default_index.to_csv(path, index=False) - - index_col = scalars_pandas_df_default_index.columns.to_list().index("rowindex") - df = session.read_csv(path, index_col=index_col) - scalars_pandas_df_default_index = scalars_pandas_df_default_index.set_index( - "rowindex" - ).sort_index() - pd.testing.assert_index_equal( - df.columns, scalars_pandas_df_default_index.columns - ) - assert df.index.name == "rowindex" + _, path = df_and_gcs_csv_for_two_columns + + names = ["a", "b", "c"] + usecols = ["a", "X"] + with pytest.raises( + ValueError, + match=re.escape("Column 'X' is not found. "), + ): + session.read_csv(path, engine="bigquery", names=names, usecols=usecols) @pytest.mark.parametrize( - "engine", + ("usecols", "index_col"), [ - pytest.param("bigquery", id="bq_engine"), - pytest.param(None, id="default_engine"), + pytest.param(["a", "b", "c"], "a", id="same"), + pytest.param(["a", "b", "c"], ["a", "b"], id="same_two_index"), + pytest.param(["a", "c"], 0, id="less_than_names"), ], ) -def test_read_csv_gcs_w_usecols(session, scalars_df_index, gcs_folder, engine): - path = gcs_folder + "test_read_csv_gcs_w_usecols" - path = path + "_default_engine*.csv" if engine is None else path + "_bq_engine*.csv" - read_path = utils.get_first_file_from_wildcard(path) if engine is None else path - scalars_df_index.to_csv(path) +def test_read_csv_for_names_and_usecols_and_indexcol( + session, usecols, index_col, df_and_gcs_csv_for_two_columns +): + _, path = df_and_gcs_csv_for_two_columns + + names = ["a", "b", "c"] + bf_df = session.read_csv( + path, engine="bigquery", names=names, usecols=usecols, index_col=index_col + ) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv( + path, + names=names, + usecols=usecols, + index_col=index_col, + dtype=bf_df.reset_index().dtypes.to_dict(), + ) + + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() + + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + + +def test_read_csv_for_names_less_than_columns_and_same_usecols( + session, df_and_gcs_csv_for_two_columns +): + _, path = df_and_gcs_csv_for_two_columns + names = ["a", "c"] + usecols = ["a", "c"] + bf_df = session.read_csv(path, engine="bigquery", names=names, usecols=usecols) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv( + path, names=names, usecols=usecols, dtype=bf_df.dtypes.to_dict() + ) + + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() - # df should only have 1 column which is bool_col. - df = session.read_csv(read_path, usecols=["bool_col"], engine=engine) - assert len(df.columns) == 1 + # BigFrames requires `sort_index()` because BigQuery doesn't preserve row IDs + # (b/280889935) or guarantee row ordering. + bf_df = bf_df.set_index(names[0]).sort_index() + pd_df = pd_df.set_index(names[0]) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + + +def test_read_csv_for_names_less_than_columns_and_mismatched_usecols( + session, df_and_gcs_csv_for_two_columns +): + _, path = df_and_gcs_csv_for_two_columns + names = ["a", "b"] + usecols = ["a"] + with pytest.raises( + ValueError, + match=re.escape("Number of passed names did not match number"), + ): + session.read_csv(path, engine="bigquery", names=names, usecols=usecols) + + +def test_read_csv_for_names_less_than_columns_and_different_usecols( + session, df_and_gcs_csv_for_two_columns +): + _, path = df_and_gcs_csv_for_two_columns + names = ["a", "b"] + usecols = ["a", "c"] + with pytest.raises( + ValueError, + match=re.escape("Usecols do not match columns"), + ): + session.read_csv(path, engine="bigquery", names=names, usecols=usecols) + + +def test_read_csv_for_dtype(session, df_and_gcs_csv_for_two_columns): + _, path = df_and_gcs_csv_for_two_columns + + dtype = {"bool_col": pd.BooleanDtype(), "int64_col": pd.Float64Dtype()} + bf_df = session.read_csv(path, engine="bigquery", dtype=dtype) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv(path, dtype=dtype) + + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() + + # BigFrames requires `sort_index()` because BigQuery doesn't preserve row IDs + # (b/280889935) or guarantee row ordering. + bf_df = bf_df.set_index("rowindex").sort_index() + pd_df = pd_df.set_index("rowindex") + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + + +def test_read_csv_for_dtype_w_names(session, df_and_gcs_csv_for_two_columns): + _, path = df_and_gcs_csv_for_two_columns + + names = ["a", "b", "c"] + dtype = {"b": pd.BooleanDtype(), "c": pd.Float64Dtype()} + bf_df = session.read_csv(path, engine="bigquery", names=names, dtype=dtype) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv(path, names=names, dtype=dtype) + + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() + + # BigFrames requires `sort_index()` because BigQuery doesn't preserve row IDs + # (b/280889935) or guarantee row ordering. + bf_df = bf_df.set_index("a").sort_index() + pd_df = pd_df.set_index("a") + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) @pytest.mark.parametrize( - "engine", + ("kwargs", "match"), [ - pytest.param("bigquery", id="bq_engine"), - pytest.param(None, id="default_engine"), + pytest.param( + {"chunksize": 5}, + "'chunksize' and 'iterator' arguments are not supported.", + id="with_chunksize", + ), + pytest.param( + {"iterator": True}, + "'chunksize' and 'iterator' arguments are not supported.", + id="with_iterator", + ), ], ) -def test_read_csv_local_w_usecols(session, scalars_pandas_df_index, engine): - with tempfile.TemporaryDirectory() as dir: - path = dir + "/test_read_csv_local_w_usecols.csv" - # Using the pandas to_csv method because the BQ one does not support local write. - scalars_pandas_df_index.to_csv(path, index=False) +def test_read_csv_default_engine_throws_not_implemented_error( + session, + scalars_df_index, + gcs_folder, + kwargs, + match, +): + path = ( + gcs_folder + + "test_read_csv_gcs_default_engine_throws_not_implemented_error*.csv" + ) + read_path = utils.get_first_file_from_wildcard(path) + scalars_df_index.to_csv(path) + with pytest.raises(NotImplementedError, match=match): + session.read_csv(read_path, **kwargs) + + +@pytest.mark.parametrize( + "header", + [0, 1, 5], +) +def test_read_csv_for_gcs_file_w_header(session, df_and_gcs_csv, header): + # Compares results for pandas and bigframes engines + scalars_df, path = df_and_gcs_csv + bf_df = session.read_csv(path, engine="bigquery", index_col=False, header=header) + pd_df = session.read_csv( + path, index_col=False, header=header, dtype=scalars_df.dtypes.to_dict() + ) + + # b/408461403: workaround the issue where the slice does not work for DataFrame. + expected_df = session.read_pandas(scalars_df.to_pandas()[header:]) + + assert pd_df.shape[0] == expected_df.shape[0] + assert bf_df.shape[0] == pd_df.shape[0] + + # We use a default index because of index_col=False, so the previous index + # column is just loaded as a column. + assert len(pd_df.columns) == len(expected_df.columns) + 1 + assert len(bf_df.columns) == len(pd_df.columns) + + # When `header > 0`, pandas and BigFrames may handle column naming differently. + # Pandas uses the literal content of the specified header row for column names, + # regardless of what it is. BigQuery, however, might generate default names based + # on data type (e.g.,bool_field_0,string_field_1, etc.). + if header == 0: + # BigFrames requires `sort_index()` because BigQuery doesn't preserve row IDs + # (b/280889935) or guarantee row ordering. + bf_df = bf_df.set_index("rowindex").sort_index() + pd_df = pd_df.set_index("rowindex") + pd.testing.assert_frame_equal(bf_df.to_pandas(), scalars_df.to_pandas()) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + + +def test_read_csv_w_usecols(session, df_and_local_csv): + # Compares results for pandas and bigframes engines + scalars_df, path = df_and_local_csv + usecols = ["rowindex", "bool_col"] + with open(path, "rb") as buffer: + bf_df = session.read_csv( + buffer, + engine="bigquery", + usecols=usecols, + ) + with open(path, "rb") as buffer: + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv( + buffer, + usecols=usecols, + dtype=scalars_df[["bool_col"]].dtypes.to_dict(), + ) + + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() + + # BigFrames requires `sort_index()` because BigQuery doesn't preserve row IDs + # (b/280889935) or guarantee row ordering. + bf_df = bf_df.set_index("rowindex").sort_index() + pd_df = pd_df.set_index("rowindex") + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + + +def test_read_csv_w_usecols_and_indexcol(session, df_and_local_csv): + # Compares results for pandas and bigframes engines + scalars_df, path = df_and_local_csv + usecols = ["rowindex", "bool_col"] + with open(path, "rb") as buffer: + bf_df = session.read_csv( + buffer, + engine="bigquery", + usecols=usecols, + index_col="rowindex", + ) + with open(path, "rb") as buffer: + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + pd_df = session.read_csv( + buffer, + usecols=usecols, + index_col="rowindex", + dtype=scalars_df[["bool_col"]].dtypes.to_dict(), + ) + + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() - # df should only have 1 column which is bool_col. - df = session.read_csv(path, usecols=["bool_col"], engine=engine) - assert len(df.columns) == 1 + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + + +def test_read_csv_w_indexcol_not_in_usecols(session, df_and_local_csv): + _, path = df_and_local_csv + with open(path, "rb") as buffer: + with pytest.raises( + ValueError, + match=re.escape("The specified index column(s) were not found"), + ): + session.read_csv( + buffer, + engine="bigquery", + usecols=["bool_col"], + index_col="rowindex", + ) @pytest.mark.parametrize( @@ -1201,36 +1766,34 @@ def test_read_csv_local_w_usecols(session, scalars_pandas_df_index, engine): pytest.param(None, id="default_engine"), ], ) -def test_read_csv_others(session, engine): +def test_read_csv_for_others_files(session, engine): uri = "https://raw.githubusercontent.com/googleapis/python-bigquery-dataframes/main/tests/data/people.csv" df = session.read_csv(uri, engine=engine) assert len(df.columns) == 3 -@pytest.mark.parametrize( - "engine", - [ - pytest.param("bigquery", id="bq_engine"), - pytest.param(None, id="default_engine"), - ], -) -def test_read_csv_local_w_encoding(session, penguins_pandas_df_default_index, engine): +def test_read_csv_local_w_encoding(session, penguins_pandas_df_default_index): with tempfile.TemporaryDirectory() as dir: path = dir + "/test_read_csv_local_w_encoding.csv" # Using the pandas to_csv method because the BQ one does not support local write. - penguins_pandas_df_default_index.to_csv( - path, index=False, encoding="ISO-8859-1" - ) + penguins_pandas_df_default_index.index.name = "rowindex" + penguins_pandas_df_default_index.to_csv(path, index=True, encoding="ISO-8859-1") # File can only be read using the same character encoding as when written. - df = session.read_csv(path, engine=engine, encoding="ISO-8859-1") - - # TODO(chelsealin): If we serialize the index, can more easily compare values. - pd.testing.assert_index_equal( - df.columns, penguins_pandas_df_default_index.columns + pd_df = session.read_csv( + path, + index_col="rowindex", + encoding="ISO-8859-1", + dtype=penguins_pandas_df_default_index.dtypes.to_dict(), ) - assert df.shape[0] == penguins_pandas_df_default_index.shape[0] + bf_df = session.read_csv( + path, engine="bigquery", index_col="rowindex", encoding="ISO-8859-1" + ) + pd.testing.assert_frame_equal( + bf_df.to_pandas(), penguins_pandas_df_default_index + ) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) def test_read_pickle_local(session, penguins_pandas_df_default_index, tmp_path): @@ -1333,6 +1896,7 @@ def test_read_parquet_gcs( df_out = df_out.assign( datetime_col=df_out["datetime_col"].astype("timestamp[us][pyarrow]"), timestamp_col=df_out["timestamp_col"].astype("timestamp[us, tz=UTC][pyarrow]"), + duration_col=df_out["duration_col"].astype("duration[us][pyarrow]"), ) # Make sure we actually have at least some values before comparing. @@ -1381,7 +1945,8 @@ def test_read_parquet_gcs_compressed( # DATETIME gets loaded as TIMESTAMP in parquet. See: # https://cloud.google.com/bigquery/docs/exporting-data#parquet_export_details df_out = df_out.assign( - datetime_col=df_out["datetime_col"].astype("timestamp[us][pyarrow]") + datetime_col=df_out["datetime_col"].astype("timestamp[us][pyarrow]"), + duration_col=df_out["duration_col"].astype("duration[us][pyarrow]"), ) # Make sure we actually have at least some values before comparing. @@ -1439,9 +2004,23 @@ def test_read_json_gcs_bq_engine(session, scalars_dfs, gcs_folder): # The auto detects of BigQuery load job have restrictions to detect the bytes, # datetime, numeric and geometry types, so they're skipped here. - df = df.drop(columns=["bytes_col", "datetime_col", "numeric_col", "geography_col"]) + df = df.drop( + columns=[ + "bytes_col", + "datetime_col", + "numeric_col", + "geography_col", + "duration_col", + ] + ) scalars_df = scalars_df.drop( - columns=["bytes_col", "datetime_col", "numeric_col", "geography_col"] + columns=[ + "bytes_col", + "datetime_col", + "numeric_col", + "geography_col", + "duration_col", + ] ) assert df.shape[0] == scalars_df.shape[0] pd.testing.assert_series_equal( @@ -1474,8 +2053,10 @@ def test_read_json_gcs_default_engine(session, scalars_dfs, gcs_folder): # The auto detects of BigQuery load job have restrictions to detect the bytes, # numeric and geometry types, so they're skipped here. - df = df.drop(columns=["bytes_col", "numeric_col", "geography_col"]) - scalars_df = scalars_df.drop(columns=["bytes_col", "numeric_col", "geography_col"]) + df = df.drop(columns=["bytes_col", "numeric_col", "geography_col", "duration_col"]) + scalars_df = scalars_df.drop( + columns=["bytes_col", "numeric_col", "geography_col", "duration_col"] + ) # pandas read_json does not respect the dtype overrides for these columns df = df.drop(columns=["date_col", "datetime_col", "time_col"]) @@ -1483,3 +2064,172 @@ def test_read_json_gcs_default_engine(session, scalars_dfs, gcs_folder): assert df.shape[0] == scalars_df.shape[0] pd.testing.assert_series_equal(df.dtypes, scalars_df.dtypes) + + +@pytest.mark.parametrize( + ("query_or_table", "index_col", "columns"), + [ + pytest.param( + "{scalars_table_id}", + ("int64_col", "string_col", "int64_col"), + ("float64_col", "bool_col"), + id="table_input_index_col_dup", + marks=pytest.mark.xfail( + raises=ValueError, + reason="ValueError: Duplicate names within 'index_col'.", + strict=True, + ), + ), + pytest.param( + """SELECT int64_col, string_col, float64_col, bool_col + FROM `{scalars_table_id}`""", + ("int64_col",), + ("string_col", "float64_col", "string_col"), + id="query_input_columns_dup", + marks=pytest.mark.xfail( + raises=ValueError, + reason="ValueError: Duplicate names within 'columns'.", + strict=True, + ), + ), + pytest.param( + "{scalars_table_id}", + ("int64_col", "string_col"), + ("float64_col", "string_col", "bool_col"), + id="table_input_cross_dup", + marks=pytest.mark.xfail( + raises=ValueError, + reason="ValueError: Overlap between 'index_col' and 'columns'.", + strict=True, + ), + ), + ], +) +def test_read_gbq_duplicate_columns_xfail( + session: bigframes.Session, + scalars_table_id: str, + query_or_table: str, + index_col: tuple, + columns: tuple, +): + session.read_gbq( + query_or_table.format(scalars_table_id=scalars_table_id), + index_col=index_col, + columns=columns, + ) + + +def test_read_gbq_with_table_ref_dry_run(scalars_table_id, session): + result = session.read_gbq(scalars_table_id, dry_run=True) + + assert isinstance(result, pd.Series) + _assert_table_dry_run_stats_are_valid(result) + + +def test_read_gbq_with_query_dry_run(scalars_table_id, session): + query = f"SELECT * FROM {scalars_table_id} LIMIT 10;" + result = session.read_gbq(query, dry_run=True) + + assert isinstance(result, pd.Series) + _assert_query_dry_run_stats_are_valid(result) + + +def test_read_gbq_dry_run_with_column_and_index(scalars_table_id, session): + query = f"SELECT * FROM {scalars_table_id} LIMIT 10;" + result = session.read_gbq( + query, dry_run=True, columns=["int64_col", "float64_col"], index_col="int64_too" + ) + + assert isinstance(result, pd.Series) + _assert_query_dry_run_stats_are_valid(result) + assert result["columnCount"] == 2 + assert result["columnDtypes"] == { + "int64_col": pd.Int64Dtype(), + "float64_col": pd.Float64Dtype(), + } + assert result["indexLevel"] == 1 + assert result["indexDtypes"] == [pd.Int64Dtype()] + + +def test_read_gbq_table_dry_run(scalars_table_id, session): + result = session.read_gbq_table(scalars_table_id, dry_run=True) + + assert isinstance(result, pd.Series) + _assert_table_dry_run_stats_are_valid(result) + + +def test_read_gbq_table_dry_run_with_max_results(scalars_table_id, session): + result = session.read_gbq_table(scalars_table_id, dry_run=True, max_results=100) + + assert isinstance(result, pd.Series) + _assert_query_dry_run_stats_are_valid(result) + + +def test_read_gbq_query_dry_run(scalars_table_id, session): + query = f"SELECT * FROM {scalars_table_id} LIMIT 10;" + result = session.read_gbq_query(query, dry_run=True) + + assert isinstance(result, pd.Series) + _assert_query_dry_run_stats_are_valid(result) + + +def test_block_dry_run_includes_local_data(session): + df1 = bigframes.dataframe.DataFrame({"col_1": [1, 2, 3]}, session=session) + df2 = bigframes.dataframe.DataFrame({"col_2": [1, 2, 3]}, session=session) + + result = df1.merge(df2, how="cross").to_pandas(dry_run=True) + + assert isinstance(result, pd.Series) + _assert_query_dry_run_stats_are_valid(result) + assert result["totalBytesProcessed"] > 0 + assert ( + df1.to_pandas(dry_run=True)["totalBytesProcessed"] + + df2.to_pandas(dry_run=True)["totalBytesProcessed"] + == result["totalBytesProcessed"] + ) + + +def _assert_query_dry_run_stats_are_valid(result: pd.Series): + expected_index = pd.Index( + [ + "columnCount", + "columnDtypes", + "indexLevel", + "indexDtypes", + "bigquerySchema", + "projectId", + "location", + "jobType", + "dispatchedSql", + "destinationTable", + "useLegacySql", + "referencedTables", + "totalBytesProcessed", + "cacheHit", + "statementType", + "creationTime", + ] + ) + + pd.testing.assert_index_equal(result.index, expected_index) + assert result["columnCount"] + result["indexLevel"] > 0 + + +def _assert_table_dry_run_stats_are_valid(result: pd.Series): + expected_index = pd.Index( + [ + "isQuery", + "columnCount", + "columnDtypes", + "bigquerySchema", + "numBytes", + "numRows", + "location", + "type", + "creationTime", + "lastModifiedTime", + ] + ) + + pd.testing.assert_index_equal(result.index, expected_index) + assert result["columnCount"] == len(result["columnDtypes"]) diff --git a/tests/system/small/test_session_as_bpd.py b/tests/system/small/test_session_as_bpd.py new file mode 100644 index 0000000000..e280c551cb --- /dev/null +++ b/tests/system/small/test_session_as_bpd.py @@ -0,0 +1,154 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Check that bpd and Session can be used interchangablely.""" + +from __future__ import annotations + +from typing import cast + +import numpy as np +import pandas.testing + +import bigframes.pandas as bpd +import bigframes.session + + +def test_cut(session: bigframes.session.Session): + sc = [30, 80, 40, 90, 60, 45, 95, 75, 55, 100, 65, 85] + x = [20, 40, 60, 80, 100] + + bpd_result = bpd.cut(sc, x) + session_result = session.cut(sc, x) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_series_equal(bpd_pd, session_pd) + + +def test_dataframe(session: bigframes.session.Session): + data = {"col": ["local", None, "data"]} + + bpd_result = bpd.DataFrame(data) + session_result = session.DataFrame(data) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_frame_equal(bpd_pd, session_pd) + + +def test_multiindex_from_arrays(session: bigframes.session.Session): + arrays = [[1, 1, 2, 2], ["red", "blue", "red", "blue"]] + + bpd_result = bpd.MultiIndex.from_arrays(arrays, names=("number", "color")) + session_result = session.MultiIndex.from_arrays(arrays, names=("number", "color")) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_index_equal(bpd_pd, session_pd) + + +def test_multiindex_from_tuples(session: bigframes.session.Session): + tuples = [(1, "red"), (1, "blue"), (2, "red"), (2, "blue")] + + bpd_result = bpd.MultiIndex.from_tuples(tuples, names=("number", "color")) + session_result = session.MultiIndex.from_tuples(tuples, names=("number", "color")) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_index_equal(bpd_pd, session_pd) + + +def test_index(session: bigframes.session.Session): + index = [1, 2, 3] + + bpd_result = bpd.Index(index) + session_result = session.Index(index) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_index_equal(bpd_pd, session_pd) + + +def test_series(session: bigframes.session.Session): + series = [1, 2, 3] + + bpd_result = bpd.Series(series) + session_result = session.Series(series) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_series_equal(bpd_pd, session_pd) + + +def test_to_datetime(session: bigframes.session.Session): + datetimes = ["2018-10-26 12:00:00", "2018-10-26 13:00:15"] + + bpd_result = bpd.to_datetime(datetimes) + session_result = cast(bpd.Series, session.to_datetime(datetimes)) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_series_equal(bpd_pd, session_pd) + + +def test_to_timedelta(session: bigframes.session.Session): + offsets = np.arange(5) + + bpd_result = bpd.to_timedelta(offsets, unit="s") + session_result = session.to_timedelta(offsets, unit="s") + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_series_equal(bpd_pd, session_pd) diff --git a/tests/system/small/test_unordered.py b/tests/system/small/test_unordered.py index 106997f3e9..c7ff0ca1dd 100644 --- a/tests/system/small/test_unordered.py +++ b/tests/system/small/test_unordered.py @@ -19,11 +19,7 @@ import bigframes.exceptions import bigframes.pandas as bpd -from tests.system.utils import ( - assert_pandas_df_equal, - assert_series_equal, - skip_legacy_pandas, -) +from bigframes.testing.utils import assert_frame_equal, assert_series_equal def test_unordered_mode_sql_no_hash(unordered_session): @@ -38,7 +34,7 @@ def test_unordered_mode_sql_no_hash(unordered_session): def test_unordered_mode_job_label(unordered_session): pd_df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}, dtype=pd.Int64Dtype()) df = bpd.DataFrame(pd_df, session=unordered_session) - df.to_pandas() + df.to_gbq() job_labels = df.query_job.labels # type:ignore assert "bigframes-mode" in job_labels assert job_labels["bigframes-mode"] == "unordered" @@ -52,7 +48,7 @@ def test_unordered_mode_cache_aggregate(unordered_session): bf_result = mean_diff.to_pandas(ordered=False) pd_result = pd_df - pd_df.mean() - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) # type: ignore def test_unordered_mode_series_peek(unordered_session): @@ -77,8 +73,9 @@ def test_unordered_mode_print(unordered_session): print(df) -@skip_legacy_pandas def test_unordered_mode_read_gbq(unordered_session): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") df = unordered_session.read_gbq( """SELECT [1, 3, 2] AS array_column, @@ -106,7 +103,7 @@ def test_unordered_mode_read_gbq(unordered_session): } ) # Don't need ignore_order as there is only 1 row - assert_pandas_df_equal(df.to_pandas(), expected) + assert_frame_equal(df.to_pandas(), expected, check_index_type=False) @pytest.mark.parametrize( @@ -127,7 +124,7 @@ def test_unordered_drop_duplicates(unordered_session, keep): bf_result = bf_df.drop_duplicates(keep=keep) pd_result = pd_df.drop_duplicates(keep=keep) - assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) + assert_frame_equal(bf_result.to_pandas(), pd_result, ignore_order=True) def test_unordered_reset_index(unordered_session): @@ -137,7 +134,7 @@ def test_unordered_reset_index(unordered_session): bf_result = bf_df.set_index("b").reset_index(drop=False) pd_result = pd_df.set_index("b").reset_index(drop=False) - assert_pandas_df_equal(bf_result.to_pandas(), pd_result) + assert_frame_equal(bf_result.to_pandas(), pd_result) def test_unordered_merge(unordered_session): @@ -149,7 +146,7 @@ def test_unordered_merge(unordered_session): bf_result = bf_df.merge(bf_df, left_on="a", right_on="c") pd_result = pd_df.merge(pd_df, left_on="a", right_on="c") - assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) + assert_frame_equal(bf_result.to_pandas(), pd_result, ignore_order=True) def test_unordered_drop_duplicates_ambiguous(unordered_session): @@ -170,7 +167,7 @@ def test_unordered_drop_duplicates_ambiguous(unordered_session): .drop_duplicates() ) - assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) + assert_frame_equal(bf_result.to_pandas(), pd_result, ignore_order=True) def test_unordered_mode_cache_preserves_order(unordered_session): @@ -184,7 +181,7 @@ def test_unordered_mode_cache_preserves_order(unordered_session): pd_result = pd_df.sort_values("b") # B is unique so unstrict order mode result here should be equivalent to strictly ordered - assert_pandas_df_equal(bf_result, pd_result, ignore_order=False) + assert_frame_equal(bf_result, pd_result, ignore_order=False) def test_unordered_mode_no_ordering_error(unordered_session): @@ -198,15 +195,13 @@ def test_unordered_mode_no_ordering_error(unordered_session): df.merge(df, on="a").head(3) -def test_unordered_mode_ambiguity_warning(unordered_session): +def test_unordered_mode_allows_ambiguity(unordered_session): pd_df = pd.DataFrame( {"a": [1, 2, 3, 4, 5, 1], "b": [4, 5, 9, 3, 1, 6]}, dtype=pd.Int64Dtype() ) pd_df.index = pd_df.index.astype(pd.Int64Dtype()) df = bpd.DataFrame(pd_df, session=unordered_session) - - with pytest.warns(bigframes.exceptions.AmbiguousWindowWarning): - df.merge(df, on="a").sort_values("b_x").head(3) + df.merge(df, on="a").sort_values("b_x").head(3) def test_unordered_mode_no_ambiguity_warning(unordered_session): @@ -221,7 +216,6 @@ def test_unordered_mode_no_ambiguity_warning(unordered_session): df.groupby("a").head(3) -@skip_legacy_pandas @pytest.mark.parametrize( ("rule", "origin", "data"), [ @@ -254,16 +248,46 @@ def test_unordered_mode_no_ambiguity_warning(unordered_session): ), ], ) -def test__resample_with_index(unordered_session, rule, origin, data): +def test_resample_with_index(unordered_session, rule, origin, data): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") col = "timestamp_col" scalars_df_index = bpd.DataFrame(data, session=unordered_session).set_index(col) scalars_pandas_df_index = pd.DataFrame(data).set_index(col) scalars_pandas_df_index.index.name = None - bf_result = scalars_df_index._resample(rule=rule, origin=origin).min().to_pandas() - + bf_result = scalars_df_index.resample(rule=rule, origin=origin).min() pd_result = scalars_pandas_df_index.resample(rule=rule, origin=origin).min() + assert isinstance(bf_result.index, bpd.DatetimeIndex) + assert isinstance(pd_result.index, pd.DatetimeIndex) pd.testing.assert_frame_equal( - bf_result, pd_result, check_dtype=False, check_index_type=False + bf_result.to_pandas(), + pd_result, + check_index_type=False, + check_dtype=False, ) + + +@pytest.mark.parametrize( + ("values", "index", "columns"), + [ + ("int64_col", "int64_too", ["string_col"]), + (["int64_col"], "int64_too", ["string_col"]), + (["int64_col", "float64_col"], "int64_too", ["string_col"]), + ], +) +def test_unordered_df_pivot( + scalars_df_unordered, scalars_pandas_df_index, values, index, columns +): + bf_result = scalars_df_unordered.pivot( + values=values, index=index, columns=columns + ).to_pandas() + pd_result = scalars_pandas_df_index.pivot( + values=values, index=index, columns=columns + ) + + # Pandas produces NaN, where bq dataframes produces pd.NA + bf_result = bf_result.fillna(float("nan")) + pd_result = pd_result.fillna(float("nan")) + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) diff --git a/tests/system/small/test_window.py b/tests/system/small/test_window.py index 2b9ec1a3c0..29ab581f76 100644 --- a/tests/system/small/test_window.py +++ b/tests/system/small/test_window.py @@ -12,9 +12,148 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime + +import numpy as np import pandas as pd import pytest +from bigframes import dtypes + + +@pytest.fixture(scope="module") +def rows_rolling_dfs(scalars_dfs): + bf_df, pd_df = scalars_dfs + + target_cols = ["int64_too", "float64_col", "int64_col"] + + return bf_df[target_cols], pd_df[target_cols] + + +@pytest.fixture(scope="module") +def range_rolling_dfs(session): + values = np.arange(20) + pd_df = pd.DataFrame( + { + "ts_col": pd.Timestamp("20250101", tz="UTC") + pd.to_timedelta(values, "s"), + "int_col": values % 4, + "float_col": values / 2, + } + ) + + bf_df = session.read_pandas(pd_df) + + return bf_df, pd_df + + +@pytest.fixture(scope="module") +def rows_rolling_series(scalars_dfs): + bf_df, pd_df = scalars_dfs + target_col = "int64_too" + + return bf_df[target_col], pd_df[target_col] + + +@pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) +def test_dataframe_rolling_closed_param(rows_rolling_dfs, closed): + bf_df, pd_df = rows_rolling_dfs + + actual_result = bf_df.rolling(window=3, closed=closed).sum().to_pandas() + + expected_result = pd_df.rolling(window=3, closed=closed).sum() + pd.testing.assert_frame_equal(actual_result, expected_result, check_dtype=False) + + +@pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) +def test_dataframe_groupby_rolling_closed_param(rows_rolling_dfs, closed): + bf_df, pd_df = rows_rolling_dfs + # Need to specify column subset for comparison due to b/406841327 + check_columns = ["float64_col", "int64_col"] + + actual_result = ( + bf_df.groupby(bf_df["int64_too"] % 2) + .rolling(window=3, closed=closed) + .sum() + .to_pandas() + ) + + expected_result = ( + pd_df.groupby(pd_df["int64_too"] % 2).rolling(window=3, closed=closed).sum() + ) + pd.testing.assert_frame_equal( + actual_result[check_columns], expected_result, check_dtype=False + ) + + +def test_dataframe_rolling_on(rows_rolling_dfs): + bf_df, pd_df = rows_rolling_dfs + + actual_result = bf_df.rolling(window=3, on="int64_too").sum().to_pandas() + + expected_result = pd_df.rolling(window=3, on="int64_too").sum() + pd.testing.assert_frame_equal(actual_result, expected_result, check_dtype=False) + + +def test_dataframe_rolling_on_invalid_column_raise_error(rows_rolling_dfs): + bf_df, _ = rows_rolling_dfs + + with pytest.raises(ValueError): + bf_df.rolling(window=3, on="whatever").sum() + + +def test_dataframe_groupby_rolling_on(rows_rolling_dfs): + bf_df, pd_df = rows_rolling_dfs + # Need to specify column subset for comparison due to b/406841327 + check_columns = ["float64_col", "int64_col"] + + actual_result = ( + bf_df.groupby(bf_df["int64_too"] % 2) + .rolling(window=3, on="float64_col") + .sum() + .to_pandas() + ) + + expected_result = ( + pd_df.groupby(pd_df["int64_too"] % 2).rolling(window=3, on="float64_col").sum() + ) + pd.testing.assert_frame_equal( + actual_result[check_columns], expected_result, check_dtype=False + ) + + +def test_dataframe_groupby_rolling_on_invalid_column_raise_error(rows_rolling_dfs): + bf_df, _ = rows_rolling_dfs + + with pytest.raises(ValueError): + bf_df.groupby(level=0).rolling(window=3, on="whatever").sum() + + +@pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) +def test_series_rolling_closed_param(rows_rolling_series, closed): + bf_series, df_series = rows_rolling_series + + actual_result = bf_series.rolling(window=3, closed=closed).sum().to_pandas() + + expected_result = df_series.rolling(window=3, closed=closed).sum() + pd.testing.assert_series_equal(actual_result, expected_result, check_dtype=False) + + +@pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) +def test_series_groupby_rolling_closed_param(rows_rolling_series, closed): + bf_series, df_series = rows_rolling_series + + actual_result = ( + bf_series.groupby(bf_series % 2) + .rolling(window=3, closed=closed) + .sum() + .to_pandas() + ) + + expected_result = ( + df_series.groupby(df_series % 2).rolling(window=3, closed=closed).sum() + ) + pd.testing.assert_series_equal(actual_result, expected_result, check_dtype=False) + @pytest.mark.parametrize( ("windowing"), @@ -41,20 +180,13 @@ pytest.param(lambda x: x.var(), id="var"), ], ) -def test_series_window_agg_ops( - scalars_df_index, scalars_pandas_df_index, windowing, agg_op -): - col_name = "int64_too" - bf_series = agg_op(windowing(scalars_df_index[col_name])).to_pandas() - pd_series = agg_op(windowing(scalars_pandas_df_index[col_name])) +def test_series_window_agg_ops(rows_rolling_series, windowing, agg_op): + bf_series, pd_series = rows_rolling_series - # Pandas always converts to float64, even for min/max/count, which is not desired - pd_series = pd_series.astype(bf_series.dtype) + actual_result = agg_op(windowing(bf_series)).to_pandas() - pd.testing.assert_series_equal( - pd_series, - bf_series, - ) + expected_result = agg_op(windowing(pd_series)) + pd.testing.assert_series_equal(expected_result, actual_result, check_dtype=False) @pytest.mark.parametrize( @@ -83,13 +215,252 @@ def test_series_window_agg_ops( pytest.param(lambda x: x.var(), id="var"), ], ) -def test_dataframe_window_agg_ops( - scalars_df_index, scalars_pandas_df_index, windowing, agg_op -): - scalars_df_index = scalars_df_index.set_index("bool_col") - scalars_pandas_df_index = scalars_pandas_df_index.set_index("bool_col") - col_names = ["int64_too", "float64_col"] - bf_result = agg_op(windowing(scalars_df_index[col_names])).to_pandas() - pd_result = agg_op(windowing(scalars_pandas_df_index[col_names])) +def test_dataframe_window_agg_ops(scalars_dfs, windowing, agg_op): + bf_df, pd_df = scalars_dfs + target_columns = ["int64_too", "float64_col", "bool_col"] + index_column = "bool_col" + bf_df = bf_df[target_columns].set_index(index_column) + pd_df = pd_df[target_columns].set_index(index_column) + + bf_result = agg_op(windowing(bf_df)).to_pandas() + + pd_result = agg_op(windowing(pd_df)) + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("windowing"), + [ + pytest.param(lambda x: x.expanding(), id="expanding"), + pytest.param(lambda x: x.rolling(3, min_periods=3), id="rolling"), + pytest.param( + lambda x: x.groupby(level=0).rolling(3, min_periods=3), id="rollinggroupby" + ), + pytest.param( + lambda x: x.groupby("int64_too").expanding(min_periods=2), + id="expandinggroupby", + ), + ], +) +@pytest.mark.parametrize( + ("func"), + [ + pytest.param("sum", id="sum_by_name"), + pytest.param(np.sum, id="sum_by_by_np"), + pytest.param([np.sum, np.mean], id="list_of_funcs"), + pytest.param( + {"int64_col": np.sum, "float64_col": "mean"}, id="dict_of_single_funcs" + ), + pytest.param( + {"int64_col": np.sum, "float64_col": ["mean", np.max]}, + id="dict_of_lists_and_single_funcs", + ), + ], +) +def test_dataframe_window_agg_func(scalars_dfs, windowing, func): + bf_df, pd_df = scalars_dfs + target_columns = ["int64_too", "float64_col", "bool_col", "int64_col"] + index_column = "bool_col" + bf_df = bf_df[target_columns].set_index(index_column) + pd_df = pd_df[target_columns].set_index(index_column) + + bf_result = windowing(bf_df).agg(func).to_pandas() + + pd_result = windowing(pd_df).agg(func) + + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) + + +def test_series_window_agg_single_func(scalars_dfs): + bf_df, pd_df = scalars_dfs + index_column = "bool_col" + bf_series = bf_df.set_index(index_column).int64_too + pd_series = pd_df.set_index(index_column).int64_too + + bf_result = bf_series.expanding().agg("sum").to_pandas() + + pd_result = pd_series.expanding().agg("sum") + + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + +def test_series_window_agg_multi_func(scalars_dfs): + bf_df, pd_df = scalars_dfs + index_column = "bool_col" + bf_series = bf_df.set_index(index_column).int64_too + pd_series = pd_df.set_index(index_column).int64_too + + bf_result = bf_series.expanding().agg(["sum", np.mean]).to_pandas() + + pd_result = pd_series.expanding().agg(["sum", np.mean]) pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) + + +@pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) +@pytest.mark.parametrize( + "window", # skipped numpy timedelta because Pandas does not support it. + [pd.Timedelta("3s"), datetime.timedelta(seconds=3), "3s"], +) +@pytest.mark.parametrize("ascending", [True, False]) +def test_series_range_rolling(range_rolling_dfs, window, closed, ascending): + bf_df, pd_df = range_rolling_dfs + bf_series = bf_df.set_index("ts_col")["int_col"] + pd_series = pd_df.set_index("ts_col")["int_col"] + + actual_result = ( + bf_series.sort_index(ascending=ascending) + .rolling(window=window, closed=closed) + .min() + .to_pandas() + ) + + expected_result = ( + pd_series.sort_index(ascending=ascending) + .rolling(window=window, closed=closed) + .min() + ) + pd.testing.assert_series_equal( + actual_result, expected_result, check_dtype=False, check_index=False + ) + + +def test_series_groupby_range_rolling(range_rolling_dfs): + bf_df, pd_df = range_rolling_dfs + bf_series = bf_df.set_index("ts_col")["int_col"] + pd_series = pd_df.set_index("ts_col")["int_col"] + + actual_result = ( + bf_series.sort_index() + .groupby(bf_series % 2 == 0) + .rolling(window="3s") + .min() + .to_pandas() + ) + + expected_result = ( + pd_series.sort_index().groupby(pd_series % 2 == 0).rolling(window="3s").min() + ) + pd.testing.assert_series_equal( + actual_result, expected_result, check_dtype=False, check_index=False + ) + + +@pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) +@pytest.mark.parametrize( + "window", # skipped numpy timedelta because Pandas does not support it. + [pd.Timedelta("3s"), datetime.timedelta(seconds=3), "3s"], +) +@pytest.mark.parametrize("ascending", [True, False]) +def test_dataframe_range_rolling(range_rolling_dfs, window, closed, ascending): + bf_df, pd_df = range_rolling_dfs + bf_df = bf_df.set_index("ts_col") + pd_df = pd_df.set_index("ts_col") + + actual_result = ( + bf_df.sort_index(ascending=ascending) + .rolling(window=window, closed=closed) + .min() + .to_pandas() + ) + + expected_result = ( + pd_df.sort_index(ascending=ascending) + .rolling(window=window, closed=closed) + .min() + ) + # Need to cast Pandas index type. Otherwise it uses DatetimeIndex that + # does not exist in BigFrame + expected_result.index = expected_result.index.astype(dtypes.TIMESTAMP_DTYPE) + pd.testing.assert_frame_equal( + actual_result, + expected_result, + check_dtype=False, + ) + + +def test_dataframe_range_rolling_on(range_rolling_dfs): + bf_df, pd_df = range_rolling_dfs + on = "ts_col" + + actual_result = bf_df.sort_values(on).rolling(window="3s", on=on).min().to_pandas() + + expected_result = pd_df.sort_values(on).rolling(window="3s", on=on).min() + # Need to specify the column order because Pandas (seemingly) + # re-arranges columns alphabetically + cols = ["ts_col", "int_col", "float_col"] + pd.testing.assert_frame_equal( + actual_result[cols], + expected_result[cols], + check_dtype=False, + check_index_type=False, + ) + + +def test_dataframe_groupby_range_rolling(range_rolling_dfs): + bf_df, pd_df = range_rolling_dfs + on = "ts_col" + + actual_result = ( + bf_df.sort_values(on) + .groupby("int_col") + .rolling(window="3s", on=on) + .min() + .to_pandas() + ) + + expected_result = ( + pd_df.sort_values(on).groupby("int_col").rolling(window="3s", on=on).min() + ) + expected_result.index = expected_result.index.set_names("index", level=1) + pd.testing.assert_frame_equal( + actual_result, + expected_result, + check_dtype=False, + check_index_type=False, + ) + + +def test_range_rolling_order_info_lookup(range_rolling_dfs): + bf_df, pd_df = range_rolling_dfs + + actual_result = ( + bf_df.set_index("ts_col") + .sort_index(ascending=False)["int_col"] + .isin(bf_df["int_col"]) + .rolling(window="3s") + .count() + .to_pandas() + ) + + expected_result = ( + pd_df.set_index("ts_col") + .sort_index(ascending=False)["int_col"] + .isin(pd_df["int_col"]) + .rolling(window="3s") + .count() + ) + pd.testing.assert_series_equal( + actual_result, expected_result, check_dtype=False, check_index=False + ) + + +def test_range_rolling_unsupported_index_type_raise_error(range_rolling_dfs): + bf_df, _ = range_rolling_dfs + + with pytest.raises(ValueError): + bf_df["int_col"].sort_index().rolling(window="3s") + + +def test_range_rolling_unsorted_index_raise_error(range_rolling_dfs): + bf_df, _ = range_rolling_dfs + + with pytest.raises(ValueError): + bf_df.set_index("ts_col")["int_col"].rolling(window="3s") + + +def test_range_rolling_unsorted_column_raise_error(range_rolling_dfs): + bf_df, _ = range_rolling_dfs + + with pytest.raises(ValueError): + bf_df.rolling(window="3s", on="ts_col") diff --git a/tests/unit/_config/test_bigquery_options.py b/tests/unit/_config/test_bigquery_options.py index 31f43ffee5..57486125b7 100644 --- a/tests/unit/_config/test_bigquery_options.py +++ b/tests/unit/_config/test_bigquery_options.py @@ -38,6 +38,7 @@ ("skip_bq_connection_check", False, True), ("client_endpoints_override", {}, {"bqclient": "endpoint_address"}), ("ordering_mode", "strict", "partial"), + ("requests_transport_adapters", object(), object()), ], ) def test_setter_raises_if_session_started(attribute, original_value, new_value): @@ -57,6 +58,18 @@ def test_setter_raises_if_session_started(attribute, original_value, new_value): assert getattr(options, attribute) is not new_value +def test_location_set_us_twice(): + """This test ensures the fix for b/423220936 is working as expected.""" + options = bigquery_options.BigQueryOptions() + setattr(options, "location", "us") + assert getattr(options, "location") == "US" + + options._session_started = True + + setattr(options, "location", "us") + assert getattr(options, "location") == "US" + + @pytest.mark.parametrize( [ "attribute", @@ -164,17 +177,34 @@ def set_location_property(): options.location = invalid_location for op in [set_location_in_constructor, set_location_property]: - with pytest.warns( - bigframes.exceptions.UnknownLocationWarning, - match=re.escape( - f"The location '{invalid_location}' is set to an unknown value. Did you mean '{possibility}'?" - ), - ): + with warnings.catch_warnings(record=True) as w: op() + assert issubclass( + w[0].category, bigframes.exceptions.UnknownLocationWarning + ) + assert ( + f"The location '{invalid_location}' is set to an unknown value. " + in str(w[0].message) + ) + # The message might contain newlines added by textwrap.fill. + assert possibility in str(w[0].message).replace("\n", "") + def test_client_endpoints_override_set_shows_warning(): options = bigquery_options.BigQueryOptions() with pytest.warns(UserWarning): options.client_endpoints_override = {"bqclient": "endpoint_address"} + + +def test_default_options(): + options = bigquery_options.BigQueryOptions() + + assert options.allow_large_results is False + assert options.ordering_mode == "strict" + + # We should default to None as an indicator that the user hasn't set these + # explicitly. See internal issue b/445731915. + assert options.credentials is None + assert options.project is None diff --git a/tests/unit/_config/test_compute_options.py b/tests/unit/_config/test_compute_options.py new file mode 100644 index 0000000000..e06eb76c37 --- /dev/null +++ b/tests/unit/_config/test_compute_options.py @@ -0,0 +1,22 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import bigframes._config as config + + +def test_default_options(): + options = config.compute_options.ComputeOptions() + + assert options.allow_large_results is None + assert config.options._allow_large_results is False diff --git a/tests/unit/_config/test_experiment_options.py b/tests/unit/_config/test_experiment_options.py index 8e612be06c..deeee2e46a 100644 --- a/tests/unit/_config/test_experiment_options.py +++ b/tests/unit/_config/test_experiment_options.py @@ -27,22 +27,22 @@ def test_semantic_operators_default_false(): def test_semantic_operators_set_true_shows_warning(): options = experiment_options.ExperimentOptions() - with pytest.warns(bfe.PreviewWarning): + with pytest.warns(FutureWarning): options.semantic_operators = True assert options.semantic_operators is True -def test_blob_default_false(): +def test_ai_operators_default_false(): options = experiment_options.ExperimentOptions() - assert options.blob is False + assert options.ai_operators is False -def test_blob_set_true_shows_warning(): +def test_ai_operators_set_true_shows_warning(): options = experiment_options.ExperimentOptions() with pytest.warns(bfe.PreviewWarning): - options.blob = True + options.ai_operators = True - assert options.blob is True + assert options.ai_operators is True diff --git a/tests/unit/_config/test_threaded_options.py b/tests/unit/_config/test_threaded_options.py index 7fc97a9f72..b16a3550bc 100644 --- a/tests/unit/_config/test_threaded_options.py +++ b/tests/unit/_config/test_threaded_options.py @@ -37,5 +37,5 @@ def mutate_options_threaded(options, result_dict): assert result_dict["this_before"] == 50 assert result_dict["this_after"] == 50 - assert result_dict["other_before"] == 25 + assert result_dict["other_before"] == 10 assert result_dict["other_after"] == 100 diff --git a/tests/unit/_tools/__init__.py b/tests/unit/_tools/__init__.py new file mode 100644 index 0000000000..378d15c4be --- /dev/null +++ b/tests/unit/_tools/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for helper methods for processing Python objects with minimal dependencies. + +Please keep the dependencies used in this subpackage to a minimum to avoid the +risk of circular dependencies. +""" diff --git a/tests/unit/_tools/test_strings.py b/tests/unit/_tools/test_strings.py new file mode 100644 index 0000000000..9c83df2556 --- /dev/null +++ b/tests/unit/_tools/test_strings.py @@ -0,0 +1,149 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for helper methods for processing strings with minimal dependencies. + +Please keep the dependencies used in this subpackage to a minimum to avoid the +risk of circular dependencies. +""" + +import base64 +import random +import sys +import uuid + +import pytest + +from bigframes._tools import strings + +# To stress test some unicode comparisons. +# https://stackoverflow.com/a/39682429/101923 +ALL_UNICODE_CHARS = "".join(chr(i) for i in range(32, 0x110000) if chr(i).isprintable()) +RANDOM_STRINGS = ( + pytest.param(str(uuid.uuid4()), id="uuid4"), + pytest.param(hex(random.randint(0, sys.maxsize)), id="hex"), + pytest.param( + base64.b64encode( + "".join(random.choice(ALL_UNICODE_CHARS) for _ in range(100)).encode( + "utf-8" + ) + ).decode("utf-8"), + id="base64", + ), + pytest.param( + "".join(random.choice(ALL_UNICODE_CHARS) for _ in range(8)), id="unicode8" + ), + pytest.param( + "".join(random.choice(ALL_UNICODE_CHARS) for _ in range(64)), id="unicode64" + ), +) + + +def random_char_not_equal(avoid: str): + random_char = avoid + while random_char == avoid: + random_char = random.choice(ALL_UNICODE_CHARS) + return random_char + + +def random_deletion(original: str): + """original string with one character removed""" + char_index = random.randrange(len(original)) + return original[:char_index] + original[char_index + 1 :] + + +def random_insertion(original: str): + char_index = random.randrange(len(original)) + random_char = random.choice(ALL_UNICODE_CHARS) + return original[: char_index + 1] + random_char + original[char_index + 1 :] + + +@pytest.mark.parametrize( + ("left", "right", "expected"), + ( + ("", "", 0), + ("abc", "abc", 0), + # Deletions + ("abcxyz", "abc", 3), + ("xyzabc", "abc", 3), + ("AXYZBC", "ABC", 3), + ("AXYZBC", "XYZ", 3), + # Insertions + ("abc", "abcxyz", 3), + ("abc", "xyzabc", 3), + # Substitutions + ("abc", "aBc", 1), + ("abcxyz", "aBcXyZ", 3), + # Combinations + ("abcdefxyz", "abcExyzα", 4), + ), +) +def test_levenshtein_distance(left: str, right: str, expected: int): + assert strings.levenshtein_distance(left, right) == expected + + +@pytest.mark.parametrize(("random_string",), RANDOM_STRINGS) +def test_levenshtein_distance_equal_strings(random_string: str): + """Mini fuzz test with different strings.""" + assert strings.levenshtein_distance(random_string, random_string) == 0 + + +@pytest.mark.parametrize(("random_string",), RANDOM_STRINGS) +def test_levenshtein_distance_random_deletion(random_string: str): + """Mini fuzz test with different strings.""" + + num_deleted = random.randrange(1, min(10, len(random_string))) + assert 1 <= num_deleted < len(random_string) + + deleted = random_string + for _ in range(num_deleted): + deleted = random_deletion(deleted) + + assert deleted != random_string + assert len(deleted) == len(random_string) - num_deleted + assert strings.levenshtein_distance(random_string, deleted) == num_deleted + + +@pytest.mark.parametrize(("random_string",), RANDOM_STRINGS) +def test_levenshtein_distance_random_insertion(random_string: str): + """Mini fuzz test with different strings.""" + + num_inserted = random.randrange(1, min(10, len(random_string))) + assert 1 <= num_inserted < len(random_string) + + inserted = random_string + for _ in range(num_inserted): + inserted = random_insertion(inserted) + + assert inserted != random_string + assert len(inserted) == len(random_string) + num_inserted + assert strings.levenshtein_distance(random_string, inserted) == num_inserted + + +@pytest.mark.parametrize(("random_string",), RANDOM_STRINGS) +def test_levenshtein_distance_random_substitution(random_string: str): + """Mini fuzz test with different strings. + + Note: we don't do multiple substitutions here to avoid accidentally + substituting the same character twice. + """ + char_index = random.randrange(len(random_string)) + replaced_char = random_string[char_index] + random_char = random_char_not_equal(replaced_char) + substituted = ( + random_string[:char_index] + random_char + random_string[char_index + 1 :] + ) + assert substituted != random_string + assert len(substituted) == len(random_string) + assert strings.levenshtein_distance(random_string, substituted) == 1 diff --git a/tests/unit/bigquery/__init__.py b/tests/unit/bigquery/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/tests/unit/bigquery/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unit/bigquery/test_json.py b/tests/unit/bigquery/test_json.py new file mode 100644 index 0000000000..d9beea26db --- /dev/null +++ b/tests/unit/bigquery/test_json.py @@ -0,0 +1,26 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest.mock as mock + +import pytest + +import bigframes.bigquery as bbq +import bigframes.pandas as bpd + + +def test_json_set_w_invalid_json_path_value_pairs(): + mock_series = mock.create_autospec(bpd.pandas.Series, instance=True) + with pytest.raises(ValueError, match="Incorrect format"): + bbq.json_set(mock_series, json_path_value_pairs=[("$.a", 1, 100)]) # type: ignore diff --git a/tests/unit/bigquery/test_ml.py b/tests/unit/bigquery/test_ml.py new file mode 100644 index 0000000000..063ddafcca --- /dev/null +++ b/tests/unit/bigquery/test_ml.py @@ -0,0 +1,147 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from unittest import mock + +import pandas as pd +import pytest + +import bigframes.bigquery._operations.ml as ml_ops +import bigframes.session + + +@pytest.fixture +def mock_session(): + return mock.create_autospec(spec=bigframes.session.Session) + + +MODEL_SERIES = pd.Series( + { + "modelReference": { + "projectId": "test-project", + "datasetId": "test-dataset", + "modelId": "test-model", + } + } +) + +MODEL_NAME = "test-project.test-dataset.test-model" + + +def test_get_model_name_and_session_with_pandas_series_model_input(): + model_name, _ = ml_ops._get_model_name_and_session(MODEL_SERIES) + assert model_name == MODEL_NAME + + +def test_get_model_name_and_session_with_pandas_series_model_input_missing_model_reference(): + model_series = pd.Series({"some_other_key": "value"}) + with pytest.raises( + ValueError, match="modelReference must be present in the pandas Series" + ): + ml_ops._get_model_name_and_session(model_series) + + +@mock.patch("bigframes.pandas.read_pandas") +def test_to_sql_with_pandas_dataframe(read_pandas_mock): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops._to_sql(df) + read_pandas_mock.assert_called_once() + + +@mock.patch("bigframes.bigquery._operations.ml._get_model_metadata") +@mock.patch("bigframes.pandas.read_pandas") +def test_create_model_with_pandas_dataframe( + read_pandas_mock, _get_model_metadata_mock, mock_session +): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops.create_model("model_name", training_data=df, session=mock_session) + read_pandas_mock.assert_called_once() + mock_session.read_gbq_query.assert_called_once() + generated_sql = mock_session.read_gbq_query.call_args[0][0] + assert "CREATE MODEL `model_name`" in generated_sql + assert "AS SELECT * FROM `pandas_df`" in generated_sql + + +@mock.patch("bigframes.pandas.read_gbq_query") +@mock.patch("bigframes.pandas.read_pandas") +def test_evaluate_with_pandas_dataframe(read_pandas_mock, read_gbq_query_mock): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops.evaluate(MODEL_SERIES, input_=df) + read_pandas_mock.assert_called_once() + read_gbq_query_mock.assert_called_once() + generated_sql = read_gbq_query_mock.call_args[0][0] + assert "ML.EVALUATE" in generated_sql + assert f"MODEL `{MODEL_NAME}`" in generated_sql + assert "(SELECT * FROM `pandas_df`)" in generated_sql + + +@mock.patch("bigframes.pandas.read_gbq_query") +@mock.patch("bigframes.pandas.read_pandas") +def test_predict_with_pandas_dataframe(read_pandas_mock, read_gbq_query_mock): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops.predict(MODEL_SERIES, input_=df) + read_pandas_mock.assert_called_once() + read_gbq_query_mock.assert_called_once() + generated_sql = read_gbq_query_mock.call_args[0][0] + assert "ML.PREDICT" in generated_sql + assert f"MODEL `{MODEL_NAME}`" in generated_sql + assert "(SELECT * FROM `pandas_df`)" in generated_sql + + +@mock.patch("bigframes.pandas.read_gbq_query") +@mock.patch("bigframes.pandas.read_pandas") +def test_explain_predict_with_pandas_dataframe(read_pandas_mock, read_gbq_query_mock): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops.explain_predict(MODEL_SERIES, input_=df) + read_pandas_mock.assert_called_once() + read_gbq_query_mock.assert_called_once() + generated_sql = read_gbq_query_mock.call_args[0][0] + assert "ML.EXPLAIN_PREDICT" in generated_sql + assert f"MODEL `{MODEL_NAME}`" in generated_sql + assert "(SELECT * FROM `pandas_df`)" in generated_sql + + +@mock.patch("bigframes.pandas.read_gbq_query") +def test_global_explain_with_pandas_series_model(read_gbq_query_mock): + ml_ops.global_explain(MODEL_SERIES) + read_gbq_query_mock.assert_called_once() + generated_sql = read_gbq_query_mock.call_args[0][0] + assert "ML.GLOBAL_EXPLAIN" in generated_sql + assert f"MODEL `{MODEL_NAME}`" in generated_sql diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000000..a9b26afeef --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,24 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + + +@pytest.fixture(scope="session") +def polars_session(): + pytest.importorskip("polars") + + from bigframes.testing import polars_session + + return polars_session.TestSession() diff --git a/tests/unit/core/compile/sqlglot/__init__.py b/tests/unit/core/compile/sqlglot/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unit/core/compile/sqlglot/aggregations/__init__.py b/tests/unit/core/compile/sqlglot/aggregations/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql new file mode 100644 index 0000000000..5c838f4882 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + CORR(`int64_col`, `float64_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `corr_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql new file mode 100644 index 0000000000..eda082250a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COVAR_SAMP(`int64_col`, `float64_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `cov_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql new file mode 100644 index 0000000000..f1197465f0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql @@ -0,0 +1,27 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ROW_NUMBER() OVER () - 1 AS `bfcol_32` + FROM `bfcte_0` +) +SELECT + `bfcol_32` AS `row_number` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql new file mode 100644 index 0000000000..bfa67b8a74 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) - 1 AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `row_number` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql new file mode 100644 index 0000000000..ed8e0c7619 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql @@ -0,0 +1,26 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COUNT(1) AS `bfcol_32` + FROM `bfcte_0` +) +SELECT + `bfcol_32` AS `size` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql new file mode 100644 index 0000000000..eafbc39daf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + ARRAY_AGG(`int64_col` IGNORE NULLS ORDER BY `int64_col` IS NULL ASC, `int64_col` ASC) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql new file mode 100644 index 0000000000..321341d4a0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COALESCE( + STRING_AGG(`string_col`, ',' + ORDER BY + `string_col` IS NULL ASC, + `string_col` ASC), + '' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql new file mode 100644 index 0000000000..d31b21f56b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COALESCE(LOGICAL_AND(`bool_col`), TRUE) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql new file mode 100644 index 0000000000..829e5a8836 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(LOGICAL_AND(`bool_col`) OVER (), TRUE) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql new file mode 100644 index 0000000000..23357817c1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(LOGICAL_AND(`bool_col`) OVER (PARTITION BY `string_col`), TRUE) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/out.sql new file mode 100644 index 0000000000..03b0d5c151 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COALESCE(LOGICAL_OR(`bool_col`), FALSE) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql new file mode 100644 index 0000000000..337f0ff963 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(LOGICAL_OR(`bool_col`) OVER (), FALSE) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql new file mode 100644 index 0000000000..4a13901f1c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + ANY_VALUE(`int64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql new file mode 100644 index 0000000000..ea15243d90 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ANY_VALUE(`int64_col`) OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql new file mode 100644 index 0000000000..e722318fbc --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ANY_VALUE(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql new file mode 100644 index 0000000000..9eabb2d88a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + APPROX_QUANTILES(`int64_col`, 4)[OFFSET(1)] AS `bfcol_1`, + APPROX_QUANTILES(`int64_col`, 4)[OFFSET(2)] AS `bfcol_2`, + APPROX_QUANTILES(`int64_col`, 4)[OFFSET(3)] AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `q1`, + `bfcol_2` AS `q2`, + `bfcol_3` AS `q3` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql new file mode 100644 index 0000000000..b5e6275381 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + APPROX_TOP_COUNT(`int64_col`, 10) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql new file mode 100644 index 0000000000..9d18367cf6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COUNT(`int64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql new file mode 100644 index 0000000000..0baac95311 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COUNT(`int64_col`) OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql new file mode 100644 index 0000000000..6d3f856459 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COUNT(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins.sql new file mode 100644 index 0000000000..015ac32799 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins.sql @@ -0,0 +1,55 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `int64_col` <= MIN(`int64_col`) OVER () + ( + 1 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + THEN STRUCT( + ( + MIN(`int64_col`) OVER () + ( + 0 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + ) - ( + ( + MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER () + ) * 0.001 + ) AS `left_exclusive`, + MIN(`int64_col`) OVER () + ( + 1 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + 0 AS `right_inclusive` + ) + WHEN `int64_col` <= MIN(`int64_col`) OVER () + ( + 2 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + THEN STRUCT( + ( + MIN(`int64_col`) OVER () + ( + 1 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + ) - 0 AS `left_exclusive`, + MIN(`int64_col`) OVER () + ( + 2 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + 0 AS `right_inclusive` + ) + WHEN `int64_col` IS NOT NULL + THEN STRUCT( + ( + MIN(`int64_col`) OVER () + ( + 2 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + ) - 0 AS `left_exclusive`, + MIN(`int64_col`) OVER () + ( + 3 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + 0 AS `right_inclusive` + ) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int_bins` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins_labels.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins_labels.sql new file mode 100644 index 0000000000..c98682f2b8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins_labels.sql @@ -0,0 +1,24 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `int64_col` < MIN(`int64_col`) OVER () + ( + 1 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + THEN 'a' + WHEN `int64_col` < MIN(`int64_col`) OVER () + ( + 2 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + THEN 'b' + WHEN `int64_col` IS NOT NULL + THEN 'c' + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int_bins_labels` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins.sql new file mode 100644 index 0000000000..a3e689b11e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `int64_col` > 0 AND `int64_col` <= 1 + THEN STRUCT(0 AS `left_exclusive`, 1 AS `right_inclusive`) + WHEN `int64_col` > 1 AND `int64_col` <= 2 + THEN STRUCT(1 AS `left_exclusive`, 2 AS `right_inclusive`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `interval_bins` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins_labels.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins_labels.sql new file mode 100644 index 0000000000..1a8a92e38e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins_labels.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `int64_col` > 0 AND `int64_col` <= 1 + THEN 0 + WHEN `int64_col` > 1 AND `int64_col` <= 2 + THEN 1 + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `interval_bins_labels` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql new file mode 100644 index 0000000000..76b455a65c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + DENSE_RANK() OVER (ORDER BY `int64_col` DESC) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_bool/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_bool/out.sql new file mode 100644 index 0000000000..96d23c4747 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_bool/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bool_col` <> LAG(`bool_col`, 1) OVER (ORDER BY `bool_col` DESC) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `diff_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_datetime/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_datetime/out.sql new file mode 100644 index 0000000000..9c279a479d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_datetime/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `datetime_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + DATETIME_DIFF( + `datetime_col`, + LAG(`datetime_col`, 1) OVER (ORDER BY `datetime_col` ASC NULLS LAST), + MICROSECOND + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `diff_datetime` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_int/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_int/out.sql new file mode 100644 index 0000000000..95d786b951 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_int/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `int64_col` - LAG(`int64_col`, 1) OVER (ORDER BY `int64_col` ASC NULLS LAST) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `diff_int` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_timestamp/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_timestamp/out.sql new file mode 100644 index 0000000000..1f8b8227b4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_timestamp/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TIMESTAMP_DIFF( + `timestamp_col`, + LAG(`timestamp_col`, 1) OVER (ORDER BY `timestamp_col` DESC), + MICROSECOND + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `diff_timestamp` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql new file mode 100644 index 0000000000..b053178f58 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FIRST_VALUE(`int64_col`) OVER ( + ORDER BY `int64_col` DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql new file mode 100644 index 0000000000..2ef7b7151e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FIRST_VALUE(`int64_col` IGNORE NULLS) OVER ( + ORDER BY `int64_col` ASC NULLS LAST + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql new file mode 100644 index 0000000000..61e90ee612 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LAST_VALUE(`int64_col`) OVER ( + ORDER BY `int64_col` DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql new file mode 100644 index 0000000000..c626c263ac --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LAST_VALUE(`int64_col` IGNORE NULLS) OVER ( + ORDER BY `int64_col` ASC NULLS LAST + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql new file mode 100644 index 0000000000..1537d735ea --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + MAX(`int64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql new file mode 100644 index 0000000000..f55201418a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + MAX(`int64_col`) OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql new file mode 100644 index 0000000000..ac9b2df84e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + MAX(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql new file mode 100644 index 0000000000..0b33d0b1d0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql @@ -0,0 +1,27 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `duration_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `int64_col` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `duration_col` AS `bfcol_8` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + AVG(`bfcol_6`) AS `bfcol_12`, + AVG(CAST(`bfcol_7` AS INT64)) AS `bfcol_13`, + CAST(FLOOR(AVG(`bfcol_8`)) AS INT64) AS `bfcol_14`, + CAST(FLOOR(AVG(`bfcol_6`)) AS INT64) AS `bfcol_15` + FROM `bfcte_1` +) +SELECT + `bfcol_12` AS `int64_col`, + `bfcol_13` AS `bool_col`, + `bfcol_14` AS `duration_col`, + `bfcol_15` AS `int64_col_w_floor` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql new file mode 100644 index 0000000000..fdb59809c3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AVG(`int64_col`) OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql new file mode 100644 index 0000000000..d96121e54d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AVG(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql new file mode 100644 index 0000000000..bfe94622b3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col`, + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + APPROX_QUANTILES(`int64_col`, 2)[OFFSET(1)] AS `bfcol_3`, + APPROX_QUANTILES(`date_col`, 2)[OFFSET(1)] AS `bfcol_4`, + APPROX_QUANTILES(`string_col`, 2)[OFFSET(1)] AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_3` AS `int64_col`, + `bfcol_4` AS `date_col`, + `bfcol_5` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql new file mode 100644 index 0000000000..0848313456 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + MIN(`int64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql new file mode 100644 index 0000000000..cbda2b7d58 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + MIN(`int64_col`) OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql new file mode 100644 index 0000000000..d601832950 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + MIN(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_nunique/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_nunique/out.sql new file mode 100644 index 0000000000..f0b54934b4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_nunique/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COUNT(DISTINCT `int64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql new file mode 100644 index 0000000000..2d38311f45 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + VAR_POP(`int64_col`) AS `bfcol_4`, + VAR_POP(CAST(`bool_col` AS INT64)) AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `int64_col`, + `bfcol_5` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql new file mode 100644 index 0000000000..430da33e3c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + VAR_POP(`int64_col`) OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/out.sql new file mode 100644 index 0000000000..bec1527137 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + CASE + WHEN LOGICAL_OR(`int64_col` = 0) + THEN 0 + ELSE EXP(SUM(CASE WHEN `int64_col` = 0 THEN 0 ELSE LN(ABS(`int64_col`)) END)) * IF(MOD(SUM(CASE WHEN SIGN(`int64_col`) < 0 THEN 1 ELSE 0 END), 2) = 1, -1, 1) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/window_partition_out.sql new file mode 100644 index 0000000000..9c1650222a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/window_partition_out.sql @@ -0,0 +1,27 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN LOGICAL_OR(`int64_col` = 0) OVER (PARTITION BY `string_col`) + THEN 0 + ELSE EXP( + SUM(CASE WHEN `int64_col` = 0 THEN 0 ELSE LN(ABS(`int64_col`)) END) OVER (PARTITION BY `string_col`) + ) * IF( + MOD( + SUM(CASE WHEN SIGN(`int64_col`) < 0 THEN 1 ELSE 0 END) OVER (PARTITION BY `string_col`), + 2 + ) = 1, + -1, + 1 + ) + END AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_qcut/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_qcut/out.sql new file mode 100644 index 0000000000..1aa2e436ca --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_qcut/out.sql @@ -0,0 +1,61 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + NOT `int64_col` IS NULL AS `bfcol_4` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + IF( + `int64_col` IS NULL, + NULL, + CAST(GREATEST( + CEIL(PERCENT_RANK() OVER (PARTITION BY `bfcol_4` ORDER BY `int64_col` ASC) * 4) - 1, + 0 + ) AS INT64) + ) AS `bfcol_5` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + IF(`bfcol_4`, `bfcol_5`, NULL) AS `bfcol_6` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + NOT `int64_col` IS NULL AS `bfcol_10` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + CASE + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) < 0 + THEN NULL + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) <= 0.25 + THEN 0 + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) <= 0.5 + THEN 1 + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) <= 0.75 + THEN 2 + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) <= 1 + THEN 3 + ELSE NULL + END AS `bfcol_11` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + IF(`bfcol_10`, `bfcol_11`, NULL) AS `bfcol_12` + FROM `bfcte_5` +) +SELECT + `rowindex`, + `int64_col`, + `bfcol_6` AS `qcut_w_int`, + `bfcol_12` AS `qcut_w_list` +FROM `bfcte_6` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql new file mode 100644 index 0000000000..b79d8d381f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + PERCENTILE_CONT(`int64_col`, 0.5) OVER () AS `bfcol_1`, + CAST(FLOOR(PERCENTILE_CONT(`int64_col`, 0.5) OVER ()) AS INT64) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `quantile`, + `bfcol_2` AS `quantile_floor` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql new file mode 100644 index 0000000000..96b121bde4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + RANK() OVER (ORDER BY `int64_col` DESC NULLS FIRST) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql new file mode 100644 index 0000000000..7d1d62f1ae --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LAG(`int64_col`, 1) OVER (ORDER BY `int64_col` ASC) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `lag` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql new file mode 100644 index 0000000000..67b40c99db --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LEAD(`int64_col`, 1) OVER (ORDER BY `int64_col` ASC) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `lead` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql new file mode 100644 index 0000000000..0202cf5c21 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `int64_col` AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `noop` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size_unary/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size_unary/out.sql new file mode 100644 index 0000000000..fffb4831b9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size_unary/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COUNT(1) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql new file mode 100644 index 0000000000..36a50302a6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql @@ -0,0 +1,27 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `duration_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `int64_col` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `duration_col` AS `bfcol_8` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + STDDEV(`bfcol_6`) AS `bfcol_12`, + STDDEV(CAST(`bfcol_7` AS INT64)) AS `bfcol_13`, + CAST(FLOOR(STDDEV(`bfcol_8`)) AS INT64) AS `bfcol_14`, + CAST(FLOOR(STDDEV(`bfcol_6`)) AS INT64) AS `bfcol_15` + FROM `bfcte_1` +) +SELECT + `bfcol_12` AS `int64_col`, + `bfcol_13` AS `bool_col`, + `bfcol_14` AS `duration_col`, + `bfcol_15` AS `int64_col_w_floor` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql new file mode 100644 index 0000000000..80e0cf5bc6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + STDDEV(`int64_col`) OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql new file mode 100644 index 0000000000..2bf6c26cd4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COALESCE(SUM(`int64_col`), 0) AS `bfcol_4`, + COALESCE(SUM(CAST(`bool_col` AS INT64)), 0) AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `int64_col`, + `bfcol_5` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql new file mode 100644 index 0000000000..47426abcbd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(SUM(`int64_col`) OVER (), 0) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql new file mode 100644 index 0000000000..fd1bd4f630 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(SUM(`int64_col`) OVER (PARTITION BY `string_col`), 0) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql new file mode 100644 index 0000000000..733a22438c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + VARIANCE(`int64_col`) AS `bfcol_4`, + VARIANCE(CAST(`bool_col` AS INT64)) AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `int64_col`, + `bfcol_5` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql new file mode 100644 index 0000000000..e9d6c1cb93 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + VARIANCE(`int64_col`) OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_binary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_binary_compiler.py new file mode 100644 index 0000000000..0897b535be --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/test_binary_compiler.py @@ -0,0 +1,54 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing + +import pytest + +from bigframes.core import agg_expressions as agg_exprs +from bigframes.core import array_value, identifiers, nodes +from bigframes.operations import aggregations as agg_ops +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def _apply_binary_agg_ops( + obj: bpd.DataFrame, + ops_list: typing.Sequence[agg_exprs.BinaryAggregation], + new_names: typing.Sequence[str], +) -> str: + aggs = [(op, identifiers.ColumnId(name)) for op, name in zip(ops_list, new_names)] + + agg_node = nodes.AggregateNode(obj._block.expr.node, aggregations=tuple(aggs)) + result = array_value.ArrayValue(agg_node) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def test_corr(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + agg_expr = agg_ops.CorrOp().as_expr("int64_col", "float64_col") + sql = _apply_binary_agg_ops(bf_df, [agg_expr], ["corr_col"]) + + snapshot.assert_match(sql, "out.sql") + + +def test_cov(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + agg_expr = agg_ops.CovOp().as_expr("int64_col", "float64_col") + sql = _apply_binary_agg_ops(bf_df, [agg_expr], ["cov_col"]) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py new file mode 100644 index 0000000000..f9ddf3e0c0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py @@ -0,0 +1,84 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing + +import pytest + +from bigframes.core import agg_expressions as agg_exprs +from bigframes.core import array_value, identifiers, nodes, ordering, window_spec +from bigframes.operations import aggregations as agg_ops +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def _apply_nullary_agg_ops( + obj: bpd.DataFrame, + ops_list: typing.Sequence[agg_exprs.NullaryAggregation], + new_names: typing.Sequence[str], +) -> str: + aggs = [(op, identifiers.ColumnId(name)) for op, name in zip(ops_list, new_names)] + + agg_node = nodes.AggregateNode(obj._block.expr.node, aggregations=tuple(aggs)) + result = array_value.ArrayValue(agg_node) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def _apply_nullary_window_op( + obj: bpd.DataFrame, + op: agg_exprs.NullaryAggregation, + window_spec: window_spec.WindowSpec, + new_name: str, +) -> str: + win_node = nodes.WindowOpNode( + obj._block.expr.node, + agg_exprs=(nodes.ColumnDef(op, identifiers.ColumnId(new_name)),), + window_spec=window_spec, + ) + result = array_value.ArrayValue(win_node).select_columns([new_name]) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def test_size(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + agg_expr = agg_ops.SizeOp().as_expr() + sql = _apply_nullary_agg_ops(bf_df, [agg_expr], ["size"]) + + snapshot.assert_match(sql, "out.sql") + + +def test_row_number(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + agg_expr = agg_exprs.NullaryAggregation(agg_ops.RowNumberOp()) + window = window_spec.WindowSpec() + sql = _apply_nullary_window_op(bf_df, agg_expr, window, "row_number") + + snapshot.assert_match(sql, "out.sql") + + +def test_row_number_with_window(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name, "int64_too"]] + agg_expr = agg_exprs.NullaryAggregation(agg_ops.RowNumberOp()) + + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + # window = window_spec.unbound(ordering=(ordering.ascending_over(col_name),ordering.ascending_over("int64_too"))) + sql = _apply_nullary_window_op(bf_df, agg_expr, window, "row_number") + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_op_registration.py b/tests/unit/core/compile/sqlglot/aggregations/test_op_registration.py new file mode 100644 index 0000000000..dbdeb2307e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/test_op_registration.py @@ -0,0 +1,44 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from sqlglot import expressions as sge + +from bigframes.core.compile.sqlglot.aggregations import op_registration +from bigframes.operations import aggregations as agg_ops + + +def test_register_then_get(): + reg = op_registration.OpRegistration() + input = sge.to_identifier("A") + op = agg_ops.SizeOp() + + @reg.register(agg_ops.SizeOp) + def test_func(op: agg_ops.SizeOp, input: sge.Expression) -> sge.Expression: + return input + + assert reg[agg_ops.SizeOp()](op, input) == test_func(op, input) + + +def test_register_function_first_argument_is_not_agg_op_raise_error(): + reg = op_registration.OpRegistration() + + @reg.register(agg_ops.SizeOp) + def test_func(input: sge.Expression) -> sge.Expression: + return input + + with pytest.raises( + ValueError, match=r".*first parameter must be a window operator.*" + ): + test_func(sge.to_identifier("A")) diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_ordered_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_ordered_unary_compiler.py new file mode 100644 index 0000000000..2f88fb5d0c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/test_ordered_unary_compiler.py @@ -0,0 +1,80 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import typing + +import pytest + +from bigframes.core import agg_expressions as agg_exprs +from bigframes.core import array_value, identifiers, nodes, ordering +from bigframes.operations import aggregations as agg_ops +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def _apply_ordered_unary_agg_ops( + obj: bpd.DataFrame, + ops_list: typing.Sequence[agg_exprs.UnaryAggregation], + new_names: typing.Sequence[str], + ordering_args: typing.Sequence[str], +) -> str: + ordering_exprs = tuple(ordering.ascending_over(arg) for arg in ordering_args) + aggs = [(op, identifiers.ColumnId(name)) for op, name in zip(ops_list, new_names)] + + agg_node = nodes.AggregateNode( + obj._block.expr.node, + aggregations=tuple(aggs), + by_column_ids=(), + order_by=ordering_exprs, + ) + result = array_value.ArrayValue(agg_node) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def test_array_agg(scalar_types_df: bpd.DataFrame, snapshot): + # TODO: Verify "NULL LAST" syntax issue on Python < 3.12 + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.ArrayAggOp().as_expr(col_name) + sql = _apply_ordered_unary_agg_ops( + bf_df, [agg_expr], [col_name], ordering_args=[col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_string_agg(scalar_types_df: bpd.DataFrame, snapshot): + # TODO: Verify "NULL LAST" syntax issue on Python < 3.12 + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.StringAggOp(sep=",").as_expr(col_name) + sql = _apply_ordered_unary_agg_ops( + bf_df, [agg_expr], [col_name], ordering_args=[col_name] + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py new file mode 100644 index 0000000000..fbf631d1a0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -0,0 +1,640 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import typing + +import pytest + +from bigframes.core import agg_expressions as agg_exprs +from bigframes.core import ( + array_value, + expression, + identifiers, + nodes, + ordering, + window_spec, +) +from bigframes.operations import aggregations as agg_ops +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def _apply_unary_agg_ops( + obj: bpd.DataFrame, + ops_list: typing.Sequence[agg_exprs.UnaryAggregation], + new_names: typing.Sequence[str], +) -> str: + aggs = [(op, identifiers.ColumnId(name)) for op, name in zip(ops_list, new_names)] + + agg_node = nodes.AggregateNode(obj._block.expr.node, aggregations=tuple(aggs)) + result = array_value.ArrayValue(agg_node) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def _apply_unary_window_op( + obj: bpd.DataFrame, + op: agg_exprs.UnaryAggregation, + window_spec: window_spec.WindowSpec, + new_name: str, +) -> str: + win_node = nodes.WindowOpNode( + obj._block.expr.node, + agg_exprs=(nodes.ColumnDef(op, identifiers.ColumnId(new_name)),), + window_spec=window_spec, + ) + result = array_value.ArrayValue(win_node).select_columns([new_name]) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def test_all(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "bool_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.AllOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_bool") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.descending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_bool" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + + +def test_any(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "bool_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.AnyOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_bool") + snapshot.assert_match(sql_window, "window_out.sql") + + +def test_approx_quartiles(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_ops_map = { + "q1": agg_ops.ApproxQuartilesOp(quartile=1).as_expr(col_name), + "q2": agg_ops.ApproxQuartilesOp(quartile=2).as_expr(col_name), + "q3": agg_ops.ApproxQuartilesOp(quartile=3).as_expr(col_name), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_approx_top_count(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.ApproxTopCountOp(number=10).as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_any_value(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.AnyValueOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.ascending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + + +def test_count(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.CountOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.descending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + + +def test_cut(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_ops_map = { + "int_bins": agg_exprs.UnaryAggregation( + agg_ops.CutOp(bins=3, right=True, labels=None), expression.deref(col_name) + ), + "interval_bins": agg_exprs.UnaryAggregation( + agg_ops.CutOp(bins=((0, 1), (1, 2)), right=True, labels=None), + expression.deref(col_name), + ), + "int_bins_labels": agg_exprs.UnaryAggregation( + agg_ops.CutOp(bins=3, labels=("a", "b", "c"), right=False), + expression.deref(col_name), + ), + "interval_bins_labels": agg_exprs.UnaryAggregation( + agg_ops.CutOp(bins=((0, 1), (1, 2)), labels=False, right=True), + expression.deref(col_name), + ), + } + window = window_spec.WindowSpec() + + # Loop through the aggregation map items + for test_name, agg_expr in agg_ops_map.items(): + sql = _apply_unary_window_op(bf_df, agg_expr, window, test_name) + + snapshot.assert_match(sql, f"{test_name}.sql") + + +def test_dense_rank(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation( + agg_ops.DenseRankOp(), expression.deref(col_name) + ) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + +def test_diff_w_int(scalar_types_df: bpd.DataFrame, snapshot): + # Test integer + int_col = "int64_col" + bf_df_int = scalar_types_df[[int_col]] + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(int_col),)) + int_op = agg_exprs.UnaryAggregation( + agg_ops.DiffOp(periods=1), expression.deref(int_col) + ) + int_sql = _apply_unary_window_op(bf_df_int, int_op, window, "diff_int") + snapshot.assert_match(int_sql, "out.sql") + + +def test_diff_w_bool(scalar_types_df: bpd.DataFrame, snapshot): + bool_col = "bool_col" + bf_df_bool = scalar_types_df[[bool_col]] + window = window_spec.WindowSpec(ordering=(ordering.descending_over(bool_col),)) + bool_op = agg_exprs.UnaryAggregation( + agg_ops.DiffOp(periods=1), expression.deref(bool_col) + ) + bool_sql = _apply_unary_window_op(bf_df_bool, bool_op, window, "diff_bool") + snapshot.assert_match(bool_sql, "out.sql") + + +def test_diff_w_datetime(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "datetime_col" + bf_df_date = scalar_types_df[[col_name]] + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + op = agg_exprs.UnaryAggregation( + agg_ops.DiffOp(periods=1), expression.deref(col_name) + ) + sql = _apply_unary_window_op(bf_df_date, op, window, "diff_datetime") + snapshot.assert_match(sql, "out.sql") + + +def test_diff_w_timestamp(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df_timestamp = scalar_types_df[[col_name]] + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + op = agg_exprs.UnaryAggregation( + agg_ops.DiffOp(periods=1), expression.deref(col_name) + ) + sql = _apply_unary_window_op(bf_df_timestamp, op, window, "diff_timestamp") + snapshot.assert_match(sql, "out.sql") + + +def test_first(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation(agg_ops.FirstOp(), expression.deref(col_name)) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + +def test_first_non_null(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation( + agg_ops.FirstNonNullOp(), expression.deref(col_name) + ) + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + +def test_last(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation(agg_ops.LastOp(), expression.deref(col_name)) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + +def test_last_non_null(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation( + agg_ops.LastNonNullOp(), expression.deref(col_name) + ) + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + +def test_max(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.MaxOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.descending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + + +def test_mean(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["int64_col", "bool_col", "duration_col"] + bf_df = scalar_types_df[col_names] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + # The `to_timedelta` creates a new mapping for the column id. + col_names.insert(0, "rowindex") + name2id = { + col_name: col_id + for col_name, col_id in zip(col_names, bf_df._block.expr.column_ids) + } + + agg_ops_map = { + "int64_col": agg_ops.MeanOp().as_expr(name2id["int64_col"]), + "bool_col": agg_ops.MeanOp().as_expr(name2id["bool_col"]), + "duration_col": agg_ops.MeanOp().as_expr(name2id["duration_col"]), + "int64_col_w_floor": agg_ops.MeanOp(should_floor_result=True).as_expr( + name2id["int64_col"] + ), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + + snapshot.assert_match(sql, "out.sql") + + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.MeanOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.ascending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + + +def test_median(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + ops_map = { + "int64_col": agg_ops.MedianOp().as_expr("int64_col"), + "date_col": agg_ops.MedianOp().as_expr("date_col"), + "string_col": agg_ops.MedianOp().as_expr("string_col"), + } + sql = _apply_unary_agg_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + + snapshot.assert_match(sql, "out.sql") + + +def test_min(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.MinOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.descending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + + +def test_nunique(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.NuniqueOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_pop_var(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["int64_col", "bool_col"] + bf_df = scalar_types_df[col_names] + + agg_ops_map = { + "int64_col": agg_ops.PopVarOp().as_expr("int64_col"), + "bool_col": agg_ops.PopVarOp().as_expr("bool_col"), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + snapshot.assert_match(sql, "out.sql") + + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.PopVarOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + +def test_product(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.ProductOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + + +def test_qcut(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + + col_name = "int64_col" + bf = scalar_types_df[[col_name]] + bf["qcut_w_int"] = bpd.qcut(bf[col_name], q=4, labels=False, duplicates="drop") + + q_list = tuple([0, 0.25, 0.5, 0.75, 1]) + bf["qcut_w_list"] = bpd.qcut( + scalar_types_df[col_name], + q=q_list, + labels=False, + duplicates="drop", + ) + + snapshot.assert_match(bf.sql, "out.sql") + + +def test_quantile(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_ops_map = { + "quantile": agg_ops.QuantileOp(q=0.5).as_expr(col_name), + "quantile_floor": agg_ops.QuantileOp(q=0.5, should_floor_result=True).as_expr( + col_name + ), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_rank(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation(agg_ops.RankOp(), expression.deref(col_name)) + + window = window_spec.WindowSpec( + ordering=(ordering.descending_over(col_name, nulls_last=False),) + ) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + +def test_shift(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + window = window_spec.WindowSpec( + ordering=(ordering.ascending_over(col_name, nulls_last=False),) + ) + + # Test lag + lag_op = agg_exprs.UnaryAggregation( + agg_ops.ShiftOp(periods=1), expression.deref(col_name) + ) + lag_sql = _apply_unary_window_op(bf_df, lag_op, window, "lag") + snapshot.assert_match(lag_sql, "lag.sql") + + # Test lead + lead_op = agg_exprs.UnaryAggregation( + agg_ops.ShiftOp(periods=-1), expression.deref(col_name) + ) + lead_sql = _apply_unary_window_op(bf_df, lead_op, window, "lead") + snapshot.assert_match(lead_sql, "lead.sql") + + # Test no-op + noop_op = agg_exprs.UnaryAggregation( + agg_ops.ShiftOp(periods=0), expression.deref(col_name) + ) + noop_sql = _apply_unary_window_op(bf_df, noop_op, window, "noop") + snapshot.assert_match(noop_sql, "noop.sql") + + +def test_std(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["int64_col", "bool_col", "duration_col"] + bf_df = scalar_types_df[col_names] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + # The `to_timedelta` creates a new mapping for the column id. + col_names.insert(0, "rowindex") + name2id = { + col_name: col_id + for col_name, col_id in zip(col_names, bf_df._block.expr.column_ids) + } + + agg_ops_map = { + "int64_col": agg_ops.StdOp().as_expr(name2id["int64_col"]), + "bool_col": agg_ops.StdOp().as_expr(name2id["bool_col"]), + "duration_col": agg_ops.StdOp().as_expr(name2id["duration_col"]), + "int64_col_w_floor": agg_ops.StdOp(should_floor_result=True).as_expr( + name2id["int64_col"] + ), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + snapshot.assert_match(sql, "out.sql") + + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.StdOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + +def test_sum(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + agg_ops_map = { + "int64_col": agg_ops.SumOp().as_expr("int64_col"), + "bool_col": agg_ops.SumOp().as_expr("bool_col"), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + + snapshot.assert_match(sql, "out.sql") + + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.SumOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.ascending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + + +def test_var(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["int64_col", "bool_col"] + bf_df = scalar_types_df[col_names] + + agg_ops_map = { + "int64_col": agg_ops.VarOp().as_expr("int64_col"), + "bool_col": agg_ops.VarOp().as_expr("bool_col"), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + snapshot.assert_match(sql, "out.sql") + + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.VarOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_windows.py b/tests/unit/core/compile/sqlglot/aggregations/test_windows.py new file mode 100644 index 0000000000..af347f4aa3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/test_windows.py @@ -0,0 +1,178 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import pandas as pd +import pytest +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes.core import window_spec +from bigframes.core.compile.sqlglot.aggregations.windows import ( + apply_window_if_present, + get_window_order_by, +) +import bigframes.core.expression as ex +import bigframes.core.identifiers as ids +import bigframes.core.ordering as ordering + + +class WindowsTest(unittest.TestCase): + def test_get_window_order_by_empty(self): + self.assertIsNone(get_window_order_by(tuple())) + + def test_get_window_order_by(self): + result = get_window_order_by((ordering.OrderingExpression(ex.deref("col1")),)) + self.assertEqual( + sge.Order(expressions=result).sql(dialect="bigquery"), + "ORDER BY `col1` ASC NULLS LAST", + ) + + def test_get_window_order_by_override_nulls(self): + result = get_window_order_by( + (ordering.OrderingExpression(ex.deref("col1")),), + override_null_order=True, + ) + self.assertEqual( + sge.Order(expressions=result).sql(dialect="bigquery"), + "ORDER BY `col1` IS NULL ASC NULLS LAST, `col1` ASC NULLS LAST", + ) + + def test_get_window_order_by_override_nulls_desc(self): + result = get_window_order_by( + ( + ordering.OrderingExpression( + ex.deref("col1"), + direction=ordering.OrderingDirection.DESC, + na_last=False, + ), + ), + override_null_order=True, + ) + self.assertEqual( + sge.Order(expressions=result).sql(dialect="bigquery"), + "ORDER BY `col1` IS NULL DESC NULLS FIRST, `col1` DESC NULLS FIRST", + ) + + def test_apply_window_if_present_no_window(self): + value = sge.func( + "SUM", sge.Column(this=sge.to_identifier("col_0", quoted=True)) + ) + result = apply_window_if_present(value) + self.assertEqual(result, value) + + def test_apply_window_if_present_row_bounded_no_ordering_raises(self): + with pytest.raises( + ValueError, match="No ordering provided for ordered analytic function" + ): + apply_window_if_present( + sge.Var(this="value"), + window_spec.WindowSpec( + bounds=window_spec.RowsWindowBounds(start=-1, end=1) + ), + ) + + def test_apply_window_if_present_grouping_no_ordering(self): + result = apply_window_if_present( + sge.Var(this="value"), + window_spec.WindowSpec( + grouping_keys=( + ex.ResolvedDerefOp( + ids.ColumnId("col1"), + dtype=dtypes.STRING_DTYPE, + is_nullable=True, + ), + ex.ResolvedDerefOp( + ids.ColumnId("col2"), + dtype=dtypes.FLOAT_DTYPE, + is_nullable=True, + ), + ex.ResolvedDerefOp( + ids.ColumnId("col3"), + dtype=dtypes.JSON_DTYPE, + is_nullable=True, + ), + ex.ResolvedDerefOp( + ids.ColumnId("col4"), + dtype=dtypes.GEO_DTYPE, + is_nullable=True, + ), + ), + ), + ) + self.assertEqual( + result.sql(dialect="bigquery"), + "value OVER (PARTITION BY `col1`, CAST(`col2` AS STRING), TO_JSON_STRING(`col3`), CAST(`col4` AS BYTES))", + ) + + def test_apply_window_if_present_range_bounded(self): + result = apply_window_if_present( + sge.Var(this="value"), + window_spec.WindowSpec( + ordering=(ordering.OrderingExpression(ex.deref("col1")),), + bounds=window_spec.RangeWindowBounds(start=None, end=pd.Timedelta(0)), + ), + ) + self.assertEqual( + result.sql(dialect="bigquery"), + "value OVER (ORDER BY `col1` ASC NULLS LAST RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)", + ) + + def test_apply_window_if_present_range_bounded_timedelta(self): + result = apply_window_if_present( + sge.Var(this="value"), + window_spec.WindowSpec( + ordering=(ordering.OrderingExpression(ex.deref("col1")),), + bounds=window_spec.RangeWindowBounds( + start=pd.Timedelta(days=-1), end=pd.Timedelta(hours=12) + ), + ), + ) + self.assertEqual( + result.sql(dialect="bigquery"), + "value OVER (ORDER BY `col1` ASC NULLS LAST RANGE BETWEEN 86400000000 PRECEDING AND 43200000000 FOLLOWING)", + ) + + def test_apply_window_if_present_all_params(self): + result = apply_window_if_present( + sge.Var(this="value"), + window_spec.WindowSpec( + grouping_keys=( + ex.ResolvedDerefOp( + ids.ColumnId("col1"), + dtype=dtypes.STRING_DTYPE, + is_nullable=True, + ), + ), + ordering=( + ordering.OrderingExpression( + ex.ResolvedDerefOp( + ids.ColumnId("col2"), + dtype=dtypes.STRING_DTYPE, + is_nullable=True, + ) + ), + ), + bounds=window_spec.RowsWindowBounds(start=-1, end=0), + ), + ) + self.assertEqual( + result.sql(dialect="bigquery"), + "value OVER (PARTITION BY `col1` ORDER BY `col2` ASC NULLS LAST ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/core/compile/sqlglot/conftest.py b/tests/unit/core/compile/sqlglot/conftest.py new file mode 100644 index 0000000000..cb5a14b690 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/conftest.py @@ -0,0 +1,280 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pathlib +import typing + +from google.cloud import bigquery +import pandas as pd +import pyarrow as pa +import pytest + +from bigframes import dtypes +import bigframes.core as core +import bigframes.pandas as bpd +import bigframes.testing.mocks as mocks +import bigframes.testing.utils + +CURRENT_DIR = pathlib.Path(__file__).parent +DATA_DIR = CURRENT_DIR.parent.parent.parent.parent / "data" + + +def _create_compiler_session(table_name, table_schema): + """Helper function to create a compiler session.""" + from bigframes.testing import compiler_session + + anonymous_dataset = bigquery.DatasetReference.from_string( + "bigframes-dev.sqlglot_test" + ) + session = mocks.create_bigquery_session( + table_name=table_name, + table_schema=table_schema, + anonymous_dataset=anonymous_dataset, + ) + session._executor = compiler_session.SQLCompilerExecutor() + return session + + +@pytest.fixture(scope="session") +def compiler_session(scalar_types_table_schema): + """Compiler session for scalar types.""" + return _create_compiler_session("scalar_types", scalar_types_table_schema) + + +@pytest.fixture(scope="session") +def compiler_session_w_repeated_types(repeated_types_table_schema): + """Compiler session for repeated data types.""" + return _create_compiler_session("repeated_types", repeated_types_table_schema) + + +@pytest.fixture(scope="session") +def compiler_session_w_nested_structs_types(nested_structs_types_table_schema): + """Compiler session for nested STRUCT data types.""" + return _create_compiler_session( + "nested_structs_types", nested_structs_types_table_schema + ) + + +@pytest.fixture(scope="session") +def compiler_session_w_json_types(json_types_table_schema): + """Compiler session for JSON data types.""" + return _create_compiler_session("json_types", json_types_table_schema) + + +@pytest.fixture(scope="session") +def scalar_types_table_schema() -> typing.Sequence[bigquery.SchemaField]: + return [ + bigquery.SchemaField("bool_col", "BOOLEAN"), + bigquery.SchemaField("bytes_col", "BYTES"), + bigquery.SchemaField("date_col", "DATE"), + bigquery.SchemaField("datetime_col", "DATETIME"), + bigquery.SchemaField("geography_col", "GEOGRAPHY"), + bigquery.SchemaField("int64_col", "INTEGER"), + bigquery.SchemaField("int64_too", "INTEGER"), + bigquery.SchemaField("numeric_col", "NUMERIC"), + bigquery.SchemaField("float64_col", "FLOAT"), + bigquery.SchemaField("rowindex", "INTEGER"), + bigquery.SchemaField("rowindex_2", "INTEGER", mode="REQUIRED"), + bigquery.SchemaField("string_col", "STRING"), + bigquery.SchemaField("time_col", "TIME"), + bigquery.SchemaField("timestamp_col", "TIMESTAMP"), + bigquery.SchemaField("duration_col", "INTEGER"), + ] + + +@pytest.fixture(scope="session") +def scalar_types_df(compiler_session) -> bpd.DataFrame: + """Returns a BigFrames DataFrame containing all scalar types and using the `rowindex` + column as the index.""" + bf_df = compiler_session._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.scalar_types", + enable_snapshot=False, + ) + bf_df = bf_df.set_index("rowindex", drop=False) + return bf_df + + +@pytest.fixture(scope="session") +def scalar_types_pandas_df() -> pd.DataFrame: + """Returns a pandas DataFrame containing all scalar types and using the `rowindex` + column as the index.""" + # TODO: add tests for empty dataframes + df = pd.read_json( + DATA_DIR / "scalars.jsonl", + lines=True, + ) + bigframes.testing.utils.convert_pandas_dtypes(df, bytes_col=True) + + df = df.set_index("rowindex", drop=False) + return df + + +@pytest.fixture(scope="module") +def scalar_types_array_value( + scalar_types_pandas_df: pd.DataFrame, compiler_session: bigframes.Session +) -> core.ArrayValue: + managed_data_source = core.local_data.ManagedArrowTable.from_pandas( + scalar_types_pandas_df + ) + return core.ArrayValue.from_managed(managed_data_source, compiler_session) + + +@pytest.fixture(scope="session") +def nested_structs_types_table_schema() -> typing.Sequence[bigquery.SchemaField]: + return [ + bigquery.SchemaField("id", "INTEGER"), + bigquery.SchemaField( + "people", + "RECORD", + fields=[ + bigquery.SchemaField("name", "STRING"), + bigquery.SchemaField("age", "INTEGER"), + bigquery.SchemaField( + "address", + "RECORD", + fields=[ + bigquery.SchemaField("city", "STRING"), + bigquery.SchemaField("country", "STRING"), + ], + ), + ], + ), + ] + + +@pytest.fixture(scope="session") +def nested_structs_types_df(compiler_session_w_nested_structs_types) -> bpd.DataFrame: + """Returns a BigFrames DataFrame containing all scalar types and using the `rowindex` + column as the index.""" + bf_df = compiler_session_w_nested_structs_types._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.nested_structs_types", + enable_snapshot=False, + ) + bf_df = bf_df.set_index("id", drop=False) + return bf_df + + +@pytest.fixture(scope="session") +def nested_structs_pandas_df() -> pd.DataFrame: + """Returns a pandas DataFrame containing STRUCT types and using the `id` + column as the index.""" + + df = pd.read_json( + DATA_DIR / "nested_structs.jsonl", + lines=True, + ) + df = df.set_index("id") + + address_struct_schema = pa.struct( + [pa.field("city", pa.string()), pa.field("country", pa.string())] + ) + person_struct_schema = pa.struct( + [ + pa.field("name", pa.string()), + pa.field("age", pa.int64()), + pa.field("address", address_struct_schema), + ] + ) + df["person"] = df["person"].astype(pd.ArrowDtype(person_struct_schema)) + return df + + +@pytest.fixture(scope="session") +def repeated_types_table_schema() -> typing.Sequence[bigquery.SchemaField]: + return [ + bigquery.SchemaField("rowindex", "INTEGER"), + bigquery.SchemaField("int_list_col", "INTEGER", "REPEATED"), + bigquery.SchemaField("bool_list_col", "BOOLEAN", "REPEATED"), + bigquery.SchemaField("float_list_col", "FLOAT", "REPEATED"), + bigquery.SchemaField("date_list_col", "DATE", "REPEATED"), + bigquery.SchemaField("date_time_list_col", "DATETIME", "REPEATED"), + bigquery.SchemaField("numeric_list_col", "NUMERIC", "REPEATED"), + bigquery.SchemaField("string_list_col", "STRING", "REPEATED"), + ] + + +@pytest.fixture(scope="session") +def repeated_types_df(compiler_session_w_repeated_types) -> bpd.DataFrame: + """Returns a BigFrames DataFrame containing all scalar types and using the `rowindex` + column as the index.""" + bf_df = compiler_session_w_repeated_types._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.repeated_types", + enable_snapshot=False, + ) + bf_df = bf_df.set_index("rowindex", drop=False) + return bf_df + + +@pytest.fixture(scope="session") +def repeated_types_pandas_df() -> pd.DataFrame: + """Returns a pandas DataFrame containing LIST types and using the `rowindex` + column as the index.""" + + df = pd.read_json( + DATA_DIR / "repeated.jsonl", + lines=True, + ) + # TODO: add dtype conversion here if needed. + df = df.set_index("rowindex") + return df + + +@pytest.fixture(scope="session") +def json_types_table_schema() -> typing.Sequence[bigquery.SchemaField]: + return [ + bigquery.SchemaField("rowindex", "INTEGER"), + bigquery.SchemaField("json_col", "JSON"), + ] + + +@pytest.fixture(scope="session") +def json_types_df(compiler_session_w_json_types) -> bpd.DataFrame: + """Returns a BigFrames DataFrame containing JSON types and using the `rowindex` + column as the index.""" + bf_df = compiler_session_w_json_types._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.json_types", + enable_snapshot=False, + ) + # TODO(b/427305807): Why `drop=False` will produce two "rowindex" columns? + bf_df = bf_df.set_index("rowindex", drop=True) + return bf_df + + +@pytest.fixture(scope="session") +def json_pandas_df() -> pd.DataFrame: + """Returns a pandas DataFrame containing JSON types and using the `rowindex` + column as the index.""" + json_data = [ + "null", + "true", + "100", + "0.98", + '"a string"', + "[]", + "[1, 2, 3]", + '[{"a": 1}, {"a": 2}, {"a": null}, {}]', + '"100"', + '{"date": "2024-07-16"}', + '{"int_value": 2, "null_filed": null}', + '{"list_data": [10, 20, 30]}', + ] + df = pd.DataFrame( + { + "rowindex": pd.Series(range(len(json_data)), dtype=dtypes.INT_DTYPE), + "json_col": pd.Series(json_data, dtype=dtypes.JSON_DTYPE), + }, + ) + # TODO(b/427305807): Why `drop=False` will produce two "rowindex" columns? + df = df.set_index("rowindex", drop=True) + return df diff --git a/tests/unit/core/compile/sqlglot/expressions/__init__.py b/tests/unit/core/compile/sqlglot/expressions/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql new file mode 100644 index 0000000000..a40784a3ca --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.CLASSIFY( + input => (`string_col`), + categories => ['greeting', 'rejection'], + connection_id => 'bigframes-dev.us.bigframes-default-connection' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql new file mode 100644 index 0000000000..ec3515e7ed --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE( + prompt => (`string_col`, ' is the same as ', `string_col`), + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql new file mode 100644 index 0000000000..3a09da7c3a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_BOOL( + prompt => (`string_col`, ' is the same as ', `string_col`), + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_connection_id/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_connection_id/out.sql new file mode 100644 index 0000000000..f844ed1691 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_connection_id/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_BOOL( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql new file mode 100644 index 0000000000..2a81ced782 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_BOOL( + prompt => (`string_col`, ' is the same as ', `string_col`), + request_type => 'SHARED', + model_params => JSON '{}' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql new file mode 100644 index 0000000000..3b89429621 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_DOUBLE( + prompt => (`string_col`, ' is the same as ', `string_col`), + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_connection_id/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_connection_id/out.sql new file mode 100644 index 0000000000..fae92515cb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_connection_id/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_DOUBLE( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql new file mode 100644 index 0000000000..480ee09ef6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_DOUBLE( + prompt => (`string_col`, ' is the same as ', `string_col`), + request_type => 'SHARED', + model_params => JSON '{}' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql new file mode 100644 index 0000000000..f33af547c7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_INT( + prompt => (`string_col`, ' is the same as ', `string_col`), + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_connection_id/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_connection_id/out.sql new file mode 100644 index 0000000000..a0c92c959c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_connection_id/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_INT( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql new file mode 100644 index 0000000000..2929e57ba0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_INT( + prompt => (`string_col`, ' is the same as ', `string_col`), + request_type => 'SHARED', + model_params => JSON '{}' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_connection_id/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_connection_id/out.sql new file mode 100644 index 0000000000..19f85b181b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_connection_id/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql new file mode 100644 index 0000000000..745243db3a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE( + prompt => (`string_col`, ' is the same as ', `string_col`), + request_type => 'SHARED', + model_params => JSON '{}' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql new file mode 100644 index 0000000000..4f7867a0f2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE( + prompt => (`string_col`, ' is the same as ', `string_col`), + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED', + output_schema => 'x INT64, y FLOAT64' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql new file mode 100644 index 0000000000..275ba8d423 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.IF( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql new file mode 100644 index 0000000000..01c71065b9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.SCORE( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql new file mode 100644 index 0000000000..d8e223d5f8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + `string_list_col`[SAFE_OFFSET(1)] AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_reduce_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_reduce_op/out.sql new file mode 100644 index 0000000000..b9f87bfd1e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_reduce_op/out.sql @@ -0,0 +1,37 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_list_col`, + `float_list_col`, + `string_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ( + SELECT + COALESCE(SUM(bf_arr_reduce_uid), 0) + FROM UNNEST(`float_list_col`) AS bf_arr_reduce_uid + ) AS `bfcol_3`, + ( + SELECT + STDDEV(bf_arr_reduce_uid) + FROM UNNEST(`float_list_col`) AS bf_arr_reduce_uid + ) AS `bfcol_4`, + ( + SELECT + COUNT(bf_arr_reduce_uid) + FROM UNNEST(`string_list_col`) AS bf_arr_reduce_uid + ) AS `bfcol_5`, + ( + SELECT + COALESCE(LOGICAL_OR(bf_arr_reduce_uid), FALSE) + FROM UNNEST(`bool_list_col`) AS bf_arr_reduce_uid + ) AS `bfcol_6` + FROM `bfcte_0` +) +SELECT + `bfcol_3` AS `sum_float`, + `bfcol_4` AS `std_float`, + `bfcol_5` AS `count_str`, + `bfcol_6` AS `any_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_only_start/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_only_start/out.sql new file mode 100644 index 0000000000..0034ffd69c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_only_start/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `string_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ARRAY( + SELECT + el + FROM UNNEST(`string_list_col`) AS el WITH OFFSET AS slice_idx + WHERE + slice_idx >= 1 + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_start_and_stop/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_start_and_stop/out.sql new file mode 100644 index 0000000000..f0638fa3af --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_start_and_stop/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `string_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ARRAY( + SELECT + el + FROM UNNEST(`string_list_col`) AS el WITH OFFSET AS slice_idx + WHERE + slice_idx >= 1 AND slice_idx < 5 + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql new file mode 100644 index 0000000000..09446bb8f5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ARRAY_TO_STRING(`string_list_col`, '.') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_op/out.sql new file mode 100644 index 0000000000..3e29701658 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_op/out.sql @@ -0,0 +1,26 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + [COALESCE(`bool_col`, FALSE)] AS `bfcol_8`, + [COALESCE(`int64_col`, 0)] AS `bfcol_9`, + [COALESCE(`string_col`, ''), COALESCE(`string_col`, '')] AS `bfcol_10`, + [ + COALESCE(`int64_col`, 0), + CAST(COALESCE(`bool_col`, FALSE) AS INT64), + COALESCE(`float64_col`, 0.0) + ] AS `bfcol_11` + FROM `bfcte_0` +) +SELECT + `bfcol_8` AS `bool_col`, + `bfcol_9` AS `int64_col`, + `bfcol_10` AS `strs_col`, + `bfcol_11` AS `numeric_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_fetch_metadata/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_fetch_metadata/out.sql new file mode 100644 index 0000000000..bd99b86064 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_fetch_metadata/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + OBJ.MAKE_REF(`string_col`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + OBJ.FETCH_METADATA(`bfcol_4`) AS `bfcol_7` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_7`.`version` AS `bfcol_10` + FROM `bfcte_2` +) +SELECT + `rowindex`, + `bfcol_10` AS `version` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_get_access_url/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_get_access_url/out.sql new file mode 100644 index 0000000000..c65436e530 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_get_access_url/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + OBJ.MAKE_REF(`string_col`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + OBJ.GET_ACCESS_URL(`bfcol_4`) AS `bfcol_7` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + JSON_VALUE(`bfcol_7`, '$.access_urls.read_url') AS `bfcol_10` + FROM `bfcte_2` +) +SELECT + `rowindex`, + `bfcol_10` AS `string_col` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_make_ref/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_make_ref/out.sql new file mode 100644 index 0000000000..d74449c986 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_make_ref/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + OBJ.MAKE_REF(`string_col`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `rowindex`, + `bfcol_4` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql new file mode 100644 index 0000000000..634a936a0e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql @@ -0,0 +1,31 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + `int64_col` & `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` AND `bfcol_7` AS `bfcol_18` + FROM `bfcte_1` +) +SELECT + `bfcol_14` AS `rowindex`, + `bfcol_15` AS `bool_col`, + `bfcol_16` AS `int64_col`, + `bfcol_17` AS `int_and_int`, + `bfcol_18` AS `bool_and_bool` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql new file mode 100644 index 0000000000..0069b07d8f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql @@ -0,0 +1,31 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + `int64_col` | `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` OR `bfcol_7` AS `bfcol_18` + FROM `bfcte_1` +) +SELECT + `bfcol_14` AS `rowindex`, + `bfcol_15` AS `bool_col`, + `bfcol_16` AS `int64_col`, + `bfcol_17` AS `int_and_int`, + `bfcol_18` AS `bool_and_bool` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql new file mode 100644 index 0000000000..e4c87ed720 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql @@ -0,0 +1,31 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + `int64_col` ^ `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` AND NOT `bfcol_7` OR NOT `bfcol_7` AND `bfcol_7` AS `bfcol_18` + FROM `bfcte_1` +) +SELECT + `bfcol_14` AS `rowindex`, + `bfcol_15` AS `bool_col`, + `bfcol_16` AS `int64_col`, + `bfcol_17` AS `int_and_int`, + `bfcol_18` AS `bool_and_bool` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql new file mode 100644 index 0000000000..57af99a52b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(CAST(`int64_col` AS STRING), '$NULL_SENTINEL$') = COALESCE(CAST(CAST(`bool_col` AS INT64) AS STRING), '$NULL_SENTINEL$') AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql new file mode 100644 index 0000000000..9c7c19e61c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` = `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` = 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` = CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) = `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_ne_int`, + `bfcol_40` AS `int_ne_1`, + `bfcol_41` AS `int_ne_bool`, + `bfcol_42` AS `bool_ne_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql new file mode 100644 index 0000000000..e99fe49c8e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` >= `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` >= 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` >= CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) >= `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_ge_int`, + `bfcol_40` AS `int_ge_1`, + `bfcol_41` AS `int_ge_bool`, + `bfcol_42` AS `bool_ge_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql new file mode 100644 index 0000000000..4e5aba3d31 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` > `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` > 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` > CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) > `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_gt_int`, + `bfcol_40` AS `int_gt_1`, + `bfcol_41` AS `int_gt_bool`, + `bfcol_42` AS `bool_gt_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql new file mode 100644 index 0000000000..197ed279fa --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql @@ -0,0 +1,32 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(`int64_col` IN (1, 2, 3), FALSE) AS `bfcol_2`, + ( + `int64_col` IS NULL + ) OR `int64_col` IN (123456) AS `bfcol_3`, + COALESCE(`int64_col` IN (1.0, 2.0, 3.0), FALSE) AS `bfcol_4`, + FALSE AS `bfcol_5`, + COALESCE(`int64_col` IN (2.5, 3), FALSE) AS `bfcol_6`, + FALSE AS `bfcol_7`, + COALESCE(`int64_col` IN (123456), FALSE) AS `bfcol_8`, + ( + `float64_col` IS NULL + ) OR `float64_col` IN (1, 2, 3) AS `bfcol_9` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `ints`, + `bfcol_3` AS `ints_w_null`, + `bfcol_4` AS `floats`, + `bfcol_5` AS `strings`, + `bfcol_6` AS `mixed`, + `bfcol_7` AS `empty`, + `bfcol_8` AS `ints_wo_match_nulls`, + `bfcol_9` AS `float_in_ints` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql new file mode 100644 index 0000000000..97a00d1c88 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` <= `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` <= 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` <= CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) <= `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_le_int`, + `bfcol_40` AS `int_le_1`, + `bfcol_41` AS `int_le_bool`, + `bfcol_42` AS `bool_le_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql new file mode 100644 index 0000000000..addebd3187 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` < `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` < 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` < CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) < `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_lt_int`, + `bfcol_40` AS `int_lt_1`, + `bfcol_41` AS `int_lt_bool`, + `bfcol_42` AS `bool_lt_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql new file mode 100644 index 0000000000..bbef212707 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + GREATEST(`int64_col`, `float64_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql new file mode 100644 index 0000000000..1f00f5892e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LEAST(`int64_col`, `float64_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql new file mode 100644 index 0000000000..417d24aa72 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` <> `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` <> 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` <> CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) <> `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_ne_int`, + `bfcol_40` AS `int_ne_1`, + `bfcol_41` AS `int_ne_bool`, + `bfcol_42` AS `bool_ne_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql new file mode 100644 index 0000000000..2fef18eeb8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql @@ -0,0 +1,60 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col`, + `rowindex`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `timestamp_col` AS `bfcol_7`, + `date_col` AS `bfcol_8`, + TIMESTAMP_ADD(CAST(`date_col` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + TIMESTAMP_ADD(`bfcol_7`, INTERVAL 86400000000 MICROSECOND) AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + TIMESTAMP_ADD(CAST(`bfcol_16` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + TIMESTAMP_ADD(`bfcol_25`, INTERVAL 86400000000 MICROSECOND) AS `bfcol_42` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + 172800000000 AS `bfcol_50` + FROM `bfcte_4` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `timestamp_col`, + `bfcol_38` AS `date_col`, + `bfcol_39` AS `date_add_timedelta`, + `bfcol_40` AS `timestamp_add_timedelta`, + `bfcol_41` AS `timedelta_add_date`, + `bfcol_42` AS `timedelta_add_timestamp`, + `bfcol_50` AS `timedelta_add_timedelta` +FROM `bfcte_5` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql new file mode 100644 index 0000000000..b8f46ceafe --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + DATE(`timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_datetime_to_integer_label/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_datetime_to_integer_label/out.sql new file mode 100644 index 0000000000..5260dd680a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_datetime_to_integer_label/out.sql @@ -0,0 +1,38 @@ +WITH `bfcte_0` AS ( + SELECT + `datetime_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(FLOOR( + IEEE_DIVIDE( + UNIX_MICROS(CAST(`datetime_col` AS TIMESTAMP)) - UNIX_MICROS(CAST(`timestamp_col` AS TIMESTAMP)), + 86400000000 + ) + ) AS INT64) AS `bfcol_2`, + CASE + WHEN UNIX_MICROS( + CAST(TIMESTAMP_TRUNC(`datetime_col`, WEEK(MONDAY)) + INTERVAL 6 DAY AS TIMESTAMP) + ) = UNIX_MICROS( + CAST(TIMESTAMP_TRUNC(`timestamp_col`, WEEK(MONDAY)) + INTERVAL 6 DAY AS TIMESTAMP) + ) + THEN 0 + ELSE CAST(FLOOR( + IEEE_DIVIDE( + UNIX_MICROS( + CAST(TIMESTAMP_TRUNC(`datetime_col`, WEEK(MONDAY)) + INTERVAL 6 DAY AS TIMESTAMP) + ) - UNIX_MICROS( + CAST(TIMESTAMP_TRUNC(`timestamp_col`, WEEK(MONDAY)) + INTERVAL 6 DAY AS TIMESTAMP) + ) - 1, + 604800000000 + ) + ) AS INT64) + 1 + END AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `fixed_freq`, + `bfcol_3` AS `non_fixed_freq_weekly` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql new file mode 100644 index 0000000000..52d80fd2a6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(DAY FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql new file mode 100644 index 0000000000..0119bbb4e9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col`, + `datetime_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(MOD(EXTRACT(DAYOFWEEK FROM `datetime_col`) + 5, 7) AS INT64) AS `bfcol_6`, + CAST(MOD(EXTRACT(DAYOFWEEK FROM `timestamp_col`) + 5, 7) AS INT64) AS `bfcol_7`, + CAST(MOD(EXTRACT(DAYOFWEEK FROM `date_col`) + 5, 7) AS INT64) AS `bfcol_8` + FROM `bfcte_0` +) +SELECT + `bfcol_6` AS `datetime_col`, + `bfcol_7` AS `timestamp_col`, + `bfcol_8` AS `date_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql new file mode 100644 index 0000000000..521419757a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(DAYOFYEAR FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql new file mode 100644 index 0000000000..fe76efb609 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql @@ -0,0 +1,36 @@ +WITH `bfcte_0` AS ( + SELECT + `datetime_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TIMESTAMP_TRUNC(`timestamp_col`, MICROSECOND) AS `bfcol_2`, + TIMESTAMP_TRUNC(`timestamp_col`, MILLISECOND) AS `bfcol_3`, + TIMESTAMP_TRUNC(`timestamp_col`, SECOND) AS `bfcol_4`, + TIMESTAMP_TRUNC(`timestamp_col`, MINUTE) AS `bfcol_5`, + TIMESTAMP_TRUNC(`timestamp_col`, HOUR) AS `bfcol_6`, + TIMESTAMP_TRUNC(`timestamp_col`, DAY) AS `bfcol_7`, + TIMESTAMP_TRUNC(`timestamp_col`, WEEK(MONDAY)) AS `bfcol_8`, + TIMESTAMP_TRUNC(`timestamp_col`, MONTH) AS `bfcol_9`, + TIMESTAMP_TRUNC(`timestamp_col`, QUARTER) AS `bfcol_10`, + TIMESTAMP_TRUNC(`timestamp_col`, YEAR) AS `bfcol_11`, + TIMESTAMP_TRUNC(`datetime_col`, MICROSECOND) AS `bfcol_12`, + TIMESTAMP_TRUNC(`datetime_col`, MICROSECOND) AS `bfcol_13` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `timestamp_col_us`, + `bfcol_3` AS `timestamp_col_ms`, + `bfcol_4` AS `timestamp_col_s`, + `bfcol_5` AS `timestamp_col_min`, + `bfcol_6` AS `timestamp_col_h`, + `bfcol_7` AS `timestamp_col_D`, + `bfcol_8` AS `timestamp_col_W`, + `bfcol_9` AS `timestamp_col_M`, + `bfcol_10` AS `timestamp_col_Q`, + `bfcol_11` AS `timestamp_col_Y`, + `bfcol_12` AS `datetime_col_q`, + `bfcol_13` AS `datetime_col_us` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql new file mode 100644 index 0000000000..5fc6621a7c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(HOUR FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql new file mode 100644 index 0000000000..9422844b34 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(MOD(EXTRACT(DAYOFWEEK FROM `timestamp_col`) + 5, 7) AS INT64) + 1 AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql new file mode 100644 index 0000000000..4db49fb10f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(ISOWEEK FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql new file mode 100644 index 0000000000..8d49933202 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(ISOYEAR FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql new file mode 100644 index 0000000000..e089a77af5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(MINUTE FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql new file mode 100644 index 0000000000..53d135903b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(MONTH FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql new file mode 100644 index 0000000000..b542dfea72 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TIMESTAMP_TRUNC(`timestamp_col`, DAY) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql new file mode 100644 index 0000000000..4a232cb5a3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(QUARTER FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql new file mode 100644 index 0000000000..e86d830b73 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(SECOND FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql new file mode 100644 index 0000000000..1d8f62f948 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql @@ -0,0 +1,22 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col`, + `datetime_col`, + `time_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FORMAT_DATE('%Y-%m-%d', `date_col`) AS `bfcol_8`, + FORMAT_DATETIME('%Y-%m-%d', `datetime_col`) AS `bfcol_9`, + FORMAT_TIME('%Y-%m-%d', `time_col`) AS `bfcol_10`, + FORMAT_TIMESTAMP('%Y-%m-%d', `timestamp_col`) AS `bfcol_11` + FROM `bfcte_0` +) +SELECT + `bfcol_8` AS `date_col`, + `bfcol_9` AS `datetime_col`, + `bfcol_10` AS `time_col`, + `bfcol_11` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql new file mode 100644 index 0000000000..ebcffd67f6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql @@ -0,0 +1,82 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col`, + `duration_col`, + `rowindex`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_8`, + `timestamp_col` AS `bfcol_9`, + `date_col` AS `bfcol_10`, + `duration_col` AS `bfcol_11` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_11` AS `bfcol_18`, + `bfcol_10` AS `bfcol_19`, + TIMESTAMP_SUB(CAST(`bfcol_10` AS DATETIME), INTERVAL `bfcol_11` MICROSECOND) AS `bfcol_20` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_19` AS `bfcol_29`, + `bfcol_20` AS `bfcol_30`, + TIMESTAMP_SUB(`bfcol_17`, INTERVAL `bfcol_18` MICROSECOND) AS `bfcol_31` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + `bfcol_30` AS `bfcol_42`, + `bfcol_31` AS `bfcol_43`, + TIMESTAMP_DIFF(CAST(`bfcol_29` AS DATETIME), CAST(`bfcol_29` AS DATETIME), MICROSECOND) AS `bfcol_44` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_38` AS `bfcol_52`, + `bfcol_39` AS `bfcol_53`, + `bfcol_40` AS `bfcol_54`, + `bfcol_41` AS `bfcol_55`, + `bfcol_42` AS `bfcol_56`, + `bfcol_43` AS `bfcol_57`, + `bfcol_44` AS `bfcol_58`, + TIMESTAMP_DIFF(`bfcol_39`, `bfcol_39`, MICROSECOND) AS `bfcol_59` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_52` AS `bfcol_68`, + `bfcol_53` AS `bfcol_69`, + `bfcol_54` AS `bfcol_70`, + `bfcol_55` AS `bfcol_71`, + `bfcol_56` AS `bfcol_72`, + `bfcol_57` AS `bfcol_73`, + `bfcol_58` AS `bfcol_74`, + `bfcol_59` AS `bfcol_75`, + `bfcol_54` - `bfcol_54` AS `bfcol_76` + FROM `bfcte_5` +) +SELECT + `bfcol_68` AS `rowindex`, + `bfcol_69` AS `timestamp_col`, + `bfcol_70` AS `duration_col`, + `bfcol_71` AS `date_col`, + `bfcol_72` AS `date_sub_timedelta`, + `bfcol_73` AS `timestamp_sub_timedelta`, + `bfcol_74` AS `timestamp_sub_date`, + `bfcol_75` AS `date_sub_timestamp`, + `bfcol_76` AS `timedelta_sub_timedelta` +FROM `bfcte_6` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql new file mode 100644 index 0000000000..5a8ab600ba --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TIME(`timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql new file mode 100644 index 0000000000..a8d40a8486 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 0.001) AS INT64)) AS DATETIME) AS `bfcol_6`, + SAFE_CAST(`string_col` AS DATETIME) AS `bfcol_7`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`float64_col` * 0.001) AS INT64)) AS DATETIME) AS `bfcol_8` + FROM `bfcte_0` +) +SELECT + `bfcol_6` AS `int64_col`, + `bfcol_7` AS `string_col`, + `bfcol_8` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql new file mode 100644 index 0000000000..a5f9ee1112 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql @@ -0,0 +1,24 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 0.001) AS INT64)) AS TIMESTAMP) AS `bfcol_2`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`float64_col` * 0.001) AS INT64)) AS TIMESTAMP) AS `bfcol_3`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 1000000) AS INT64)) AS TIMESTAMP) AS `bfcol_4`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 1000) AS INT64)) AS TIMESTAMP) AS `bfcol_5`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col`) AS INT64)) AS TIMESTAMP) AS `bfcol_6`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 0.001) AS INT64)) AS TIMESTAMP) AS `bfcol_7` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col`, + `bfcol_3` AS `float64_col`, + `bfcol_4` AS `int64_col_s`, + `bfcol_5` AS `int64_col_ms`, + `bfcol_6` AS `int64_col_us`, + `bfcol_7` AS `int64_col_ns` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql new file mode 100644 index 0000000000..e6515017f2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UNIX_MICROS(`timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql new file mode 100644 index 0000000000..caec5effe0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UNIX_MILLIS(`timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql new file mode 100644 index 0000000000..6dc0ea2a02 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UNIX_SECONDS(`timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql new file mode 100644 index 0000000000..1ceb674137 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(YEAR FROM `timestamp_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `timestamp_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql new file mode 100644 index 0000000000..1f90accd0b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bool_col` AS `bfcol_2`, + `float64_col` <> 0 AS `bfcol_3`, + `float64_col` <> 0 AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `bool_col`, + `bfcol_3` AS `float64_col`, + `bfcol_4` AS `float64_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql new file mode 100644 index 0000000000..32c8da56fa --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(CAST(`bool_col` AS INT64) AS FLOAT64) AS `bfcol_1`, + CAST('1.34235e4' AS FLOAT64) AS `bfcol_2`, + SAFE_CAST(SAFE_CAST(`bool_col` AS INT64) AS FLOAT64) AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `bool_col`, + `bfcol_2` AS `str_const`, + `bfcol_3` AS `bool_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql new file mode 100644 index 0000000000..d1577c0664 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + INT64(`json_col`) AS `bfcol_1`, + FLOAT64(`json_col`) AS `bfcol_2`, + BOOL(`json_col`) AS `bfcol_3`, + STRING(`json_col`) AS `bfcol_4`, + SAFE.INT64(`json_col`) AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col`, + `bfcol_2` AS `float64_col`, + `bfcol_3` AS `bool_col`, + `bfcol_4` AS `string_col`, + `bfcol_5` AS `int64_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql new file mode 100644 index 0000000000..e0fe2af9a9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql @@ -0,0 +1,33 @@ +WITH `bfcte_0` AS ( + SELECT + `datetime_col`, + `float64_col`, + `numeric_col`, + `time_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UNIX_MICROS(CAST(`datetime_col` AS TIMESTAMP)) AS `bfcol_5`, + UNIX_MICROS(SAFE_CAST(`datetime_col` AS TIMESTAMP)) AS `bfcol_6`, + TIME_DIFF(CAST(`time_col` AS TIME), '00:00:00', MICROSECOND) AS `bfcol_7`, + TIME_DIFF(SAFE_CAST(`time_col` AS TIME), '00:00:00', MICROSECOND) AS `bfcol_8`, + UNIX_MICROS(`timestamp_col`) AS `bfcol_9`, + CAST(TRUNC(`numeric_col`) AS INT64) AS `bfcol_10`, + CAST(TRUNC(`float64_col`) AS INT64) AS `bfcol_11`, + SAFE_CAST(TRUNC(`float64_col`) AS INT64) AS `bfcol_12`, + CAST('100' AS INT64) AS `bfcol_13` + FROM `bfcte_0` +) +SELECT + `bfcol_5` AS `datetime_col`, + `bfcol_6` AS `datetime_w_safe`, + `bfcol_7` AS `time_col`, + `bfcol_8` AS `time_w_safe`, + `bfcol_9` AS `timestamp_col`, + `bfcol_10` AS `numeric_col`, + `bfcol_11` AS `float64_col`, + `bfcol_12` AS `float64_w_safe`, + `bfcol_13` AS `str_const` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql new file mode 100644 index 0000000000..2defc2e72b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql @@ -0,0 +1,26 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + PARSE_JSON(CAST(`int64_col` AS STRING)) AS `bfcol_4`, + PARSE_JSON(CAST(`float64_col` AS STRING)) AS `bfcol_5`, + PARSE_JSON(CAST(`bool_col` AS STRING)) AS `bfcol_6`, + PARSE_JSON(`string_col`) AS `bfcol_7`, + PARSE_JSON(CAST(`bool_col` AS STRING)) AS `bfcol_8`, + PARSE_JSON_IN_SAFE(`string_col`) AS `bfcol_9` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `int64_col`, + `bfcol_5` AS `float64_col`, + `bfcol_6` AS `bool_col`, + `bfcol_7` AS `string_col`, + `bfcol_8` AS `bool_w_safe`, + `bfcol_9` AS `string_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql new file mode 100644 index 0000000000..da6eb6ce18 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(`int64_col` AS STRING) AS `bfcol_2`, + INITCAP(CAST(`bool_col` AS STRING)) AS `bfcol_3`, + INITCAP(SAFE_CAST(`bool_col` AS STRING)) AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col`, + `bfcol_3` AS `bool_col`, + `bfcol_4` AS `bool_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql new file mode 100644 index 0000000000..6523d8376c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(TIMESTAMP_MICROS(`int64_col`) AS DATETIME) AS `bfcol_1`, + CAST(TIMESTAMP_MICROS(`int64_col`) AS TIME) AS `bfcol_2`, + CAST(TIMESTAMP_MICROS(`int64_col`) AS TIMESTAMP) AS `bfcol_3`, + SAFE_CAST(TIMESTAMP_MICROS(`int64_col`) AS TIME) AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_to_datetime`, + `bfcol_2` AS `int64_to_time`, + `bfcol_3` AS `int64_to_timestamp`, + `bfcol_4` AS `int64_to_time_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql new file mode 100644 index 0000000000..08a489e240 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql @@ -0,0 +1,29 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col`, + `int64_too` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bool_col` THEN `int64_col` END AS `bfcol_4`, + CASE WHEN `bool_col` THEN `int64_col` WHEN `bool_col` THEN `int64_too` END AS `bfcol_5`, + CASE WHEN `bool_col` THEN `bool_col` WHEN `bool_col` THEN `bool_col` END AS `bfcol_6`, + CASE + WHEN `bool_col` + THEN `int64_col` + WHEN `bool_col` + THEN CAST(`bool_col` AS INT64) + WHEN `bool_col` + THEN `float64_col` + END AS `bfcol_7` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `single_case`, + `bfcol_5` AS `double_case`, + `bfcol_6` AS `bool_types_case`, + `bfcol_7` AS `mixed_types_cast` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql new file mode 100644 index 0000000000..b162593147 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `int64_too`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + GREATEST(LEAST(`rowindex`, `int64_too`), `int64_col`) AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_3` AS `result_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql new file mode 100644 index 0000000000..451de48b64 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `int64_too` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `int64_col` AS `bfcol_2`, + COALESCE(`int64_too`, `int64_col`) AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col`, + `bfcol_3` AS `int64_too` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_fillna/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_fillna/out.sql new file mode 100644 index 0000000000..07f2877e74 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_fillna/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(`int64_col`, `float64_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql new file mode 100644 index 0000000000..19fce60091 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FARM_FINGERPRINT(`string_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql new file mode 100644 index 0000000000..1bd2eb7426 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `bytes_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ~( + `int64_col` + ) AS `bfcol_6`, + ~( + `bytes_col` + ) AS `bfcol_7`, + NOT ( + `bool_col` + ) AS `bfcol_8` + FROM `bfcte_0` +) +SELECT + `bfcol_6` AS `int64_col`, + `bfcol_7` AS `bytes_col`, + `bfcol_8` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql new file mode 100644 index 0000000000..0a549bdd44 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `float64_col` IS NULL AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql new file mode 100644 index 0000000000..22628c6a4b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE `string_col` WHEN 'value1' THEN 'mapped1' ELSE `string_col` END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql new file mode 100644 index 0000000000..bf3425fe6d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + NOT `float64_col` IS NULL AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql new file mode 100644 index 0000000000..13b27c2e14 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql @@ -0,0 +1,70 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CONCAT( + CAST(FARM_FINGERPRINT( + CONCAT( + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bool_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bytes_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`date_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`datetime_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(ST_ASTEXT(`geography_col`), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`int64_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`int64_too` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`numeric_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`float64_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex_2` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(`string_col`, ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`time_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`timestamp_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`duration_col` AS STRING), ''), '\\', '\\\\')) + ) + ) AS STRING), + CAST(FARM_FINGERPRINT( + CONCAT( + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bool_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bytes_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`date_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`datetime_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(ST_ASTEXT(`geography_col`), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`int64_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`int64_too` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`numeric_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`float64_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex_2` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(`string_col`, ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`time_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`timestamp_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`duration_col` AS STRING), ''), '\\', '\\\\')), + '_' + ) + ) AS STRING), + CAST(RAND() AS STRING) + ) AS `bfcol_31` + FROM `bfcte_0` +) +SELECT + `bfcol_31` AS `row_key` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql new file mode 100644 index 0000000000..611cbf4e7e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `bytes_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(`bool_col` AS INT64) + BYTE_LENGTH(`bytes_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql new file mode 100644 index 0000000000..872c794333 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + IF(`bool_col`, `int64_col`, `float64_col`) AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_3` AS `result_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql new file mode 100644 index 0000000000..105b5f1665 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_AREA(`geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql new file mode 100644 index 0000000000..c338baeb5f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_ASTEXT(`geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql new file mode 100644 index 0000000000..2d4ac2e960 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_BOUNDARY(`geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql new file mode 100644 index 0000000000..84b3ab1600 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_BUFFER(`geography_col`, 1.0, 8.0, FALSE) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql new file mode 100644 index 0000000000..733f1e9495 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_CENTROID(`geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql new file mode 100644 index 0000000000..11b3b7f691 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_CONVEXHULL(`geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql new file mode 100644 index 0000000000..4e18216dda --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_DIFFERENCE(`geography_col`, `geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_distance/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_distance/out.sql new file mode 100644 index 0000000000..e98a581de7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_distance/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_DISTANCE(`geography_col`, `geography_col`, TRUE) AS `bfcol_1`, + ST_DISTANCE(`geography_col`, `geography_col`, FALSE) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `spheroid`, + `bfcol_2` AS `no_spheroid` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql new file mode 100644 index 0000000000..1bbb114349 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SAFE.ST_GEOGFROMTEXT(`string_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogpoint/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogpoint/out.sql new file mode 100644 index 0000000000..f6c953d161 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogpoint/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex`, + `rowindex_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_GEOGPOINT(`rowindex`, `rowindex_2`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `rowindex` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_intersection/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_intersection/out.sql new file mode 100644 index 0000000000..f9290fe01a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_intersection/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_INTERSECTION(`geography_col`, `geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql new file mode 100644 index 0000000000..516f175c13 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_ISCLOSED(`geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql new file mode 100644 index 0000000000..80eef1c906 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_LENGTH(`geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql new file mode 100644 index 0000000000..09211270d1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SAFE.ST_X(`geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql new file mode 100644 index 0000000000..625613ae2a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SAFE.ST_Y(`geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql new file mode 100644 index 0000000000..435ee96df1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_EXTRACT(`json_col`, '$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql new file mode 100644 index 0000000000..6c9c02594d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_EXTRACT_ARRAY(`json_col`, '$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql new file mode 100644 index 0000000000..a3a51be378 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_EXTRACT_STRING_ARRAY(`json_col`, '$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_keys/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_keys/out.sql new file mode 100644 index 0000000000..640f933bb2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_keys/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_KEYS(`json_col`, NULL) AS `bfcol_1`, + JSON_KEYS(`json_col`, 2) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_keys`, + `bfcol_2` AS `json_keys_w_max_depth` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql new file mode 100644 index 0000000000..164fe2e426 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_QUERY(`json_col`, '$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql new file mode 100644 index 0000000000..4c3fa8e7e9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_QUERY_ARRAY(`json_col`, '$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql new file mode 100644 index 0000000000..f41979ea2e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_SET(`json_col`, '$.a', 100) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql new file mode 100644 index 0000000000..72f7237240 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_VALUE(`json_col`, '$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql new file mode 100644 index 0000000000..5f80187ba0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + PARSE_JSON(`string_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json/out.sql new file mode 100644 index 0000000000..ebca0c51c5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TO_JSON(`string_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql new file mode 100644 index 0000000000..e282c89c80 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + TO_JSON_STRING(`json_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql new file mode 100644 index 0000000000..0fb9589387 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ABS(`float64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql new file mode 100644 index 0000000000..1707aad8c1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` + `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` + 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` + CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) + `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_add_int`, + `bfcol_40` AS `int_add_1`, + `bfcol_41` AS `int_add_bool`, + `bfcol_42` AS `bool_add_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql new file mode 100644 index 0000000000..cb674787ff --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CONCAT(`string_col`, 'a') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql new file mode 100644 index 0000000000..2fef18eeb8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql @@ -0,0 +1,60 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col`, + `rowindex`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `timestamp_col` AS `bfcol_7`, + `date_col` AS `bfcol_8`, + TIMESTAMP_ADD(CAST(`date_col` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + TIMESTAMP_ADD(`bfcol_7`, INTERVAL 86400000000 MICROSECOND) AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + TIMESTAMP_ADD(CAST(`bfcol_16` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + TIMESTAMP_ADD(`bfcol_25`, INTERVAL 86400000000 MICROSECOND) AS `bfcol_42` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + 172800000000 AS `bfcol_50` + FROM `bfcte_4` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `timestamp_col`, + `bfcol_38` AS `date_col`, + `bfcol_39` AS `date_add_timedelta`, + `bfcol_40` AS `timestamp_add_timedelta`, + `bfcol_41` AS `timedelta_add_date`, + `bfcol_42` AS `timedelta_add_timestamp`, + `bfcol_50` AS `timedelta_add_timedelta` +FROM `bfcte_5` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql new file mode 100644 index 0000000000..bb1766adf3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN ABS(`float64_col`) > 1 + THEN CAST('NaN' AS FLOAT64) + ELSE ACOS(`float64_col`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql new file mode 100644 index 0000000000..af556b9c3a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `float64_col` < 1 + THEN CAST('NaN' AS FLOAT64) + ELSE ACOSH(`float64_col`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql new file mode 100644 index 0000000000..8243232e0b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN ABS(`float64_col`) > 1 + THEN CAST('NaN' AS FLOAT64) + ELSE ASIN(`float64_col`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql new file mode 100644 index 0000000000..e6bf3b339c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ASINH(`float64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql new file mode 100644 index 0000000000..a85ff6403c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ATAN(`float64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql new file mode 100644 index 0000000000..28fc8c869d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ATAN2(`int64_col`, `float64_col`) AS `bfcol_6`, + ATAN2(CAST(`bool_col` AS INT64), `float64_col`) AS `bfcol_7` + FROM `bfcte_0` +) +SELECT + `bfcol_6` AS `int64_col`, + `bfcol_7` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql new file mode 100644 index 0000000000..197bf59306 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN ABS(`float64_col`) > 1 + THEN CAST('NaN' AS FLOAT64) + ELSE ATANH(`float64_col`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql new file mode 100644 index 0000000000..922fe5c550 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CEIL(`float64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql new file mode 100644 index 0000000000..0acb2bfa94 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COS(`float64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql new file mode 100644 index 0000000000..8c84a25047 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN ABS(`float64_col`) > 709.78 + THEN CAST('Infinity' AS FLOAT64) + ELSE COSH(`float64_col`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql new file mode 100644 index 0000000000..ba6b6bfa9f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `float_list_col`, + `int_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ML.DISTANCE(`int_list_col`, `int_list_col`, 'COSINE') AS `bfcol_2`, + ML.DISTANCE(`float_list_col`, `float_list_col`, 'COSINE') AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int_list_col`, + `bfcol_3` AS `float_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql new file mode 100644 index 0000000000..db11f1529f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql @@ -0,0 +1,122 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_8`, + `int64_col` AS `bfcol_9`, + `bool_col` AS `bfcol_10`, + `float64_col` AS `bfcol_11`, + IEEE_DIVIDE(`int64_col`, `int64_col`) AS `bfcol_12` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_8` AS `bfcol_18`, + `bfcol_9` AS `bfcol_19`, + `bfcol_10` AS `bfcol_20`, + `bfcol_11` AS `bfcol_21`, + `bfcol_12` AS `bfcol_22`, + IEEE_DIVIDE(`bfcol_9`, 1) AS `bfcol_23` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_18` AS `bfcol_30`, + `bfcol_19` AS `bfcol_31`, + `bfcol_20` AS `bfcol_32`, + `bfcol_21` AS `bfcol_33`, + `bfcol_22` AS `bfcol_34`, + `bfcol_23` AS `bfcol_35`, + IEEE_DIVIDE(`bfcol_19`, 0.0) AS `bfcol_36` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_30` AS `bfcol_44`, + `bfcol_31` AS `bfcol_45`, + `bfcol_32` AS `bfcol_46`, + `bfcol_33` AS `bfcol_47`, + `bfcol_34` AS `bfcol_48`, + `bfcol_35` AS `bfcol_49`, + `bfcol_36` AS `bfcol_50`, + IEEE_DIVIDE(`bfcol_31`, `bfcol_33`) AS `bfcol_51` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_44` AS `bfcol_60`, + `bfcol_45` AS `bfcol_61`, + `bfcol_46` AS `bfcol_62`, + `bfcol_47` AS `bfcol_63`, + `bfcol_48` AS `bfcol_64`, + `bfcol_49` AS `bfcol_65`, + `bfcol_50` AS `bfcol_66`, + `bfcol_51` AS `bfcol_67`, + IEEE_DIVIDE(`bfcol_47`, `bfcol_45`) AS `bfcol_68` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_60` AS `bfcol_78`, + `bfcol_61` AS `bfcol_79`, + `bfcol_62` AS `bfcol_80`, + `bfcol_63` AS `bfcol_81`, + `bfcol_64` AS `bfcol_82`, + `bfcol_65` AS `bfcol_83`, + `bfcol_66` AS `bfcol_84`, + `bfcol_67` AS `bfcol_85`, + `bfcol_68` AS `bfcol_86`, + IEEE_DIVIDE(`bfcol_63`, 0.0) AS `bfcol_87` + FROM `bfcte_5` +), `bfcte_7` AS ( + SELECT + *, + `bfcol_78` AS `bfcol_98`, + `bfcol_79` AS `bfcol_99`, + `bfcol_80` AS `bfcol_100`, + `bfcol_81` AS `bfcol_101`, + `bfcol_82` AS `bfcol_102`, + `bfcol_83` AS `bfcol_103`, + `bfcol_84` AS `bfcol_104`, + `bfcol_85` AS `bfcol_105`, + `bfcol_86` AS `bfcol_106`, + `bfcol_87` AS `bfcol_107`, + IEEE_DIVIDE(`bfcol_79`, CAST(`bfcol_80` AS INT64)) AS `bfcol_108` + FROM `bfcte_6` +), `bfcte_8` AS ( + SELECT + *, + `bfcol_98` AS `bfcol_120`, + `bfcol_99` AS `bfcol_121`, + `bfcol_100` AS `bfcol_122`, + `bfcol_101` AS `bfcol_123`, + `bfcol_102` AS `bfcol_124`, + `bfcol_103` AS `bfcol_125`, + `bfcol_104` AS `bfcol_126`, + `bfcol_105` AS `bfcol_127`, + `bfcol_106` AS `bfcol_128`, + `bfcol_107` AS `bfcol_129`, + `bfcol_108` AS `bfcol_130`, + IEEE_DIVIDE(CAST(`bfcol_100` AS INT64), `bfcol_99`) AS `bfcol_131` + FROM `bfcte_7` +) +SELECT + `bfcol_120` AS `rowindex`, + `bfcol_121` AS `int64_col`, + `bfcol_122` AS `bool_col`, + `bfcol_123` AS `float64_col`, + `bfcol_124` AS `int_div_int`, + `bfcol_125` AS `int_div_1`, + `bfcol_126` AS `int_div_0`, + `bfcol_127` AS `int_div_float`, + `bfcol_128` AS `float_div_int`, + `bfcol_129` AS `float_div_0`, + `bfcol_130` AS `int_div_bool`, + `bfcol_131` AS `bool_div_int` +FROM `bfcte_8` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql new file mode 100644 index 0000000000..1a82a67368 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `timestamp_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + CAST(FLOOR(IEEE_DIVIDE(86400000000, `int64_col`)) AS INT64) AS `bfcol_9` + FROM `bfcte_0` +) +SELECT + `bfcol_6` AS `rowindex`, + `bfcol_7` AS `timestamp_col`, + `bfcol_8` AS `int64_col`, + `bfcol_9` AS `timedelta_div_numeric` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_euclidean_distance/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_euclidean_distance/out.sql new file mode 100644 index 0000000000..3327a99f4b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_euclidean_distance/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int_list_col`, + `numeric_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ML.DISTANCE(`int_list_col`, `int_list_col`, 'EUCLIDEAN') AS `bfcol_2`, + ML.DISTANCE(`numeric_list_col`, `numeric_list_col`, 'EUCLIDEAN') AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int_list_col`, + `bfcol_3` AS `numeric_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql new file mode 100644 index 0000000000..610b96cda7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `float64_col` > 709.78 + THEN CAST('Infinity' AS FLOAT64) + ELSE EXP(`float64_col`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql new file mode 100644 index 0000000000..076ad584c2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `float64_col` > 709.78 + THEN CAST('Infinity' AS FLOAT64) + ELSE EXP(`float64_col`) + END - 1 AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql new file mode 100644 index 0000000000..e0c2e1072e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FLOOR(`float64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql new file mode 100644 index 0000000000..2fe20fb618 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col`, + `rowindex`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + 43200000000 AS `bfcol_6` + FROM `bfcte_0` +) +SELECT + `rowindex`, + `timestamp_col`, + `date_col`, + `bfcol_6` AS `timedelta_div_numeric` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql new file mode 100644 index 0000000000..776cc33e0f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `float64_col` <= 0 THEN CAST('NaN' AS FLOAT64) ELSE LN(`float64_col`) END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql new file mode 100644 index 0000000000..11a318c22d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `float64_col` <= 0 + THEN CAST('NaN' AS FLOAT64) + ELSE LOG(10, `float64_col`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql new file mode 100644 index 0000000000..4297fff227 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `float64_col` <= -1 + THEN CAST('NaN' AS FLOAT64) + ELSE LN(1 + `float64_col`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_manhattan_distance/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_manhattan_distance/out.sql new file mode 100644 index 0000000000..185bb7b277 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_manhattan_distance/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `float_list_col`, + `numeric_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ML.DISTANCE(`float_list_col`, `float_list_col`, 'MANHATTAN') AS `bfcol_2`, + ML.DISTANCE(`numeric_list_col`, `numeric_list_col`, 'MANHATTAN') AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `float_list_col`, + `bfcol_3` AS `numeric_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql new file mode 100644 index 0000000000..241ffa0b5e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql @@ -0,0 +1,292 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + CASE + WHEN `int64_col` = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `int64_col` + WHEN `int64_col` < CAST(0 AS INT64) + AND ( + MOD(`int64_col`, `int64_col`) + ) > CAST(0 AS INT64) + THEN `int64_col` + ( + MOD(`int64_col`, `int64_col`) + ) + WHEN `int64_col` > CAST(0 AS INT64) + AND ( + MOD(`int64_col`, `int64_col`) + ) < CAST(0 AS INT64) + THEN `int64_col` + ( + MOD(`int64_col`, `int64_col`) + ) + ELSE MOD(`int64_col`, `int64_col`) + END AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + CASE + WHEN -( + `bfcol_7` + ) = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_7` + WHEN -( + `bfcol_7` + ) < CAST(0 AS INT64) + AND ( + MOD(`bfcol_7`, -( + `bfcol_7` + )) + ) > CAST(0 AS INT64) + THEN -( + `bfcol_7` + ) + ( + MOD(`bfcol_7`, -( + `bfcol_7` + )) + ) + WHEN -( + `bfcol_7` + ) > CAST(0 AS INT64) + AND ( + MOD(`bfcol_7`, -( + `bfcol_7` + )) + ) < CAST(0 AS INT64) + THEN -( + `bfcol_7` + ) + ( + MOD(`bfcol_7`, -( + `bfcol_7` + )) + ) + ELSE MOD(`bfcol_7`, -( + `bfcol_7` + )) + END AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + CASE + WHEN 1 = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_15` + WHEN 1 < CAST(0 AS INT64) AND ( + MOD(`bfcol_15`, 1) + ) > CAST(0 AS INT64) + THEN 1 + ( + MOD(`bfcol_15`, 1) + ) + WHEN 1 > CAST(0 AS INT64) AND ( + MOD(`bfcol_15`, 1) + ) < CAST(0 AS INT64) + THEN 1 + ( + MOD(`bfcol_15`, 1) + ) + ELSE MOD(`bfcol_15`, 1) + END AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CASE + WHEN 0 = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_25` + WHEN 0 < CAST(0 AS INT64) AND ( + MOD(`bfcol_25`, 0) + ) > CAST(0 AS INT64) + THEN 0 + ( + MOD(`bfcol_25`, 0) + ) + WHEN 0 > CAST(0 AS INT64) AND ( + MOD(`bfcol_25`, 0) + ) < CAST(0 AS INT64) + THEN 0 + ( + MOD(`bfcol_25`, 0) + ) + ELSE MOD(`bfcol_25`, 0) + END AS `bfcol_42` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_36` AS `bfcol_50`, + `bfcol_37` AS `bfcol_51`, + `bfcol_38` AS `bfcol_52`, + `bfcol_39` AS `bfcol_53`, + `bfcol_40` AS `bfcol_54`, + `bfcol_41` AS `bfcol_55`, + `bfcol_42` AS `bfcol_56`, + CASE + WHEN CAST(`bfcol_38` AS BIGNUMERIC) = CAST(0 AS INT64) + THEN CAST('NaN' AS FLOAT64) * CAST(`bfcol_38` AS BIGNUMERIC) + WHEN CAST(`bfcol_38` AS BIGNUMERIC) < CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + ) > CAST(0 AS INT64) + THEN CAST(`bfcol_38` AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + ) + WHEN CAST(`bfcol_38` AS BIGNUMERIC) > CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + ) < CAST(0 AS INT64) + THEN CAST(`bfcol_38` AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + ) + ELSE MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + END AS `bfcol_57` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_50` AS `bfcol_66`, + `bfcol_51` AS `bfcol_67`, + `bfcol_52` AS `bfcol_68`, + `bfcol_53` AS `bfcol_69`, + `bfcol_54` AS `bfcol_70`, + `bfcol_55` AS `bfcol_71`, + `bfcol_56` AS `bfcol_72`, + `bfcol_57` AS `bfcol_73`, + CASE + WHEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) = CAST(0 AS INT64) + THEN CAST('NaN' AS FLOAT64) * CAST(`bfcol_52` AS BIGNUMERIC) + WHEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) < CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) + ) > CAST(0 AS INT64) + THEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) + ) + WHEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) > CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) + ) < CAST(0 AS INT64) + THEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) + ) + ELSE MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) + END AS `bfcol_74` + FROM `bfcte_5` +), `bfcte_7` AS ( + SELECT + *, + `bfcol_66` AS `bfcol_84`, + `bfcol_67` AS `bfcol_85`, + `bfcol_68` AS `bfcol_86`, + `bfcol_69` AS `bfcol_87`, + `bfcol_70` AS `bfcol_88`, + `bfcol_71` AS `bfcol_89`, + `bfcol_72` AS `bfcol_90`, + `bfcol_73` AS `bfcol_91`, + `bfcol_74` AS `bfcol_92`, + CASE + WHEN CAST(1 AS BIGNUMERIC) = CAST(0 AS INT64) + THEN CAST('NaN' AS FLOAT64) * CAST(`bfcol_68` AS BIGNUMERIC) + WHEN CAST(1 AS BIGNUMERIC) < CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + ) > CAST(0 AS INT64) + THEN CAST(1 AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + ) + WHEN CAST(1 AS BIGNUMERIC) > CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + ) < CAST(0 AS INT64) + THEN CAST(1 AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + ) + ELSE MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + END AS `bfcol_93` + FROM `bfcte_6` +), `bfcte_8` AS ( + SELECT + *, + `bfcol_84` AS `bfcol_104`, + `bfcol_85` AS `bfcol_105`, + `bfcol_86` AS `bfcol_106`, + `bfcol_87` AS `bfcol_107`, + `bfcol_88` AS `bfcol_108`, + `bfcol_89` AS `bfcol_109`, + `bfcol_90` AS `bfcol_110`, + `bfcol_91` AS `bfcol_111`, + `bfcol_92` AS `bfcol_112`, + `bfcol_93` AS `bfcol_113`, + CASE + WHEN CAST(0 AS BIGNUMERIC) = CAST(0 AS INT64) + THEN CAST('NaN' AS FLOAT64) * CAST(`bfcol_86` AS BIGNUMERIC) + WHEN CAST(0 AS BIGNUMERIC) < CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + ) > CAST(0 AS INT64) + THEN CAST(0 AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + ) + WHEN CAST(0 AS BIGNUMERIC) > CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + ) < CAST(0 AS INT64) + THEN CAST(0 AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + ) + ELSE MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + END AS `bfcol_114` + FROM `bfcte_7` +) +SELECT + `bfcol_104` AS `rowindex`, + `bfcol_105` AS `int64_col`, + `bfcol_106` AS `float64_col`, + `bfcol_107` AS `int_mod_int`, + `bfcol_108` AS `int_mod_int_neg`, + `bfcol_109` AS `int_mod_1`, + `bfcol_110` AS `int_mod_0`, + `bfcol_111` AS `float_mod_float`, + `bfcol_112` AS `float_mod_float_neg`, + `bfcol_113` AS `float_mod_1`, + `bfcol_114` AS `float_mod_0` +FROM `bfcte_8` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql new file mode 100644 index 0000000000..d0c537e482 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` * `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` * 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` * CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) * `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_mul_int`, + `bfcol_40` AS `int_mul_1`, + `bfcol_41` AS `int_mul_bool`, + `bfcol_42` AS `bool_mul_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql new file mode 100644 index 0000000000..ebdf296b2b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql @@ -0,0 +1,43 @@ +WITH `bfcte_0` AS ( + SELECT + `duration_col`, + `int64_col`, + `rowindex`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_8`, + `timestamp_col` AS `bfcol_9`, + `int64_col` AS `bfcol_10`, + `duration_col` AS `bfcol_11` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_10` AS `bfcol_18`, + `bfcol_11` AS `bfcol_19`, + CAST(FLOOR(`bfcol_11` * `bfcol_10`) AS INT64) AS `bfcol_20` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_19` AS `bfcol_29`, + `bfcol_20` AS `bfcol_30`, + CAST(FLOOR(`bfcol_18` * `bfcol_19`) AS INT64) AS `bfcol_31` + FROM `bfcte_2` +) +SELECT + `bfcol_26` AS `rowindex`, + `bfcol_27` AS `timestamp_col`, + `bfcol_28` AS `int64_col`, + `bfcol_29` AS `duration_col`, + `bfcol_30` AS `timedelta_mul_numeric`, + `bfcol_31` AS `numeric_mul_timedelta` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql new file mode 100644 index 0000000000..4374af349b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + -( + `float64_col` + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql new file mode 100644 index 0000000000..1ed016029a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `float64_col` AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pow/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pow/out.sql new file mode 100644 index 0000000000..05fbaa12c9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pow/out.sql @@ -0,0 +1,329 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + CASE + WHEN `int64_col` <> 0 AND `int64_col` * LN(ABS(`int64_col`)) > 43.66827237527655 + THEN NULL + ELSE CAST(POWER(CAST(`int64_col` AS NUMERIC), `int64_col`) AS INT64) + END AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + CASE + WHEN `bfcol_8` = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_7` = 1 + THEN 1 + WHEN `bfcol_7` = CAST(0 AS INT64) AND `bfcol_8` < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_7`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_7`, + CASE + WHEN ABS(`bfcol_8`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_8`) + ELSE `bfcol_8` + END + ) + WHEN ABS(`bfcol_8`) > 9007199254740992 + THEN POWER( + `bfcol_7`, + CASE + WHEN ABS(`bfcol_8`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_8`) + ELSE `bfcol_8` + END + ) + WHEN `bfcol_7` < CAST(0 AS INT64) AND NOT CAST(`bfcol_8` AS INT64) = `bfcol_8` + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_7` <> CAST(0 AS INT64) AND `bfcol_8` * LN(ABS(`bfcol_7`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_7` < CAST(0 AS INT64) AND MOD(CAST(`bfcol_8` AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_7`, + CASE + WHEN ABS(`bfcol_8`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_8`) + ELSE `bfcol_8` + END + ) + END AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + CASE + WHEN `bfcol_15` = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_16` = 1 + THEN 1 + WHEN `bfcol_16` = CAST(0 AS INT64) AND `bfcol_15` < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_16`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_16`, + CASE + WHEN ABS(`bfcol_15`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_15`) + ELSE `bfcol_15` + END + ) + WHEN ABS(`bfcol_15`) > 9007199254740992 + THEN POWER( + `bfcol_16`, + CASE + WHEN ABS(`bfcol_15`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_15`) + ELSE `bfcol_15` + END + ) + WHEN `bfcol_16` < CAST(0 AS INT64) AND NOT CAST(`bfcol_15` AS INT64) = `bfcol_15` + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_16` <> CAST(0 AS INT64) AND `bfcol_15` * LN(ABS(`bfcol_16`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_16` < CAST(0 AS INT64) AND MOD(CAST(`bfcol_15` AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_16`, + CASE + WHEN ABS(`bfcol_15`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_15`) + ELSE `bfcol_15` + END + ) + END AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CASE + WHEN `bfcol_26` = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_26` = 1 + THEN 1 + WHEN `bfcol_26` = CAST(0 AS INT64) AND `bfcol_26` < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_26`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_26`, + CASE + WHEN ABS(`bfcol_26`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_26`) + ELSE `bfcol_26` + END + ) + WHEN ABS(`bfcol_26`) > 9007199254740992 + THEN POWER( + `bfcol_26`, + CASE + WHEN ABS(`bfcol_26`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_26`) + ELSE `bfcol_26` + END + ) + WHEN `bfcol_26` < CAST(0 AS INT64) AND NOT CAST(`bfcol_26` AS INT64) = `bfcol_26` + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_26` <> CAST(0 AS INT64) AND `bfcol_26` * LN(ABS(`bfcol_26`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_26` < CAST(0 AS INT64) AND MOD(CAST(`bfcol_26` AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_26`, + CASE + WHEN ABS(`bfcol_26`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_26`) + ELSE `bfcol_26` + END + ) + END AS `bfcol_42` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_36` AS `bfcol_50`, + `bfcol_37` AS `bfcol_51`, + `bfcol_38` AS `bfcol_52`, + `bfcol_39` AS `bfcol_53`, + `bfcol_40` AS `bfcol_54`, + `bfcol_41` AS `bfcol_55`, + `bfcol_42` AS `bfcol_56`, + CASE + WHEN `bfcol_37` <> 0 AND 0 * LN(ABS(`bfcol_37`)) > 43.66827237527655 + THEN NULL + ELSE CAST(POWER(CAST(`bfcol_37` AS NUMERIC), 0) AS INT64) + END AS `bfcol_57` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_50` AS `bfcol_66`, + `bfcol_51` AS `bfcol_67`, + `bfcol_52` AS `bfcol_68`, + `bfcol_53` AS `bfcol_69`, + `bfcol_54` AS `bfcol_70`, + `bfcol_55` AS `bfcol_71`, + `bfcol_56` AS `bfcol_72`, + `bfcol_57` AS `bfcol_73`, + CASE + WHEN 0 = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_52` = 1 + THEN 1 + WHEN `bfcol_52` = CAST(0 AS INT64) AND 0 < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_52`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_52`, + CASE + WHEN ABS(0) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(0) + ELSE 0 + END + ) + WHEN ABS(0) > 9007199254740992 + THEN POWER( + `bfcol_52`, + CASE + WHEN ABS(0) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(0) + ELSE 0 + END + ) + WHEN `bfcol_52` < CAST(0 AS INT64) AND NOT CAST(0 AS INT64) = 0 + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_52` <> CAST(0 AS INT64) AND 0 * LN(ABS(`bfcol_52`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_52` < CAST(0 AS INT64) AND MOD(CAST(0 AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_52`, + CASE + WHEN ABS(0) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(0) + ELSE 0 + END + ) + END AS `bfcol_74` + FROM `bfcte_5` +), `bfcte_7` AS ( + SELECT + *, + `bfcol_66` AS `bfcol_84`, + `bfcol_67` AS `bfcol_85`, + `bfcol_68` AS `bfcol_86`, + `bfcol_69` AS `bfcol_87`, + `bfcol_70` AS `bfcol_88`, + `bfcol_71` AS `bfcol_89`, + `bfcol_72` AS `bfcol_90`, + `bfcol_73` AS `bfcol_91`, + `bfcol_74` AS `bfcol_92`, + CASE + WHEN `bfcol_67` <> 0 AND 1 * LN(ABS(`bfcol_67`)) > 43.66827237527655 + THEN NULL + ELSE CAST(POWER(CAST(`bfcol_67` AS NUMERIC), 1) AS INT64) + END AS `bfcol_93` + FROM `bfcte_6` +), `bfcte_8` AS ( + SELECT + *, + `bfcol_84` AS `bfcol_104`, + `bfcol_85` AS `bfcol_105`, + `bfcol_86` AS `bfcol_106`, + `bfcol_87` AS `bfcol_107`, + `bfcol_88` AS `bfcol_108`, + `bfcol_89` AS `bfcol_109`, + `bfcol_90` AS `bfcol_110`, + `bfcol_91` AS `bfcol_111`, + `bfcol_92` AS `bfcol_112`, + `bfcol_93` AS `bfcol_113`, + CASE + WHEN 1 = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_86` = 1 + THEN 1 + WHEN `bfcol_86` = CAST(0 AS INT64) AND 1 < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_86`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_86`, + CASE + WHEN ABS(1) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(1) + ELSE 1 + END + ) + WHEN ABS(1) > 9007199254740992 + THEN POWER( + `bfcol_86`, + CASE + WHEN ABS(1) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(1) + ELSE 1 + END + ) + WHEN `bfcol_86` < CAST(0 AS INT64) AND NOT CAST(1 AS INT64) = 1 + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_86` <> CAST(0 AS INT64) AND 1 * LN(ABS(`bfcol_86`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_86` < CAST(0 AS INT64) AND MOD(CAST(1 AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_86`, + CASE + WHEN ABS(1) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(1) + ELSE 1 + END + ) + END AS `bfcol_114` + FROM `bfcte_7` +) +SELECT + `bfcol_104` AS `rowindex`, + `bfcol_105` AS `int64_col`, + `bfcol_106` AS `float64_col`, + `bfcol_107` AS `int_pow_int`, + `bfcol_108` AS `int_pow_float`, + `bfcol_109` AS `float_pow_int`, + `bfcol_110` AS `float_pow_float`, + `bfcol_111` AS `int_pow_0`, + `bfcol_112` AS `float_pow_0`, + `bfcol_113` AS `int_pow_1`, + `bfcol_114` AS `float_pow_1` +FROM `bfcte_8` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql new file mode 100644 index 0000000000..9ce76f7c63 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql @@ -0,0 +1,81 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + CAST(ROUND(`int64_col`, 0) AS INT64) AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + CAST(ROUND(`bfcol_7`, 1) AS INT64) AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + CAST(ROUND(`bfcol_15`, -1) AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + ROUND(`bfcol_26`, 0) AS `bfcol_42` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_36` AS `bfcol_50`, + `bfcol_37` AS `bfcol_51`, + `bfcol_38` AS `bfcol_52`, + `bfcol_39` AS `bfcol_53`, + `bfcol_40` AS `bfcol_54`, + `bfcol_41` AS `bfcol_55`, + `bfcol_42` AS `bfcol_56`, + ROUND(`bfcol_38`, 1) AS `bfcol_57` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_50` AS `bfcol_66`, + `bfcol_51` AS `bfcol_67`, + `bfcol_52` AS `bfcol_68`, + `bfcol_53` AS `bfcol_69`, + `bfcol_54` AS `bfcol_70`, + `bfcol_55` AS `bfcol_71`, + `bfcol_56` AS `bfcol_72`, + `bfcol_57` AS `bfcol_73`, + ROUND(`bfcol_52`, -1) AS `bfcol_74` + FROM `bfcte_5` +) +SELECT + `bfcol_66` AS `rowindex`, + `bfcol_67` AS `int64_col`, + `bfcol_68` AS `float64_col`, + `bfcol_69` AS `int_round_0`, + `bfcol_70` AS `int_round_1`, + `bfcol_71` AS `int_round_m1`, + `bfcol_72` AS `float_round_0`, + `bfcol_73` AS `float_round_1`, + `bfcol_74` AS `float_round_m1` +FROM `bfcte_6` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql new file mode 100644 index 0000000000..1699b6d8df --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SIN(`float64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql new file mode 100644 index 0000000000..c1ea003e2d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN ABS(`float64_col`) > 709.78 + THEN SIGN(`float64_col`) * CAST('Infinity' AS FLOAT64) + ELSE SINH(`float64_col`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql new file mode 100644 index 0000000000..152545d550 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `float64_col` < 0 THEN CAST('NaN' AS FLOAT64) ELSE SQRT(`float64_col`) END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql new file mode 100644 index 0000000000..7e0f07af7b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` - `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` - 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` - CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) - `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_add_int`, + `bfcol_40` AS `int_add_1`, + `bfcol_41` AS `int_add_bool`, + `bfcol_42` AS `bool_add_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql new file mode 100644 index 0000000000..ebcffd67f6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql @@ -0,0 +1,82 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col`, + `duration_col`, + `rowindex`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_8`, + `timestamp_col` AS `bfcol_9`, + `date_col` AS `bfcol_10`, + `duration_col` AS `bfcol_11` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_11` AS `bfcol_18`, + `bfcol_10` AS `bfcol_19`, + TIMESTAMP_SUB(CAST(`bfcol_10` AS DATETIME), INTERVAL `bfcol_11` MICROSECOND) AS `bfcol_20` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_19` AS `bfcol_29`, + `bfcol_20` AS `bfcol_30`, + TIMESTAMP_SUB(`bfcol_17`, INTERVAL `bfcol_18` MICROSECOND) AS `bfcol_31` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + `bfcol_30` AS `bfcol_42`, + `bfcol_31` AS `bfcol_43`, + TIMESTAMP_DIFF(CAST(`bfcol_29` AS DATETIME), CAST(`bfcol_29` AS DATETIME), MICROSECOND) AS `bfcol_44` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_38` AS `bfcol_52`, + `bfcol_39` AS `bfcol_53`, + `bfcol_40` AS `bfcol_54`, + `bfcol_41` AS `bfcol_55`, + `bfcol_42` AS `bfcol_56`, + `bfcol_43` AS `bfcol_57`, + `bfcol_44` AS `bfcol_58`, + TIMESTAMP_DIFF(`bfcol_39`, `bfcol_39`, MICROSECOND) AS `bfcol_59` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_52` AS `bfcol_68`, + `bfcol_53` AS `bfcol_69`, + `bfcol_54` AS `bfcol_70`, + `bfcol_55` AS `bfcol_71`, + `bfcol_56` AS `bfcol_72`, + `bfcol_57` AS `bfcol_73`, + `bfcol_58` AS `bfcol_74`, + `bfcol_59` AS `bfcol_75`, + `bfcol_54` - `bfcol_54` AS `bfcol_76` + FROM `bfcte_5` +) +SELECT + `bfcol_68` AS `rowindex`, + `bfcol_69` AS `timestamp_col`, + `bfcol_70` AS `duration_col`, + `bfcol_71` AS `date_col`, + `bfcol_72` AS `date_sub_timedelta`, + `bfcol_73` AS `timestamp_sub_timedelta`, + `bfcol_74` AS `timestamp_sub_date`, + `bfcol_75` AS `date_sub_timestamp`, + `bfcol_76` AS `timedelta_sub_timedelta` +FROM `bfcte_6` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql new file mode 100644 index 0000000000..f09d26a188 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TAN(`float64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql new file mode 100644 index 0000000000..a5e5a87fbc --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TANH(`float64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `float64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_unsafe_pow_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_unsafe_pow_op/out.sql new file mode 100644 index 0000000000..9957a34665 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_unsafe_pow_op/out.sql @@ -0,0 +1,43 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bool_col` AS `bfcol_3`, + `int64_col` AS `bfcol_4`, + `float64_col` AS `bfcol_5`, + ( + `int64_col` >= 0 + ) AND ( + `int64_col` <= 10 + ) AS `bfcol_6` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + * + FROM `bfcte_1` + WHERE + `bfcol_6` +), `bfcte_3` AS ( + SELECT + *, + POWER(`bfcol_4`, `bfcol_4`) AS `bfcol_14`, + POWER(`bfcol_4`, `bfcol_5`) AS `bfcol_15`, + POWER(`bfcol_5`, `bfcol_4`) AS `bfcol_16`, + POWER(`bfcol_5`, `bfcol_5`) AS `bfcol_17`, + POWER(`bfcol_4`, CAST(`bfcol_3` AS INT64)) AS `bfcol_18`, + POWER(CAST(`bfcol_3` AS INT64), `bfcol_4`) AS `bfcol_19` + FROM `bfcte_2` +) +SELECT + `bfcol_14` AS `int_pow_int`, + `bfcol_15` AS `int_pow_float`, + `bfcol_16` AS `float_pow_int`, + `bfcol_17` AS `float_pow_float`, + `bfcol_18` AS `int_pow_bool`, + `bfcol_19` AS `bool_pow_int` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql new file mode 100644 index 0000000000..cb674787ff --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CONCAT(`string_col`, 'a') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql new file mode 100644 index 0000000000..dd1f1473f4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INITCAP(`string_col`, '') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql new file mode 100644 index 0000000000..eeb2574094 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ENDS_WITH(`string_col`, 'ab') AS `bfcol_1`, + ENDS_WITH(`string_col`, 'ab') OR ENDS_WITH(`string_col`, 'cd') AS `bfcol_2`, + FALSE AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `single`, + `bfcol_2` AS `double`, + `bfcol_3` AS `empty` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql new file mode 100644 index 0000000000..61c2643f16 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`string_col`, '^(\\p{N}|\\p{L})+$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql new file mode 100644 index 0000000000..2b086f3e3d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`string_col`, '^\\p{L}+$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql new file mode 100644 index 0000000000..d4dddc348f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`string_col`, '^(\\p{Nd})+$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql new file mode 100644 index 0000000000..eba0e51ed0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS( + `string_col`, + '^[\\p{Nd}\\x{00B9}\\x{00B2}\\x{00B3}\\x{2070}\\x{2074}-\\x{2079}\\x{2080}-\\x{2089}]+$' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql new file mode 100644 index 0000000000..b6ff57797c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LOWER(`string_col`) = `string_col` AND UPPER(`string_col`) <> `string_col` AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql new file mode 100644 index 0000000000..6143b3685a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`string_col`, '^\\pN+$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql new file mode 100644 index 0000000000..47ccd642d4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`string_col`, '^\\s+$') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql new file mode 100644 index 0000000000..54f7b55ce3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UPPER(`string_col`) = `string_col` AND LOWER(`string_col`) <> `string_col` AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql new file mode 100644 index 0000000000..63e8e160bf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LENGTH(`string_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len_w_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len_w_array/out.sql new file mode 100644 index 0000000000..609c4131e6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len_w_array/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ARRAY_LENGTH(`int_list_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql new file mode 100644 index 0000000000..0a9623162a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LOWER(`string_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql new file mode 100644 index 0000000000..1b73ee3258 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LTRIM(`string_col`, ' ') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql new file mode 100644 index 0000000000..2fd3365a80 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_REPLACE(`string_col`, 'e', 'a') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql new file mode 100644 index 0000000000..61b2e2f432 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REPLACE(`string_col`, 'e', 'a') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql new file mode 100644 index 0000000000..f9d287a591 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REVERSE(`string_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql new file mode 100644 index 0000000000..72bdbba29f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + RTRIM(`string_col`, ' ') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql new file mode 100644 index 0000000000..54c8adb7b8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + STARTS_WITH(`string_col`, 'ab') AS `bfcol_1`, + STARTS_WITH(`string_col`, 'ab') OR STARTS_WITH(`string_col`, 'cd') AS `bfcol_2`, + FALSE AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `single`, + `bfcol_2` AS `double`, + `bfcol_3` AS `empty` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql new file mode 100644 index 0000000000..e973a97136 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `string_col` LIKE '%e%' AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql new file mode 100644 index 0000000000..510e52e254 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`string_col`, 'e') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql new file mode 100644 index 0000000000..ad02f6b223 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + IF( + REGEXP_CONTAINS(`string_col`, '([a-z]*)'), + REGEXP_REPLACE(`string_col`, CONCAT('.*?', '([a-z]*)', '.*'), '\\1'), + NULL + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql new file mode 100644 index 0000000000..82847d5e22 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INSTR(`string_col`, 'e', 1) - 1 AS `bfcol_1`, + INSTR(`string_col`, 'e', 3) - 1 AS `bfcol_2`, + INSTR(SUBSTRING(`string_col`, 1, 5), 'e') - 1 AS `bfcol_3`, + INSTR(SUBSTRING(`string_col`, 3, 3), 'e') - 1 AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `none_none`, + `bfcol_2` AS `start_none`, + `bfcol_3` AS `none_end`, + `bfcol_4` AS `start_end` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql new file mode 100644 index 0000000000..f868b73032 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + IF(SUBSTRING(`string_col`, 2, 1) <> '', SUBSTRING(`string_col`, 2, 1), NULL) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql new file mode 100644 index 0000000000..2bb6042fe9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LPAD(`string_col`, GREATEST(LENGTH(`string_col`), 10), '-') AS `bfcol_1`, + RPAD(`string_col`, GREATEST(LENGTH(`string_col`), 10), '-') AS `bfcol_2`, + RPAD( + LPAD( + `string_col`, + CAST(FLOOR(SAFE_DIVIDE(GREATEST(LENGTH(`string_col`), 10) - LENGTH(`string_col`), 2)) AS INT64) + LENGTH(`string_col`), + '-' + ), + GREATEST(LENGTH(`string_col`), 10), + '-' + ) AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `left`, + `bfcol_2` AS `right`, + `bfcol_3` AS `both` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql new file mode 100644 index 0000000000..90a52a40b1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REPEAT(`string_col`, 2) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql new file mode 100644 index 0000000000..8bd2a5f7fe --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SUBSTRING(`string_col`, 2, 2) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql new file mode 100644 index 0000000000..cb674787ff --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CONCAT(`string_col`, 'a') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql new file mode 100644 index 0000000000..37b15a0cf9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SPLIT(`string_col`, ',') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql new file mode 100644 index 0000000000..ebe4c39bbf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TRIM(`string_col`, ' ') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql new file mode 100644 index 0000000000..aa14c5f05d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UPPER(`string_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql new file mode 100644 index 0000000000..79c4f695aa --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN STARTS_WITH(`string_col`, '-') + THEN CONCAT('-', LPAD(SUBSTRING(`string_col`, 2), GREATEST(LENGTH(`string_col`), 10) - 1, '0')) + ELSE LPAD(`string_col`, GREATEST(LENGTH(`string_col`), 10), '0') + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql new file mode 100644 index 0000000000..b85e88a90a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `people` + FROM `bigframes-dev`.`sqlglot_test`.`nested_structs_types` +), `bfcte_1` AS ( + SELECT + *, + `people`.`name` AS `bfcol_1`, + `people`.`name` AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string`, + `bfcol_2` AS `int` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql new file mode 100644 index 0000000000..575a162080 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + STRUCT( + `bool_col` AS bool_col, + `int64_col` AS int64_col, + `float64_col` AS float64_col, + `string_col` AS string_col + ) AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `result_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql new file mode 100644 index 0000000000..432aefd7f6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FLOOR(`int64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql new file mode 100644 index 0000000000..ed7dbc7c8a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + `int64_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + CAST(FLOOR(`bfcol_8` * 1000000) AS INT64) AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` * 3600000000 AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + `bfcol_27` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `float64_col`, + `bfcol_39` AS `duration_us`, + `bfcol_40` AS `duration_s`, + `bfcol_41` AS `duration_w`, + `bfcol_42` AS `duration_on_duration` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py new file mode 100644 index 0000000000..1397c7d6c0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -0,0 +1,347 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +from packaging import version +import pytest +import sqlglot + +from bigframes import dataframe +from bigframes import operations as ops +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + +CONNECTION_ID = "bigframes-dev.us.bigframes-default-connection" + + +def test_ai_generate(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerate( + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + output_schema=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_with_connection_id(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerate( + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + output_schema=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_with_output_schema(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerate( + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + output_schema="x INT64, y FLOAT64", + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_with_model_param(scalar_types_df: dataframe.DataFrame, snapshot): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this version." + ) + + col_name = "string_col" + + op = ops.AIGenerate( + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint=None, + request_type="shared", + model_params=json.dumps(dict()), + output_schema=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_bool(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerateBool( + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_bool_with_connection_id( + scalar_types_df: dataframe.DataFrame, snapshot +): + col_name = "string_col" + + op = ops.AIGenerateBool( + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_bool_with_model_param( + scalar_types_df: dataframe.DataFrame, snapshot +): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this version." + ) + + col_name = "string_col" + + op = ops.AIGenerateBool( + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint=None, + request_type="shared", + model_params=json.dumps(dict()), + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_int(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerateInt( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_int_with_connection_id( + scalar_types_df: dataframe.DataFrame, snapshot +): + col_name = "string_col" + + op = ops.AIGenerateInt( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_int_with_model_param( + scalar_types_df: dataframe.DataFrame, snapshot +): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this version." + ) + + col_name = "string_col" + + op = ops.AIGenerateInt( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint=None, + request_type="shared", + model_params=json.dumps(dict()), + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_double(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerateDouble( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_double_with_connection_id( + scalar_types_df: dataframe.DataFrame, snapshot +): + col_name = "string_col" + + op = ops.AIGenerateDouble( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_double_with_model_param( + scalar_types_df: dataframe.DataFrame, snapshot +): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this version." + ) + + col_name = "string_col" + + op = ops.AIGenerateDouble( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint=None, + request_type="shared", + model_params=json.dumps(dict()), + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_if(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIIf( + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_classify(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIClassify( + prompt_context=(None,), + categories=("greeting", "rejection"), + connection_id=CONNECTION_ID, + ) + + sql = utils._apply_ops_to_sql(scalar_types_df, [op.as_expr(col_name)], ["result"]) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_score(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIScore( + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py new file mode 100644 index 0000000000..67c8bb0e5c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py @@ -0,0 +1,99 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +from bigframes.core import expression +from bigframes.operations._op_converters import convert_index, convert_slice +import bigframes.operations.aggregations as agg_ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_array_to_string(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.ArrayToStringOp(delimiter=".").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_array_index(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [convert_index(1).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_array_reduce_op(repeated_types_df: bpd.DataFrame, snapshot): + ops_map = { + "sum_float": ops.ArrayReduceOp(agg_ops.SumOp()).as_expr("float_list_col"), + "std_float": ops.ArrayReduceOp(agg_ops.StdOp()).as_expr("float_list_col"), + "count_str": ops.ArrayReduceOp(agg_ops.CountOp()).as_expr("string_list_col"), + "any_bool": ops.ArrayReduceOp(agg_ops.AnyOp()).as_expr("bool_list_col"), + } + + sql = utils._apply_ops_to_sql( + repeated_types_df, list(ops_map.values()), list(ops_map.keys()) + ) + snapshot.assert_match(sql, "out.sql") + + +def test_array_slice_with_only_start(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [convert_slice(slice(1, None)).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_array_slice_with_start_and_stop(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [convert_slice(slice(1, 5)).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_to_array_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col", "string_col"]] + # Bigquery won't allow you to materialize arrays with null, so use non-nullable + int64_non_null = ops.coalesce_op.as_expr("int64_col", expression.const(0)) + bool_col_non_null = ops.coalesce_op.as_expr("bool_col", expression.const(False)) + float_col_non_null = ops.coalesce_op.as_expr("float64_col", expression.const(0.0)) + string_col_non_null = ops.coalesce_op.as_expr("string_col", expression.const("")) + + ops_map = { + "bool_col": ops.ToArrayOp().as_expr(bool_col_non_null), + "int64_col": ops.ToArrayOp().as_expr(int64_non_null), + "strs_col": ops.ToArrayOp().as_expr(string_col_non_null, string_col_non_null), + "numeric_col": ops.ToArrayOp().as_expr( + int64_non_null, bool_col_non_null, float_col_non_null + ), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py new file mode 100644 index 0000000000..80aa22aaac --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_obj_fetch_metadata(scalar_types_df: bpd.DataFrame, snapshot): + blob_s = scalar_types_df["string_col"].str.to_blob() + sql = blob_s.blob.version().to_frame().sql + snapshot.assert_match(sql, "out.sql") + + +def test_obj_get_access_url(scalar_types_df: bpd.DataFrame, snapshot): + blob_s = scalar_types_df["string_col"].str.to_blob() + sql = blob_s.blob.read_url().to_frame().sql + snapshot.assert_match(sql, "out.sql") + + +def test_obj_make_ref(scalar_types_df: bpd.DataFrame, snapshot): + blob_df = scalar_types_df["string_col"].str.to_blob() + snapshot.assert_match(blob_df.to_frame().sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_bool_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_bool_ops.py new file mode 100644 index 0000000000..08b60d6ddf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_bool_ops.py @@ -0,0 +1,43 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_and_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "int64_col"]] + + bf_df["int_and_int"] = bf_df["int64_col"] & bf_df["int64_col"] + bf_df["bool_and_bool"] = bf_df["bool_col"] & bf_df["bool_col"] + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_or_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "int64_col"]] + + bf_df["int_and_int"] = bf_df["int64_col"] | bf_df["int64_col"] + bf_df["bool_and_bool"] = bf_df["bool_col"] | bf_df["bool_col"] + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_xor_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "int64_col"]] + + bf_df["int_and_int"] = bf_df["int64_col"] ^ bf_df["int64_col"] + bf_df["bool_and_bool"] = bf_df["bool_col"] ^ bf_df["bool_col"] + snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py new file mode 100644 index 0000000000..20dd6c5ca6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py @@ -0,0 +1,136 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_is_in(scalar_types_df: bpd.DataFrame, snapshot): + int_col = "int64_col" + float_col = "float64_col" + bf_df = scalar_types_df[[int_col, float_col]] + ops_map = { + "ints": ops.IsInOp(values=(1, 2, 3)).as_expr(int_col), + "ints_w_null": ops.IsInOp(values=(None, 123456)).as_expr(int_col), + "floats": ops.IsInOp(values=(1.0, 2.0, 3.0), match_nulls=False).as_expr( + int_col + ), + "strings": ops.IsInOp(values=("1.0", "2.0")).as_expr(int_col), + "mixed": ops.IsInOp(values=("1.0", 2.5, 3)).as_expr(int_col), + "empty": ops.IsInOp(values=()).as_expr(int_col), + "ints_wo_match_nulls": ops.IsInOp( + values=(None, 123456), match_nulls=False + ).as_expr(int_col), + "float_in_ints": ops.IsInOp(values=(1, 2, 3, None)).as_expr(float_col), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_eq_null_match(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + sql = utils._apply_binary_op(bf_df, ops.eq_null_match_op, "int64_col", "bool_col") + snapshot.assert_match(sql, "out.sql") + + +def test_eq_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_ne_int"] = bf_df["int64_col"] == bf_df["int64_col"] + bf_df["int_ne_1"] = bf_df["int64_col"] == 1 + + bf_df["int_ne_bool"] = bf_df["int64_col"] == bf_df["bool_col"] + bf_df["bool_ne_int"] = bf_df["bool_col"] == bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_gt_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_gt_int"] = bf_df["int64_col"] > bf_df["int64_col"] + bf_df["int_gt_1"] = bf_df["int64_col"] > 1 + + bf_df["int_gt_bool"] = bf_df["int64_col"] > bf_df["bool_col"] + bf_df["bool_gt_int"] = bf_df["bool_col"] > bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_ge_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_ge_int"] = bf_df["int64_col"] >= bf_df["int64_col"] + bf_df["int_ge_1"] = bf_df["int64_col"] >= 1 + + bf_df["int_ge_bool"] = bf_df["int64_col"] >= bf_df["bool_col"] + bf_df["bool_ge_int"] = bf_df["bool_col"] >= bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_lt_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_lt_int"] = bf_df["int64_col"] < bf_df["int64_col"] + bf_df["int_lt_1"] = bf_df["int64_col"] < 1 + + bf_df["int_lt_bool"] = bf_df["int64_col"] < bf_df["bool_col"] + bf_df["bool_lt_int"] = bf_df["bool_col"] < bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_le_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_le_int"] = bf_df["int64_col"] <= bf_df["int64_col"] + bf_df["int_le_1"] = bf_df["int64_col"] <= 1 + + bf_df["int_le_bool"] = bf_df["int64_col"] <= bf_df["bool_col"] + bf_df["bool_le_int"] = bf_df["bool_col"] <= bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_maximum_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + sql = utils._apply_binary_op(bf_df, ops.maximum_op, "int64_col", "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_minimum_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + sql = utils._apply_binary_op(bf_df, ops.minimum_op, "int64_col", "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_ne_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_ne_int"] = bf_df["int64_col"] != bf_df["int64_col"] + bf_df["int_ne_1"] = bf_df["int64_col"] != 1 + + bf_df["int_ne_bool"] = bf_df["int64_col"] != bf_df["bool_col"] + bf_df["bool_ne_int"] = bf_df["bool_col"] != bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py new file mode 100644 index 0000000000..c4acb37e51 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py @@ -0,0 +1,295 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_date(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.date_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_day(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.day_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_dayofweek(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["datetime_col", "timestamp_col", "date_col"] + bf_df = scalar_types_df[col_names] + ops_map = {col_name: ops.dayofweek_op.as_expr(col_name) for col_name in col_names} + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_dayofyear(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.dayofyear_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_datetime_to_integer_label(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["datetime_col", "timestamp_col"] + bf_df = scalar_types_df[col_names] + ops_map = { + "fixed_freq": ops.DatetimeToIntegerLabelOp( + freq=pd.tseries.offsets.Day(), origin="start", closed="left" # type: ignore + ).as_expr("datetime_col", "timestamp_col"), + "non_fixed_freq_weekly": ops.DatetimeToIntegerLabelOp( + freq=pd.tseries.offsets.Week(weekday=6), origin="start", closed="left" # type: ignore + ).as_expr("datetime_col", "timestamp_col"), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_floor_dt(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["datetime_col", "timestamp_col", "date_col"] + bf_df = scalar_types_df[col_names] + ops_map = { + "timestamp_col_us": ops.FloorDtOp("us").as_expr("timestamp_col"), + "timestamp_col_ms": ops.FloorDtOp("ms").as_expr("timestamp_col"), + "timestamp_col_s": ops.FloorDtOp("s").as_expr("timestamp_col"), + "timestamp_col_min": ops.FloorDtOp("min").as_expr("timestamp_col"), + "timestamp_col_h": ops.FloorDtOp("h").as_expr("timestamp_col"), + "timestamp_col_D": ops.FloorDtOp("D").as_expr("timestamp_col"), + "timestamp_col_W": ops.FloorDtOp("W").as_expr("timestamp_col"), + "timestamp_col_M": ops.FloorDtOp("M").as_expr("timestamp_col"), + "timestamp_col_Q": ops.FloorDtOp("Q").as_expr("timestamp_col"), + "timestamp_col_Y": ops.FloorDtOp("Y").as_expr("timestamp_col"), + "datetime_col_q": ops.FloorDtOp("us").as_expr("datetime_col"), + "datetime_col_us": ops.FloorDtOp("us").as_expr("datetime_col"), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_floor_dt_op_invalid_freq(scalar_types_df: bpd.DataFrame): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + with pytest.raises( + NotImplementedError, match="Unsupported freq paramater: invalid" + ): + utils._apply_ops_to_sql( + bf_df, + [ops.FloorDtOp(freq="invalid").as_expr(col_name)], # type:ignore + [col_name], + ) + + +def test_hour(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.hour_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_minute(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.minute_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_month(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.month_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_normalize(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.normalize_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_quarter(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.quarter_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_second(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.second_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_strftime(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "datetime_col", "date_col", "time_col"]] + ops_map = { + "date_col": ops.StrftimeOp("%Y-%m-%d").as_expr("date_col"), + "datetime_col": ops.StrftimeOp("%Y-%m-%d").as_expr("datetime_col"), + "time_col": ops.StrftimeOp("%Y-%m-%d").as_expr("time_col"), + "timestamp_col": ops.StrftimeOp("%Y-%m-%d").as_expr("timestamp_col"), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_time(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.time_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_to_datetime(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["int64_col", "string_col", "float64_col"] + bf_df = scalar_types_df[col_names] + ops_map = {col_name: ops.ToDatetimeOp().as_expr(col_name) for col_name in col_names} + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_to_timestamp(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "string_col", "float64_col"]] + ops_map = { + "int64_col": ops.ToTimestampOp().as_expr("int64_col"), + "float64_col": ops.ToTimestampOp().as_expr("float64_col"), + "int64_col_s": ops.ToTimestampOp(unit="s").as_expr("int64_col"), + "int64_col_ms": ops.ToTimestampOp(unit="ms").as_expr("int64_col"), + "int64_col_us": ops.ToTimestampOp(unit="us").as_expr("int64_col"), + "int64_col_ns": ops.ToTimestampOp(unit="ns").as_expr("int64_col"), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_unix_micros(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.UnixMicros().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_unix_millis(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.UnixMillis().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_unix_seconds(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.UnixSeconds().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_year(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.year_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_iso_day(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.iso_day_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_iso_week(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.iso_week_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_iso_year(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.iso_year_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_add_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "date_col"]] + timedelta = pd.Timedelta(1, unit="d") + + bf_df["date_add_timedelta"] = bf_df["date_col"] + timedelta + bf_df["timestamp_add_timedelta"] = bf_df["timestamp_col"] + timedelta + bf_df["timedelta_add_date"] = timedelta + bf_df["date_col"] + bf_df["timedelta_add_timestamp"] = timedelta + bf_df["timestamp_col"] + bf_df["timedelta_add_timedelta"] = timedelta + timedelta + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_sub_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "duration_col", "date_col"]] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + bf_df["date_sub_timedelta"] = bf_df["date_col"] - bf_df["duration_col"] + bf_df["timestamp_sub_timedelta"] = bf_df["timestamp_col"] - bf_df["duration_col"] + bf_df["timestamp_sub_date"] = bf_df["date_col"] - bf_df["date_col"] + bf_df["date_sub_timestamp"] = bf_df["timestamp_col"] - bf_df["timestamp_col"] + bf_df["timedelta_sub_timedelta"] = bf_df["duration_col"] - bf_df["duration_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py new file mode 100644 index 0000000000..11daf6813a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -0,0 +1,328 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import dtypes +from bigframes import operations as ops +from bigframes.core import expression as ex +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_astype_int(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + to_type = dtypes.INT_DTYPE + + ops_map = { + "datetime_col": ops.AsTypeOp(to_type=to_type).as_expr("datetime_col"), + "datetime_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr( + "datetime_col" + ), + "time_col": ops.AsTypeOp(to_type=to_type).as_expr("time_col"), + "time_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr("time_col"), + "timestamp_col": ops.AsTypeOp(to_type=to_type).as_expr("timestamp_col"), + "numeric_col": ops.AsTypeOp(to_type=to_type).as_expr("numeric_col"), + "float64_col": ops.AsTypeOp(to_type=to_type).as_expr("float64_col"), + "float64_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr( + "float64_col" + ), + "str_const": ops.AsTypeOp(to_type=to_type).as_expr(ex.const("100")), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_float(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + to_type = dtypes.FLOAT_DTYPE + + ops_map = { + "bool_col": ops.AsTypeOp(to_type=to_type).as_expr("bool_col"), + "str_const": ops.AsTypeOp(to_type=to_type).as_expr(ex.const("1.34235e4")), + "bool_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr("bool_col"), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_bool(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + to_type = dtypes.BOOL_DTYPE + + ops_map = { + "bool_col": ops.AsTypeOp(to_type=to_type).as_expr("bool_col"), + "float64_col": ops.AsTypeOp(to_type=to_type).as_expr("float64_col"), + "float64_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr( + "float64_col" + ), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_time_like(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + + ops_map = { + "int64_to_datetime": ops.AsTypeOp(to_type=dtypes.DATETIME_DTYPE).as_expr( + "int64_col" + ), + "int64_to_time": ops.AsTypeOp(to_type=dtypes.TIME_DTYPE).as_expr("int64_col"), + "int64_to_timestamp": ops.AsTypeOp(to_type=dtypes.TIMESTAMP_DTYPE).as_expr( + "int64_col" + ), + "int64_to_time_safe": ops.AsTypeOp( + to_type=dtypes.TIME_DTYPE, safe=True + ).as_expr("int64_col"), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_string(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + to_type = dtypes.STRING_DTYPE + + ops_map = { + "int64_col": ops.AsTypeOp(to_type=to_type).as_expr("int64_col"), + "bool_col": ops.AsTypeOp(to_type=to_type).as_expr("bool_col"), + "bool_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr("bool_col"), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_json(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + + ops_map = { + "int64_col": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr("int64_col"), + "float64_col": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr("float64_col"), + "bool_col": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr("bool_col"), + "string_col": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr("string_col"), + "bool_w_safe": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE, safe=True).as_expr( + "bool_col" + ), + "string_w_safe": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE, safe=True).as_expr( + "string_col" + ), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_from_json(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df + + ops_map = { + "int64_col": ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr("json_col"), + "float64_col": ops.AsTypeOp(to_type=dtypes.FLOAT_DTYPE).as_expr("json_col"), + "bool_col": ops.AsTypeOp(to_type=dtypes.BOOL_DTYPE).as_expr("json_col"), + "string_col": ops.AsTypeOp(to_type=dtypes.STRING_DTYPE).as_expr("json_col"), + "int64_w_safe": ops.AsTypeOp(to_type=dtypes.INT_DTYPE, safe=True).as_expr( + "json_col" + ), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_json_invalid( + scalar_types_df: bpd.DataFrame, json_types_df: bpd.DataFrame +): + # Test invalid cast to JSON + with pytest.raises(TypeError, match="Cannot cast timestamp.* to .*json.*"): + ops_map_to = { + "datetime_to_json": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr( + "datetime_col" + ), + } + utils._apply_ops_to_sql( + scalar_types_df, list(ops_map_to.values()), list(ops_map_to.keys()) + ) + + # Test invalid cast from JSON + with pytest.raises(TypeError, match="Cannot cast .*json.* to timestamp.*"): + ops_map_from = { + "json_to_datetime": ops.AsTypeOp(to_type=dtypes.DATETIME_DTYPE).as_expr( + "json_col" + ), + } + utils._apply_ops_to_sql( + json_types_df, list(ops_map_from.values()), list(ops_map_from.keys()) + ) + + +def test_case_when_op(scalar_types_df: bpd.DataFrame, snapshot): + ops_map = { + "single_case": ops.case_when_op.as_expr( + "bool_col", + "int64_col", + ), + "double_case": ops.case_when_op.as_expr( + "bool_col", + "int64_col", + "bool_col", + "int64_too", + ), + "bool_types_case": ops.case_when_op.as_expr( + "bool_col", + "bool_col", + "bool_col", + "bool_col", + ), + "mixed_types_cast": ops.case_when_op.as_expr( + "bool_col", + "int64_col", + "bool_col", + "bool_col", + "bool_col", + "float64_col", + ), + } + + array_value = scalar_types_df._block.expr + result, col_ids = array_value.compute_values(list(ops_map.values())) + + # Rename columns for deterministic golden SQL results. + assert len(col_ids) == len(ops_map.keys()) + result = result.rename_columns( + {col_id: key for col_id, key in zip(col_ids, ops_map.keys())} + ).select_columns(list(ops_map.keys())) + + sql = result.session._executor.to_sql(result, enable_cache=False) + snapshot.assert_match(sql, "out.sql") + + +def test_coalesce(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "int64_too"]] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.coalesce_op.as_expr("int64_col", "int64_col"), + ops.coalesce_op.as_expr("int64_too", "int64_col"), + ], + ["int64_col", "int64_too"], + ) + snapshot.assert_match(sql, "out.sql") + + +def test_clip(scalar_types_df: bpd.DataFrame, snapshot): + op_expr = ops.clip_op.as_expr("rowindex", "int64_col", "int64_too") + + array_value = scalar_types_df._block.expr + result, col_ids = array_value.compute_values([op_expr]) + + # Rename columns for deterministic golden SQL results. + assert len(col_ids) == 1 + result = result.rename_columns({col_ids[0]: "result_col"}).select_columns( + ["result_col"] + ) + + sql = result.session._executor.to_sql(result, enable_cache=False) + snapshot.assert_match(sql, "out.sql") + + +def test_fillna(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + sql = utils._apply_binary_op(bf_df, ops.fillna_op, "int64_col", "float64_col") + snapshot.assert_match(sql, "out.sql") + + +def test_hash(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.hash_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_invert(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bytes_col", "bool_col"]] + ops_map = { + "int64_col": ops.invert_op.as_expr("int64_col"), + "bytes_col": ops.invert_op.as_expr("bytes_col"), + "bool_col": ops.invert_op.as_expr("bool_col"), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + + snapshot.assert_match(sql, "out.sql") + + +def test_isnull(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.isnull_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_notnull(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.notnull_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_row_key(scalar_types_df: bpd.DataFrame, snapshot): + column_ids = (col for col in scalar_types_df._block.expr.column_ids) + sql = utils._apply_ops_to_sql( + scalar_types_df, [ops.RowKey().as_expr(*column_ids)], ["row_key"] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_sql_scalar_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "bytes_col"]] + sql = utils._apply_nary_op( + bf_df, + ops.SqlScalarOp(dtypes.INT_DTYPE, "CAST({0} AS INT64) + BYTE_LENGTH({1})"), + "bool_col", + "bytes_col", + ) + snapshot.assert_match(sql, "out.sql") + + +def test_map(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, + [ops.MapOp(mappings=(("value1", "mapped1"),)).as_expr(col_name)], + [col_name], + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_where(scalar_types_df: bpd.DataFrame, snapshot): + op_expr = ops.where_op.as_expr("int64_col", "bool_col", "float64_col") + + array_value = scalar_types_df._block.expr + result, col_ids = array_value.compute_values([op_expr]) + + # Rename columns for deterministic golden SQL results. + assert len(col_ids) == 1 + result = result.rename_columns({col_ids[0]: "result_col"}).select_columns( + ["result_col"] + ) + + sql = result.session._executor.to_sql(result, enable_cache=False) + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py new file mode 100644 index 0000000000..9047ce4d04 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py @@ -0,0 +1,168 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_geo_area(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.geo_area_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_astext(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.geo_st_astext_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_boundary(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.geo_st_boundary_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_buffer(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.GeoStBufferOp(1.0, 8.0, False).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_centroid(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.geo_st_centroid_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.geo_st_convexhull_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_distance(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.GeoStDistanceOp(use_spheroid=True).as_expr(col_name, col_name), + ops.GeoStDistanceOp(use_spheroid=False).as_expr(col_name, col_name), + ], + ["spheroid", "no_spheroid"], + ) + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_difference(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_binary_op(bf_df, ops.geo_st_difference_op, col_name, col_name) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.geo_st_geogfromtext_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_geogpoint(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["rowindex", "rowindex_2"] + bf_df = scalar_types_df[col_names] + sql = utils._apply_binary_op( + bf_df, ops.geo_st_geogpoint_op, col_names[0], col_names[1] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_intersection(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_binary_op(bf_df, ops.geo_st_intersection_op, col_name, col_name) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_isclosed(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.geo_st_isclosed_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_length(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.GeoStLengthOp(True).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_x(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.geo_x_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_y(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.geo_y_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py new file mode 100644 index 0000000000..1c5894fc96 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py @@ -0,0 +1,132 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.core.expression as ex +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_json_extract(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.JSONExtract(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_extract_array(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.JSONExtractArray(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_extract_string_array(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.JSONExtractStringArray(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_keys(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + + ops_map = { + "json_keys": ops.JSONKeys().as_expr(col_name), + "json_keys_w_max_depth": ops.JSONKeys(max_depth=2).as_expr(col_name), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_json_query(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.JSONQuery(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_query_array(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.JSONQueryArray(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_value(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.JSONValue(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_parse_json(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.ParseJSON().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_to_json(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.ToJSON().as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_to_json_string(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.ToJSONString().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_set(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = utils._apply_binary_op( + bf_df, ops.JSONSet(json_path="$.a"), "json_col", ex.const(100) + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py new file mode 100644 index 0000000000..1a08a80eb1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -0,0 +1,489 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pytest + +from bigframes import operations as ops +import bigframes.core.expression as ex +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_arccosh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.arccosh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arccos(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.arccos_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arcsin(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.arcsin_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arcsinh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.arcsinh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arctan2(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col", "bool_col"]] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.arctan2_op.as_expr("int64_col", "float64_col"), + ops.arctan2_op.as_expr("bool_col", "float64_col"), + ], + ["int64_col", "bool_col"], + ) + snapshot.assert_match(sql, "out.sql") + + +def test_arctan(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.arctan_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arctanh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.arctanh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_abs(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.abs_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_ceil(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.ceil_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_cos(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.cos_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_cosh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.cosh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_cosine_distance(repeated_types_df: bpd.DataFrame, snapshot): + col_names = ["int_list_col", "float_list_col"] + bf_df = repeated_types_df[col_names] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.cosine_distance_op.as_expr("int_list_col", "int_list_col"), + ops.cosine_distance_op.as_expr("float_list_col", "float_list_col"), + ], + ["int_list_col", "float_list_col"], + ) + snapshot.assert_match(sql, "out.sql") + + +def test_exp(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.exp_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_expm1(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.expm1_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_floor(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.floor_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_ln(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.ln_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_log10(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.log10_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_log1p(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.log1p_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_neg(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.neg_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_pos(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.pos_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_pow(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + + bf_df["int_pow_int"] = bf_df["int64_col"] ** bf_df["int64_col"] + bf_df["int_pow_float"] = bf_df["int64_col"] ** bf_df["float64_col"] + bf_df["float_pow_int"] = bf_df["float64_col"] ** bf_df["int64_col"] + bf_df["float_pow_float"] = bf_df["float64_col"] ** bf_df["float64_col"] + + bf_df["int_pow_0"] = bf_df["int64_col"] ** 0 + bf_df["float_pow_0"] = bf_df["float64_col"] ** 0 + bf_df["int_pow_1"] = bf_df["int64_col"] ** 1 + bf_df["float_pow_1"] = bf_df["float64_col"] ** 1 + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_round(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + + bf_df["int_round_0"] = bf_df["int64_col"].round(0) + bf_df["int_round_1"] = bf_df["int64_col"].round(1) + bf_df["int_round_m1"] = bf_df["int64_col"].round(-1) + + bf_df["float_round_0"] = bf_df["float64_col"].round(0) + bf_df["float_round_1"] = bf_df["float64_col"].round(1) + bf_df["float_round_m1"] = bf_df["float64_col"].round(-1) + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_sqrt(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.sqrt_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_sin(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.sin_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_sinh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.sinh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_tan(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.tan_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_tanh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.tanh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_add_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_add_int"] = bf_df["int64_col"] + bf_df["int64_col"] + bf_df["int_add_1"] = bf_df["int64_col"] + 1 + + bf_df["int_add_bool"] = bf_df["int64_col"] + bf_df["bool_col"] + bf_df["bool_add_int"] = bf_df["bool_col"] + bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_add_string(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = utils._apply_binary_op(bf_df, ops.add_op, "string_col", ex.const("a")) + + snapshot.assert_match(sql, "out.sql") + + +def test_add_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "date_col"]] + timedelta = pd.Timedelta(1, unit="d") + + bf_df["date_add_timedelta"] = bf_df["date_col"] + timedelta + bf_df["timestamp_add_timedelta"] = bf_df["timestamp_col"] + timedelta + bf_df["timedelta_add_date"] = timedelta + bf_df["date_col"] + bf_df["timedelta_add_timestamp"] = timedelta + bf_df["timestamp_col"] + bf_df["timedelta_add_timedelta"] = timedelta + timedelta + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_add_unsupported_raises(scalar_types_df: bpd.DataFrame): + with pytest.raises(TypeError): + utils._apply_binary_op(scalar_types_df, ops.add_op, "timestamp_col", "date_col") + + with pytest.raises(TypeError): + utils._apply_binary_op(scalar_types_df, ops.add_op, "int64_col", "string_col") + + +def test_div_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] + + bf_df["int_div_int"] = bf_df["int64_col"] / bf_df["int64_col"] + bf_df["int_div_1"] = bf_df["int64_col"] / 1 + bf_df["int_div_0"] = bf_df["int64_col"] / 0.0 + + bf_df["int_div_float"] = bf_df["int64_col"] / bf_df["float64_col"] + bf_df["float_div_int"] = bf_df["float64_col"] / bf_df["int64_col"] + bf_df["float_div_0"] = bf_df["float64_col"] / 0.0 + + bf_df["int_div_bool"] = bf_df["int64_col"] / bf_df["bool_col"] + bf_df["bool_div_int"] = bf_df["bool_col"] / bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_div_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "int64_col"]] + timedelta = pd.Timedelta(1, unit="d") + bf_df["timedelta_div_numeric"] = timedelta / bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_euclidean_distance(repeated_types_df: bpd.DataFrame, snapshot): + col_names = ["int_list_col", "numeric_list_col"] + bf_df = repeated_types_df[col_names] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.euclidean_distance_op.as_expr("int_list_col", "int_list_col"), + ops.euclidean_distance_op.as_expr("numeric_list_col", "numeric_list_col"), + ], + ["int_list_col", "numeric_list_col"], + ) + snapshot.assert_match(sql, "out.sql") + + +def test_floordiv_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] + + bf_df["int_div_int"] = bf_df["int64_col"] // bf_df["int64_col"] + bf_df["int_div_1"] = bf_df["int64_col"] // 1 + bf_df["int_div_0"] = bf_df["int64_col"] // 0.0 + + bf_df["int_div_float"] = bf_df["int64_col"] // bf_df["float64_col"] + bf_df["float_div_int"] = bf_df["float64_col"] // bf_df["int64_col"] + bf_df["float_div_0"] = bf_df["float64_col"] // 0.0 + + bf_df["int_div_bool"] = bf_df["int64_col"] // bf_df["bool_col"] + bf_df["bool_div_int"] = bf_df["bool_col"] // bf_df["int64_col"] + + +def test_floordiv_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "date_col"]] + timedelta = pd.Timedelta(1, unit="d") + + bf_df["timedelta_div_numeric"] = timedelta // 2 + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_manhattan_distance(repeated_types_df: bpd.DataFrame, snapshot): + col_names = ["float_list_col", "numeric_list_col"] + bf_df = repeated_types_df[col_names] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.manhattan_distance_op.as_expr("float_list_col", "float_list_col"), + ops.manhattan_distance_op.as_expr("numeric_list_col", "numeric_list_col"), + ], + ["float_list_col", "numeric_list_col"], + ) + snapshot.assert_match(sql, "out.sql") + + +def test_mul_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_mul_int"] = bf_df["int64_col"] * bf_df["int64_col"] + bf_df["int_mul_1"] = bf_df["int64_col"] * 1 + + bf_df["int_mul_bool"] = bf_df["int64_col"] * bf_df["bool_col"] + bf_df["bool_mul_int"] = bf_df["bool_col"] * bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_mul_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "int64_col", "duration_col"]] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + bf_df["timedelta_mul_numeric"] = bf_df["duration_col"] * bf_df["int64_col"] + bf_df["numeric_mul_timedelta"] = bf_df["int64_col"] * bf_df["duration_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_mod_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + + bf_df["int_mod_int"] = bf_df["int64_col"] % bf_df["int64_col"] + bf_df["int_mod_int_neg"] = bf_df["int64_col"] % -bf_df["int64_col"] + bf_df["int_mod_1"] = bf_df["int64_col"] % 1 + bf_df["int_mod_0"] = bf_df["int64_col"] % 0 + + bf_df["float_mod_float"] = bf_df["float64_col"] % bf_df["float64_col"] + bf_df["float_mod_float_neg"] = bf_df["float64_col"] % -bf_df["float64_col"] + bf_df["float_mod_1"] = bf_df["float64_col"] % 1 + bf_df["float_mod_0"] = bf_df["float64_col"] % 0 + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_sub_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_add_int"] = bf_df["int64_col"] - bf_df["int64_col"] + bf_df["int_add_1"] = bf_df["int64_col"] - 1 + + bf_df["int_add_bool"] = bf_df["int64_col"] - bf_df["bool_col"] + bf_df["bool_add_int"] = bf_df["bool_col"] - bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_sub_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "duration_col", "date_col"]] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + bf_df["date_sub_timedelta"] = bf_df["date_col"] - bf_df["duration_col"] + bf_df["timestamp_sub_timedelta"] = bf_df["timestamp_col"] - bf_df["duration_col"] + bf_df["timestamp_sub_date"] = bf_df["date_col"] - bf_df["date_col"] + bf_df["date_sub_timestamp"] = bf_df["timestamp_col"] - bf_df["timestamp_col"] + bf_df["timedelta_sub_timedelta"] = bf_df["duration_col"] - bf_df["duration_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_sub_unsupported_raises(scalar_types_df: bpd.DataFrame): + with pytest.raises(TypeError): + utils._apply_binary_op(scalar_types_df, ops.sub_op, "string_col", "string_col") + + with pytest.raises(TypeError): + utils._apply_binary_op(scalar_types_df, ops.sub_op, "int64_col", "string_col") + + +def test_unsafe_pow_op(scalar_types_df: bpd.DataFrame, snapshot): + # Choose certain row so the sql execution won't fail even with unsafe_pow_op. + bf_df = scalar_types_df[ + (scalar_types_df["int64_col"] >= 0) & (scalar_types_df["int64_col"] <= 10) + ] + bf_df = bf_df[["int64_col", "float64_col", "bool_col"]] + + int64_col_id = bf_df["int64_col"]._value_column + float64_col_id = bf_df["float64_col"]._value_column + bool_col_id = bf_df["bool_col"]._value_column + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.unsafe_pow_op.as_expr(int64_col_id, int64_col_id), + ops.unsafe_pow_op.as_expr(int64_col_id, float64_col_id), + ops.unsafe_pow_op.as_expr(float64_col_id, int64_col_id), + ops.unsafe_pow_op.as_expr(float64_col_id, float64_col_id), + ops.unsafe_pow_op.as_expr(int64_col_id, bool_col_id), + ops.unsafe_pow_op.as_expr(bool_col_id, int64_col_id), + ], + [ + "int_pow_int", + "int_pow_float", + "float_pow_int", + "float_pow_float", + "int_pow_bool", + "bool_pow_int", + ], + ) + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py new file mode 100644 index 0000000000..d1856b259d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py @@ -0,0 +1,330 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.core.expression as ex +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_capitalize(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.capitalize_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_endswith(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "single": ops.EndsWithOp(pat=("ab",)).as_expr(col_name), + "double": ops.EndsWithOp(pat=("ab", "cd")).as_expr(col_name), + "empty": ops.EndsWithOp(pat=()).as_expr(col_name), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_isalnum(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.isalnum_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_isalpha(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.isalpha_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_isdecimal(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.isdecimal_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_isdigit(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.isdigit_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_islower(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.islower_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_isnumeric(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.isnumeric_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_isspace(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.isspace_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_isupper(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.isupper_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_len(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.len_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_len_w_array(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "int_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.len_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_lower(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.lower_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_lstrip(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrLstripOp(" ").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_replace_str(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.ReplaceStrOp("e", "a").as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_regex_replace_str(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.RegexReplaceStrOp(r"e", "a").as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_reverse(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.reverse_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_rstrip(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrRstripOp(" ").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_startswith(scalar_types_df: bpd.DataFrame, snapshot): + + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "single": ops.StartsWithOp(pat=("ab",)).as_expr(col_name), + "double": ops.StartsWithOp(pat=("ab", "cd")).as_expr(col_name), + "empty": ops.StartsWithOp(pat=()).as_expr(col_name), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_str_get(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrGetOp(1).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_pad(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "left": ops.StrPadOp(length=10, fillchar="-", side="left").as_expr(col_name), + "right": ops.StrPadOp(length=10, fillchar="-", side="right").as_expr(col_name), + "both": ops.StrPadOp(length=10, fillchar="-", side="both").as_expr(col_name), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_str_slice(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrSliceOp(1, 3).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_strip(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrStripOp(" ").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_contains(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrContainsOp("e").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_contains_regex(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrContainsRegexOp("e").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_extract(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrExtractOp(r"([a-z]*)", 1).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_repeat(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrRepeatOp(2).as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_str_find(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "none_none": ops.StrFindOp("e", start=None, end=None).as_expr(col_name), + "start_none": ops.StrFindOp("e", start=2, end=None).as_expr(col_name), + "none_end": ops.StrFindOp("e", start=None, end=5).as_expr(col_name), + "start_end": ops.StrFindOp("e", start=2, end=5).as_expr(col_name), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + + snapshot.assert_match(sql, "out.sql") + + +def test_string_split(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.StringSplitOp(pat=",").as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_upper(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.upper_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_zfill(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.ZfillOp(width=10).as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_add_string(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = utils._apply_binary_op(bf_df, ops.add_op, "string_col", ex.const("a")) + + snapshot.assert_match(sql, "out.sql") + + +def test_strconcat(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = utils._apply_binary_op(bf_df, ops.strconcat_op, "string_col", ex.const("a")) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py new file mode 100644 index 0000000000..0e24426fe8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py @@ -0,0 +1,68 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing + +import pytest + +from bigframes import operations as ops +from bigframes.core import expression as ex +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def _apply_nary_op( + obj: bpd.DataFrame, + op: ops.NaryOp, + *args: typing.Union[str, ex.Expression], +) -> str: + """Applies a nary op to the given DataFrame and return the SQL representing + the resulting DataFrame.""" + array_value = obj._block.expr + op_expr = op.as_expr(*args) + result, col_ids = array_value.compute_values([op_expr]) + + # Rename columns for deterministic golden SQL results. + assert len(col_ids) == 1 + result = result.rename_columns({col_ids[0]: "result_col"}).select_columns( + ["result_col"] + ) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def test_struct_field(nested_structs_types_df: bpd.DataFrame, snapshot): + col_name = "people" + bf_df = nested_structs_types_df[[col_name]] + + ops_map = { + # When a name string is provided. + "string": ops.StructFieldOp("name").as_expr(col_name), + # When an index integer is provided. + "int": ops.StructFieldOp(0).as_expr(col_name), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + + snapshot.assert_match(sql, "out.sql") + + +def test_struct_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "int64_col", "float64_col", "string_col"]] + op = ops.StructOp(column_names=tuple(bf_df.columns.tolist())) + sql = _apply_nary_op(bf_df, op, *bf_df.columns.tolist()) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py new file mode 100644 index 0000000000..164c11aab5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py @@ -0,0 +1,41 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_to_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + bf_df["duration_us"] = bpd.to_timedelta(bf_df["int64_col"], "us") + bf_df["duration_s"] = bpd.to_timedelta(bf_df["float64_col"], "s") + bf_df["duration_w"] = bpd.to_timedelta(bf_df["int64_col"], "h") + bf_df["duration_on_duration"] = bpd.to_timedelta(bf_df["duration_us"], "ms") + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_timedelta_floor(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql( + bf_df, [ops.timedelta_floor_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate/out.sql new file mode 100644 index 0000000000..949ed82574 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate/out.sql @@ -0,0 +1,27 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_too` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `int64_too` AS `bfcol_2`, + `bool_col` AS `bfcol_3` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + `bfcol_3`, + COALESCE(SUM(`bfcol_2`), 0) AS `bfcol_6` + FROM `bfcte_1` + WHERE + NOT `bfcol_3` IS NULL + GROUP BY + `bfcol_3` +) +SELECT + `bfcol_3` AS `bool_col`, + `bfcol_6` AS `int64_too` +FROM `bfcte_2` +ORDER BY + `bfcol_3` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate_wo_dropna/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate_wo_dropna/out.sql new file mode 100644 index 0000000000..3c09250858 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate_wo_dropna/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_too` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `int64_too` AS `bfcol_2`, + `bool_col` AS `bfcol_3` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + `bfcol_3`, + COALESCE(SUM(`bfcol_2`), 0) AS `bfcol_6` + FROM `bfcte_1` + GROUP BY + `bfcol_3` +) +SELECT + `bfcol_3` AS `bool_col`, + `bfcol_6` AS `int64_too` +FROM `bfcte_2` +ORDER BY + `bfcol_3` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql new file mode 100644 index 0000000000..a0d7db2b1a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql @@ -0,0 +1,82 @@ +WITH `bfcte_1` AS ( + SELECT + `int64_col`, + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + *, + ROW_NUMBER() OVER () - 1 AS `bfcol_7` + FROM `bfcte_1` +), `bfcte_5` AS ( + SELECT + *, + 0 AS `bfcol_8` + FROM `bfcte_3` +), `bfcte_6` AS ( + SELECT + `rowindex` AS `bfcol_9`, + `rowindex` AS `bfcol_10`, + `int64_col` AS `bfcol_11`, + `string_col` AS `bfcol_12`, + `bfcol_8` AS `bfcol_13`, + `bfcol_7` AS `bfcol_14` + FROM `bfcte_5` +), `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + *, + ROW_NUMBER() OVER () - 1 AS `bfcol_22` + FROM `bfcte_0` +), `bfcte_4` AS ( + SELECT + *, + 1 AS `bfcol_23` + FROM `bfcte_2` +), `bfcte_7` AS ( + SELECT + `rowindex` AS `bfcol_24`, + `rowindex` AS `bfcol_25`, + `int64_col` AS `bfcol_26`, + `string_col` AS `bfcol_27`, + `bfcol_23` AS `bfcol_28`, + `bfcol_22` AS `bfcol_29` + FROM `bfcte_4` +), `bfcte_8` AS ( + SELECT + * + FROM ( + SELECT + `bfcol_9` AS `bfcol_30`, + `bfcol_10` AS `bfcol_31`, + `bfcol_11` AS `bfcol_32`, + `bfcol_12` AS `bfcol_33`, + `bfcol_13` AS `bfcol_34`, + `bfcol_14` AS `bfcol_35` + FROM `bfcte_6` + UNION ALL + SELECT + `bfcol_24` AS `bfcol_30`, + `bfcol_25` AS `bfcol_31`, + `bfcol_26` AS `bfcol_32`, + `bfcol_27` AS `bfcol_33`, + `bfcol_28` AS `bfcol_34`, + `bfcol_29` AS `bfcol_35` + FROM `bfcte_7` + ) +) +SELECT + `bfcol_30` AS `rowindex`, + `bfcol_31` AS `rowindex_1`, + `bfcol_32` AS `int64_col`, + `bfcol_33` AS `string_col` +FROM `bfcte_8` +ORDER BY + `bfcol_34` ASC NULLS LAST, + `bfcol_35` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql new file mode 100644 index 0000000000..8e65381fef --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql @@ -0,0 +1,142 @@ +WITH `bfcte_2` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_6` AS ( + SELECT + *, + ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) - 1 AS `bfcol_4` + FROM `bfcte_2` +), `bfcte_10` AS ( + SELECT + *, + 0 AS `bfcol_5` + FROM `bfcte_6` +), `bfcte_13` AS ( + SELECT + `float64_col` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bfcol_5` AS `bfcol_8`, + `bfcol_4` AS `bfcol_9` + FROM `bfcte_10` +), `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_too` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_0` + WHERE + `bool_col` +), `bfcte_8` AS ( + SELECT + *, + ROW_NUMBER() OVER () - 1 AS `bfcol_15` + FROM `bfcte_4` +), `bfcte_12` AS ( + SELECT + *, + 1 AS `bfcol_16` + FROM `bfcte_8` +), `bfcte_14` AS ( + SELECT + `float64_col` AS `bfcol_17`, + `int64_too` AS `bfcol_18`, + `bfcol_16` AS `bfcol_19`, + `bfcol_15` AS `bfcol_20` + FROM `bfcte_12` +), `bfcte_1` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_5` AS ( + SELECT + *, + ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) - 1 AS `bfcol_25` + FROM `bfcte_1` +), `bfcte_9` AS ( + SELECT + *, + 2 AS `bfcol_26` + FROM `bfcte_5` +), `bfcte_15` AS ( + SELECT + `float64_col` AS `bfcol_27`, + `int64_col` AS `bfcol_28`, + `bfcol_26` AS `bfcol_29`, + `bfcol_25` AS `bfcol_30` + FROM `bfcte_9` +), `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_too` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + * + FROM `bfcte_0` + WHERE + `bool_col` +), `bfcte_7` AS ( + SELECT + *, + ROW_NUMBER() OVER () - 1 AS `bfcol_36` + FROM `bfcte_3` +), `bfcte_11` AS ( + SELECT + *, + 3 AS `bfcol_37` + FROM `bfcte_7` +), `bfcte_16` AS ( + SELECT + `float64_col` AS `bfcol_38`, + `int64_too` AS `bfcol_39`, + `bfcol_37` AS `bfcol_40`, + `bfcol_36` AS `bfcol_41` + FROM `bfcte_11` +), `bfcte_17` AS ( + SELECT + * + FROM ( + SELECT + `bfcol_6` AS `bfcol_42`, + `bfcol_7` AS `bfcol_43`, + `bfcol_8` AS `bfcol_44`, + `bfcol_9` AS `bfcol_45` + FROM `bfcte_13` + UNION ALL + SELECT + `bfcol_17` AS `bfcol_42`, + `bfcol_18` AS `bfcol_43`, + `bfcol_19` AS `bfcol_44`, + `bfcol_20` AS `bfcol_45` + FROM `bfcte_14` + UNION ALL + SELECT + `bfcol_27` AS `bfcol_42`, + `bfcol_28` AS `bfcol_43`, + `bfcol_29` AS `bfcol_44`, + `bfcol_30` AS `bfcol_45` + FROM `bfcte_15` + UNION ALL + SELECT + `bfcol_38` AS `bfcol_42`, + `bfcol_39` AS `bfcol_43`, + `bfcol_40` AS `bfcol_44`, + `bfcol_41` AS `bfcol_45` + FROM `bfcte_16` + ) +) +SELECT + `bfcol_42` AS `float64_col`, + `bfcol_43` AS `int64_col` +FROM `bfcte_17` +ORDER BY + `bfcol_44` ASC NULLS LAST, + `bfcol_45` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_dataframe/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_dataframe/out.sql new file mode 100644 index 0000000000..e594b67669 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_dataframe/out.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `int_list_col`, + `rowindex`, + `string_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + * + REPLACE (`int_list_col`[SAFE_OFFSET(`bfcol_13`)] AS `int_list_col`, `string_list_col`[SAFE_OFFSET(`bfcol_13`)] AS `string_list_col`) + FROM `bfcte_0` + CROSS JOIN UNNEST(GENERATE_ARRAY(0, LEAST(ARRAY_LENGTH(`int_list_col`) - 1, ARRAY_LENGTH(`string_list_col`) - 1))) AS `bfcol_13` WITH OFFSET AS `bfcol_7` +) +SELECT + `rowindex`, + `rowindex` AS `rowindex_1`, + `int_list_col`, + `string_list_col` +FROM `bfcte_1` +ORDER BY + `bfcol_7` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_series/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_series/out.sql new file mode 100644 index 0000000000..5af0aa0092 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_series/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int_list_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + * + REPLACE (`bfcol_8` AS `int_list_col`) + FROM `bfcte_0` + CROSS JOIN UNNEST(`int_list_col`) AS `bfcol_8` WITH OFFSET AS `bfcol_4` +) +SELECT + `rowindex`, + `int_list_col` +FROM `bfcte_1` +ORDER BY + `bfcol_4` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_filter/test_compile_filter/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_filter/test_compile_filter/out.sql new file mode 100644 index 0000000000..f5fff16f60 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_filter/test_compile_filter/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_5`, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `rowindex` >= 1 AS `bfcol_8` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + * + FROM `bfcte_1` + WHERE + `bfcol_8` +) +SELECT + `bfcol_5` AS `rowindex`, + `bfcol_6` AS `rowindex_1`, + `bfcol_7` AS `int64_col` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats/out.sql new file mode 100644 index 0000000000..63076077cf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats/out.sql @@ -0,0 +1,36 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT('POINT(1 1)', 0)]) +), `bfcte_1` AS ( + SELECT + *, + ST_REGIONSTATS( + `bfcol_0`, + 'ee://some/raster/uri', + band => 'band1', + include => 'some equation', + options => JSON '{"scale": 100}' + ) AS `bfcol_2` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_2`.`min` AS `bfcol_5`, + `bfcol_2`.`max` AS `bfcol_6`, + `bfcol_2`.`sum` AS `bfcol_7`, + `bfcol_2`.`count` AS `bfcol_8`, + `bfcol_2`.`mean` AS `bfcol_9`, + `bfcol_2`.`area` AS `bfcol_10` + FROM `bfcte_1` +) +SELECT + `bfcol_5` AS `min`, + `bfcol_6` AS `max`, + `bfcol_7` AS `sum`, + `bfcol_8` AS `count`, + `bfcol_9` AS `mean`, + `bfcol_10` AS `area` +FROM `bfcte_2` +ORDER BY + `bfcol_1` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats_without_optional_args/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats_without_optional_args/out.sql new file mode 100644 index 0000000000..f794711961 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats_without_optional_args/out.sql @@ -0,0 +1,30 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT('POINT(1 1)', 0)]) +), `bfcte_1` AS ( + SELECT + *, + ST_REGIONSTATS(`bfcol_0`, 'ee://some/raster/uri') AS `bfcol_2` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_2`.`min` AS `bfcol_5`, + `bfcol_2`.`max` AS `bfcol_6`, + `bfcol_2`.`sum` AS `bfcol_7`, + `bfcol_2`.`count` AS `bfcol_8`, + `bfcol_2`.`mean` AS `bfcol_9`, + `bfcol_2`.`area` AS `bfcol_10` + FROM `bfcte_1` +) +SELECT + `bfcol_5` AS `min`, + `bfcol_6` AS `max`, + `bfcol_7` AS `sum`, + `bfcol_8` AS `count`, + `bfcol_9` AS `mean`, + `bfcol_10` AS `area` +FROM `bfcte_2` +ORDER BY + `bfcol_1` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_simplify/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_simplify/out.sql new file mode 100644 index 0000000000..b8dd1587a8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_simplify/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT('POINT(1 1)', 0)]) +), `bfcte_1` AS ( + SELECT + *, + ST_SIMPLIFY(`bfcol_0`, 123.125) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `0` +FROM `bfcte_1` +ORDER BY + `bfcol_1` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql new file mode 100644 index 0000000000..77aef6ad8b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql @@ -0,0 +1,41 @@ +WITH `bfcte_1` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `rowindex` AS `bfcol_2`, + `int64_col` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `int64_too` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `int64_too` + FROM `bfcte_0` + GROUP BY + `int64_too` +), `bfcte_4` AS ( + SELECT + `bfcte_3`.*, + EXISTS( + SELECT + 1 + FROM ( + SELECT + `int64_too` AS `bfcol_4` + FROM `bfcte_2` + ) AS `bft_0` + WHERE + COALESCE(`bfcte_3`.`bfcol_3`, 0) = COALESCE(`bft_0`.`bfcol_4`, 0) + AND COALESCE(`bfcte_3`.`bfcol_3`, 1) = COALESCE(`bft_0`.`bfcol_4`, 1) + ) AS `bfcol_5` + FROM `bfcte_3` +) +SELECT + `bfcol_2` AS `rowindex`, + `bfcol_5` AS `int64_col` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql new file mode 100644 index 0000000000..8089c5b462 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql @@ -0,0 +1,34 @@ +WITH `bfcte_1` AS ( + SELECT + `rowindex`, + `rowindex_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `rowindex` AS `bfcol_2`, + `rowindex_2` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `rowindex_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `rowindex_2` + FROM `bfcte_0` + GROUP BY + `rowindex_2` +), `bfcte_4` AS ( + SELECT + `bfcte_3`.*, + `bfcte_3`.`bfcol_3` IN (( + SELECT + `rowindex_2` AS `bfcol_4` + FROM `bfcte_2` + )) AS `bfcol_5` + FROM `bfcte_3` +) +SELECT + `bfcol_2` AS `rowindex`, + `bfcol_5` AS `rowindex_2` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join/out.sql new file mode 100644 index 0000000000..3a7ff60d3e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join/out.sql @@ -0,0 +1,32 @@ +WITH `bfcte_1` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `rowindex` AS `bfcol_2`, + `int64_col` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `int64_col`, + `int64_too` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `int64_col` AS `bfcol_6`, + `int64_too` AS `bfcol_7` + FROM `bfcte_0` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_2` + LEFT JOIN `bfcte_3` + ON COALESCE(`bfcol_2`, 0) = COALESCE(`bfcol_6`, 0) + AND COALESCE(`bfcol_2`, 1) = COALESCE(`bfcol_6`, 1) +) +SELECT + `bfcol_3` AS `int64_col`, + `bfcol_7` AS `int64_too` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/bool_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/bool_col/out.sql new file mode 100644 index 0000000000..30f363e900 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/bool_col/out.sql @@ -0,0 +1,33 @@ +WITH `bfcte_1` AS ( + SELECT + `bool_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `rowindex` AS `bfcol_2`, + `bool_col` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `bool_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7` + FROM `bfcte_0` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_2` + INNER JOIN `bfcte_3` + ON COALESCE(CAST(`bfcol_3` AS STRING), '0') = COALESCE(CAST(`bfcol_7` AS STRING), '0') + AND COALESCE(CAST(`bfcol_3` AS STRING), '1') = COALESCE(CAST(`bfcol_7` AS STRING), '1') +) +SELECT + `bfcol_2` AS `rowindex_x`, + `bfcol_3` AS `bool_col`, + `bfcol_6` AS `rowindex_y` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/float64_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/float64_col/out.sql new file mode 100644 index 0000000000..9fa7673fb3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/float64_col/out.sql @@ -0,0 +1,33 @@ +WITH `bfcte_1` AS ( + SELECT + `float64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `rowindex` AS `bfcol_2`, + `float64_col` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `float64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `rowindex` AS `bfcol_6`, + `float64_col` AS `bfcol_7` + FROM `bfcte_0` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_2` + INNER JOIN `bfcte_3` + ON IF(IS_NAN(`bfcol_3`), 2, COALESCE(`bfcol_3`, 0)) = IF(IS_NAN(`bfcol_7`), 2, COALESCE(`bfcol_7`, 0)) + AND IF(IS_NAN(`bfcol_3`), 3, COALESCE(`bfcol_3`, 1)) = IF(IS_NAN(`bfcol_7`), 3, COALESCE(`bfcol_7`, 1)) +) +SELECT + `bfcol_2` AS `rowindex_x`, + `bfcol_3` AS `float64_col`, + `bfcol_6` AS `rowindex_y` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/int64_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/int64_col/out.sql new file mode 100644 index 0000000000..c9fca069d6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/int64_col/out.sql @@ -0,0 +1,33 @@ +WITH `bfcte_1` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `rowindex` AS `bfcol_2`, + `int64_col` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7` + FROM `bfcte_0` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_2` + INNER JOIN `bfcte_3` + ON COALESCE(`bfcol_3`, 0) = COALESCE(`bfcol_7`, 0) + AND COALESCE(`bfcol_3`, 1) = COALESCE(`bfcol_7`, 1) +) +SELECT + `bfcol_2` AS `rowindex_x`, + `bfcol_3` AS `int64_col`, + `bfcol_6` AS `rowindex_y` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/numeric_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/numeric_col/out.sql new file mode 100644 index 0000000000..88649c6518 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/numeric_col/out.sql @@ -0,0 +1,33 @@ +WITH `bfcte_1` AS ( + SELECT + `numeric_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `rowindex` AS `bfcol_2`, + `numeric_col` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `numeric_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `rowindex` AS `bfcol_6`, + `numeric_col` AS `bfcol_7` + FROM `bfcte_0` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_2` + INNER JOIN `bfcte_3` + ON COALESCE(`bfcol_3`, CAST(0 AS NUMERIC)) = COALESCE(`bfcol_7`, CAST(0 AS NUMERIC)) + AND COALESCE(`bfcol_3`, CAST(1 AS NUMERIC)) = COALESCE(`bfcol_7`, CAST(1 AS NUMERIC)) +) +SELECT + `bfcol_2` AS `rowindex_x`, + `bfcol_3` AS `numeric_col`, + `bfcol_6` AS `rowindex_y` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/string_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/string_col/out.sql new file mode 100644 index 0000000000..8758ec8340 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/string_col/out.sql @@ -0,0 +1,33 @@ +WITH `bfcte_1` AS ( + SELECT + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `rowindex` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `rowindex` AS `bfcol_4`, + `string_col` AS `bfcol_5` + FROM `bfcte_0` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_2` + INNER JOIN `bfcte_3` + ON COALESCE(CAST(`bfcol_1` AS STRING), '0') = COALESCE(CAST(`bfcol_5` AS STRING), '0') + AND COALESCE(CAST(`bfcol_1` AS STRING), '1') = COALESCE(CAST(`bfcol_5` AS STRING), '1') +) +SELECT + `bfcol_0` AS `rowindex_x`, + `bfcol_1` AS `string_col`, + `bfcol_4` AS `rowindex_y` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/time_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/time_col/out.sql new file mode 100644 index 0000000000..42fc15cd1d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/time_col/out.sql @@ -0,0 +1,33 @@ +WITH `bfcte_1` AS ( + SELECT + `rowindex`, + `time_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `rowindex` AS `bfcol_0`, + `time_col` AS `bfcol_1` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `rowindex`, + `time_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `rowindex` AS `bfcol_4`, + `time_col` AS `bfcol_5` + FROM `bfcte_0` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_2` + INNER JOIN `bfcte_3` + ON COALESCE(CAST(`bfcol_1` AS STRING), '0') = COALESCE(CAST(`bfcol_5` AS STRING), '0') + AND COALESCE(CAST(`bfcol_1` AS STRING), '1') = COALESCE(CAST(`bfcol_5` AS STRING), '1') +) +SELECT + `bfcol_0` AS `rowindex_x`, + `bfcol_1` AS `time_col`, + `bfcol_4` AS `rowindex_y` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_projection/test_compile_projection/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_projection/test_compile_projection/out.sql new file mode 100644 index 0000000000..3f819800e5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_projection/test_compile_projection/out.sql @@ -0,0 +1,26 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + `bfcol_1` AS `bfcol_2`, + `bfcol_0` AS `bfcol_3` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_4`, + `bfcol_3` + 1 AS `bfcol_5` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + `bfcol_4` AS `bfcol_6`, + `bfcol_5` AS `bfcol_7` + FROM `bfcte_2` +) +SELECT + `bfcol_6` AS `rowindex`, + `bfcol_7` AS `int64_col` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_random_sample/test_compile_random_sample/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_random_sample/test_compile_random_sample/out.sql new file mode 100644 index 0000000000..aae34716d8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_random_sample/test_compile_random_sample/out.sql @@ -0,0 +1,184 @@ +WITH `bfcte_0` AS ( + SELECT + *, + RAND() AS `bfcol_16` + FROM UNNEST(ARRAY>[STRUCT( + TRUE, + CAST(b'Hello, World!' AS BYTES), + CAST('2021-07-21' AS DATE), + CAST('2021-07-21T11:39:45' AS DATETIME), + ST_GEOGFROMTEXT('POINT(-122.0838511 37.3860517)'), + 123456789, + 0, + CAST(1.234567890 AS NUMERIC), + 1.25, + 0, + 0, + 'Hello, World!', + CAST('11:41:43.076160' AS TIME), + CAST('2021-07-21T17:43:43.945289+00:00' AS TIMESTAMP), + 4, + 0 + ), STRUCT( + FALSE, + CAST(b'\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf' AS BYTES), + CAST('1991-02-03' AS DATE), + CAST('1991-01-02T03:45:06' AS DATETIME), + ST_GEOGFROMTEXT('POINT(-71.104 42.315)'), + -987654321, + 1, + CAST(1.234567890 AS NUMERIC), + 2.51, + 1, + 1, + 'こんにちは', + CAST('11:14:34.701606' AS TIME), + CAST('2021-07-21T17:43:43.945289+00:00' AS TIMESTAMP), + -1000000, + 1 + ), STRUCT( + TRUE, + CAST(b'\xc2\xa1Hola Mundo!' AS BYTES), + CAST('2023-03-01' AS DATE), + CAST('2023-03-01T10:55:13' AS DATETIME), + ST_GEOGFROMTEXT('POINT(-0.124474760143016 51.5007826749545)'), + 314159, + 0, + CAST(101.101010100 AS NUMERIC), + 25000000000.0, + 2, + 2, + ' ¡Hola Mundo! ', + CAST('23:59:59.999999' AS TIME), + CAST('2023-03-01T10:55:13.250125+00:00' AS TIMESTAMP), + 0, + 2 + ), STRUCT( + CAST(NULL AS BOOLEAN), + CAST(NULL AS BYTES), + CAST(NULL AS DATE), + CAST(NULL AS DATETIME), + CAST(NULL AS GEOGRAPHY), + CAST(NULL AS INT64), + 1, + CAST(NULL AS NUMERIC), + CAST(NULL AS FLOAT64), + 3, + 3, + CAST(NULL AS STRING), + CAST(NULL AS TIME), + CAST(NULL AS TIMESTAMP), + CAST(NULL AS INT64), + 3 + ), STRUCT( + FALSE, + CAST(b'\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf' AS BYTES), + CAST('2021-07-21' AS DATE), + CAST(NULL AS DATETIME), + CAST(NULL AS GEOGRAPHY), + -234892, + -2345, + CAST(NULL AS NUMERIC), + CAST(NULL AS FLOAT64), + 4, + 4, + 'Hello, World!', + CAST(NULL AS TIME), + CAST(NULL AS TIMESTAMP), + 31540000000000, + 4 + ), STRUCT( + FALSE, + CAST(b'G\xc3\xbcten Tag' AS BYTES), + CAST('1980-03-14' AS DATE), + CAST('1980-03-14T15:16:17' AS DATETIME), + CAST(NULL AS GEOGRAPHY), + 55555, + 0, + CAST(5.555555000 AS NUMERIC), + 555.555, + 5, + 5, + 'Güten Tag!', + CAST('15:16:17.181921' AS TIME), + CAST('1980-03-14T15:16:17.181921+00:00' AS TIMESTAMP), + 4, + 5 + ), STRUCT( + TRUE, + CAST(b'Hello\tBigFrames!\x07' AS BYTES), + CAST('2023-05-23' AS DATE), + CAST('2023-05-23T11:37:01' AS DATETIME), + ST_GEOGFROMTEXT('LINESTRING(-0.127959 51.507728, -0.127026 51.507473)'), + 101202303, + 2, + CAST(-10.090807000 AS NUMERIC), + -123.456, + 6, + 6, + 'capitalize, This ', + CAST('01:02:03.456789' AS TIME), + CAST('2023-05-23T11:42:55.000001+00:00' AS TIMESTAMP), + CAST(NULL AS INT64), + 6 + ), STRUCT( + TRUE, + CAST(NULL AS BYTES), + CAST('2038-01-20' AS DATE), + CAST('2038-01-19T03:14:08' AS DATETIME), + CAST(NULL AS GEOGRAPHY), + -214748367, + 2, + CAST(11111111.100000000 AS NUMERIC), + 42.42, + 7, + 7, + ' سلام', + CAST('12:00:00.000001' AS TIME), + CAST('2038-01-19T03:14:17.999999+00:00' AS TIMESTAMP), + 4, + 7 + ), STRUCT( + FALSE, + CAST(NULL AS BYTES), + CAST(NULL AS DATE), + CAST(NULL AS DATETIME), + CAST(NULL AS GEOGRAPHY), + 2, + 1, + CAST(NULL AS NUMERIC), + 6.87, + 8, + 8, + 'T', + CAST(NULL AS TIME), + CAST(NULL AS TIMESTAMP), + 432000000000, + 8 + )]) +), `bfcte_1` AS ( + SELECT + * + FROM `bfcte_0` + WHERE + `bfcol_16` < 0.1 +) +SELECT + `bfcol_0` AS `bool_col`, + `bfcol_1` AS `bytes_col`, + `bfcol_2` AS `date_col`, + `bfcol_3` AS `datetime_col`, + `bfcol_4` AS `geography_col`, + `bfcol_5` AS `int64_col`, + `bfcol_6` AS `int64_too`, + `bfcol_7` AS `numeric_col`, + `bfcol_8` AS `float64_col`, + `bfcol_9` AS `rowindex`, + `bfcol_10` AS `rowindex_2`, + `bfcol_11` AS `string_col`, + `bfcol_12` AS `time_col`, + `bfcol_13` AS `timestamp_col`, + `bfcol_14` AS `duration_col` +FROM `bfcte_1` +ORDER BY + `bfcol_15` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal/out.sql new file mode 100644 index 0000000000..2b080b0b7c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal/out.sql @@ -0,0 +1,187 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT( + 0, + TRUE, + CAST(b'Hello, World!' AS BYTES), + CAST('2021-07-21' AS DATE), + CAST('2021-07-21T11:39:45' AS DATETIME), + ST_GEOGFROMTEXT('POINT(-122.0838511 37.3860517)'), + 123456789, + 0, + CAST(1.234567890 AS NUMERIC), + 1.25, + 0, + 0, + 'Hello, World!', + CAST('11:41:43.076160' AS TIME), + CAST('2021-07-21T17:43:43.945289+00:00' AS TIMESTAMP), + 4, + 0 + ), STRUCT( + 1, + FALSE, + CAST(b'\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf' AS BYTES), + CAST('1991-02-03' AS DATE), + CAST('1991-01-02T03:45:06' AS DATETIME), + ST_GEOGFROMTEXT('POINT(-71.104 42.315)'), + -987654321, + 1, + CAST(1.234567890 AS NUMERIC), + 2.51, + 1, + 1, + 'こんにちは', + CAST('11:14:34.701606' AS TIME), + CAST('2021-07-21T17:43:43.945289+00:00' AS TIMESTAMP), + -1000000, + 1 + ), STRUCT( + 2, + TRUE, + CAST(b'\xc2\xa1Hola Mundo!' AS BYTES), + CAST('2023-03-01' AS DATE), + CAST('2023-03-01T10:55:13' AS DATETIME), + ST_GEOGFROMTEXT('POINT(-0.124474760143016 51.5007826749545)'), + 314159, + 0, + CAST(101.101010100 AS NUMERIC), + 25000000000.0, + 2, + 2, + ' ¡Hola Mundo! ', + CAST('23:59:59.999999' AS TIME), + CAST('2023-03-01T10:55:13.250125+00:00' AS TIMESTAMP), + 0, + 2 + ), STRUCT( + 3, + CAST(NULL AS BOOLEAN), + CAST(NULL AS BYTES), + CAST(NULL AS DATE), + CAST(NULL AS DATETIME), + CAST(NULL AS GEOGRAPHY), + CAST(NULL AS INT64), + 1, + CAST(NULL AS NUMERIC), + CAST(NULL AS FLOAT64), + 3, + 3, + CAST(NULL AS STRING), + CAST(NULL AS TIME), + CAST(NULL AS TIMESTAMP), + CAST(NULL AS INT64), + 3 + ), STRUCT( + 4, + FALSE, + CAST(b'\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf' AS BYTES), + CAST('2021-07-21' AS DATE), + CAST(NULL AS DATETIME), + CAST(NULL AS GEOGRAPHY), + -234892, + -2345, + CAST(NULL AS NUMERIC), + CAST(NULL AS FLOAT64), + 4, + 4, + 'Hello, World!', + CAST(NULL AS TIME), + CAST(NULL AS TIMESTAMP), + 31540000000000, + 4 + ), STRUCT( + 5, + FALSE, + CAST(b'G\xc3\xbcten Tag' AS BYTES), + CAST('1980-03-14' AS DATE), + CAST('1980-03-14T15:16:17' AS DATETIME), + CAST(NULL AS GEOGRAPHY), + 55555, + 0, + CAST(5.555555000 AS NUMERIC), + 555.555, + 5, + 5, + 'Güten Tag!', + CAST('15:16:17.181921' AS TIME), + CAST('1980-03-14T15:16:17.181921+00:00' AS TIMESTAMP), + 4, + 5 + ), STRUCT( + 6, + TRUE, + CAST(b'Hello\tBigFrames!\x07' AS BYTES), + CAST('2023-05-23' AS DATE), + CAST('2023-05-23T11:37:01' AS DATETIME), + ST_GEOGFROMTEXT('LINESTRING(-0.127959 51.507728, -0.127026 51.507473)'), + 101202303, + 2, + CAST(-10.090807000 AS NUMERIC), + -123.456, + 6, + 6, + 'capitalize, This ', + CAST('01:02:03.456789' AS TIME), + CAST('2023-05-23T11:42:55.000001+00:00' AS TIMESTAMP), + CAST(NULL AS INT64), + 6 + ), STRUCT( + 7, + TRUE, + CAST(NULL AS BYTES), + CAST('2038-01-20' AS DATE), + CAST('2038-01-19T03:14:08' AS DATETIME), + CAST(NULL AS GEOGRAPHY), + -214748367, + 2, + CAST(11111111.100000000 AS NUMERIC), + 42.42, + 7, + 7, + ' سلام', + CAST('12:00:00.000001' AS TIME), + CAST('2038-01-19T03:14:17.999999+00:00' AS TIMESTAMP), + 4, + 7 + ), STRUCT( + 8, + FALSE, + CAST(NULL AS BYTES), + CAST(NULL AS DATE), + CAST(NULL AS DATETIME), + CAST(NULL AS GEOGRAPHY), + 2, + 1, + CAST(NULL AS NUMERIC), + 6.87, + 8, + 8, + 'T', + CAST(NULL AS TIME), + CAST(NULL AS TIMESTAMP), + 432000000000, + 8 + )]) +) +SELECT + `bfcol_0` AS `rowindex`, + `bfcol_1` AS `bool_col`, + `bfcol_2` AS `bytes_col`, + `bfcol_3` AS `date_col`, + `bfcol_4` AS `datetime_col`, + `bfcol_5` AS `geography_col`, + `bfcol_6` AS `int64_col`, + `bfcol_7` AS `int64_too`, + `bfcol_8` AS `numeric_col`, + `bfcol_9` AS `float64_col`, + `bfcol_10` AS `rowindex_1`, + `bfcol_11` AS `rowindex_2`, + `bfcol_12` AS `string_col`, + `bfcol_13` AS `time_col`, + `bfcol_14` AS `timestamp_col`, + `bfcol_15` AS `duration_col` +FROM `bfcte_0` +ORDER BY + `bfcol_16` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_json_df/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_json_df/out.sql new file mode 100644 index 0000000000..4e21266b87 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_json_df/out.sql @@ -0,0 +1,11 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT(0, PARSE_JSON('null'), 0), STRUCT(1, PARSE_JSON('true'), 1), STRUCT(2, PARSE_JSON('100'), 2), STRUCT(3, PARSE_JSON('0.98'), 3), STRUCT(4, PARSE_JSON('"a string"'), 4), STRUCT(5, PARSE_JSON('[]'), 5), STRUCT(6, PARSE_JSON('[1,2,3]'), 6), STRUCT(7, PARSE_JSON('[{"a":1},{"a":2},{"a":null},{}]'), 7), STRUCT(8, PARSE_JSON('"100"'), 8), STRUCT(9, PARSE_JSON('{"date":"2024-07-16"}'), 9), STRUCT(10, PARSE_JSON('{"int_value":2,"null_filed":null}'), 10), STRUCT(11, PARSE_JSON('{"list_data":[10,20,30]}'), 11)]) +) +SELECT + `bfcol_0` AS `rowindex`, + `bfcol_1` AS `json_col` +FROM `bfcte_0` +ORDER BY + `bfcol_2` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_lists_df/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_lists_df/out.sql new file mode 100644 index 0000000000..923476aafd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_lists_df/out.sql @@ -0,0 +1,47 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY, `bfcol_2` ARRAY, `bfcol_3` ARRAY, `bfcol_4` ARRAY, `bfcol_5` ARRAY, `bfcol_6` ARRAY, `bfcol_7` ARRAY, `bfcol_8` INT64>>[STRUCT( + 0, + [1], + [TRUE], + [1.2, 2.3], + ['2021-07-21'], + ['2021-07-21 11:39:45'], + [1.2, 2.3, 3.4], + ['abc', 'de', 'f'], + 0 + ), STRUCT( + 1, + [1, 2], + [TRUE, FALSE], + [1.1], + ['2021-07-21', '1987-03-28'], + ['1999-03-14 17:22:00'], + [5.5, 2.3], + ['a', 'bc', 'de'], + 1 + ), STRUCT( + 2, + [1, 2, 3], + [TRUE], + [0.5, -1.9, 2.3], + ['2017-08-01', '2004-11-22'], + ['1979-06-03 03:20:45'], + [1.7000000000000002], + ['', 'a'], + 2 + )]) +) +SELECT + `bfcol_0` AS `rowindex`, + `bfcol_1` AS `int_list_col`, + `bfcol_2` AS `bool_list_col`, + `bfcol_3` AS `float_list_col`, + `bfcol_4` AS `date_list_col`, + `bfcol_5` AS `date_time_list_col`, + `bfcol_6` AS `numeric_list_col`, + `bfcol_7` AS `string_list_col` +FROM `bfcte_0` +ORDER BY + `bfcol_8` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_special_values/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_special_values/out.sql new file mode 100644 index 0000000000..ba5e0c8f1c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_special_values/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY, `bfcol_5` STRUCT, `bfcol_6` ARRAY, `bfcol_7` INT64>>[STRUCT( + CAST(NULL AS FLOAT64), + CAST('Infinity' AS FLOAT64), + CAST('-Infinity' AS FLOAT64), + CAST(NULL AS FLOAT64), + CAST(NULL AS STRUCT), + STRUCT(CAST(NULL AS INT64) AS `foo`), + ARRAY[], + 0 + ), STRUCT(1.0, 1.0, 1.0, 1.0, STRUCT(1 AS `foo`), STRUCT(1 AS `foo`), [1, 2], 1), STRUCT(2.0, 2.0, 2.0, 2.0, STRUCT(2 AS `foo`), STRUCT(2 AS `foo`), [3, 4], 2)]) +) +SELECT + `bfcol_0` AS `col_none`, + `bfcol_1` AS `col_inf`, + `bfcol_2` AS `col_neginf`, + `bfcol_3` AS `col_nan`, + `bfcol_4` AS `col_struct_none`, + `bfcol_5` AS `col_struct_w_none`, + `bfcol_6` AS `col_list_none` +FROM `bfcte_0` +ORDER BY + `bfcol_7` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_structs_df/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_structs_df/out.sql new file mode 100644 index 0000000000..7ded9cf5ff --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_structs_df/out.sql @@ -0,0 +1,27 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>, `bfcol_2` INT64>>[STRUCT( + 1, + STRUCT( + 'Alice' AS `name`, + 30 AS `age`, + STRUCT('New York' AS `city`, 'USA' AS `country`) AS `address` + ), + 0 + ), STRUCT( + 2, + STRUCT( + 'Bob' AS `name`, + 25 AS `age`, + STRUCT('London' AS `city`, 'UK' AS `country`) AS `address` + ), + 1 + )]) +) +SELECT + `bfcol_0` AS `id`, + `bfcol_1` AS `person` +FROM `bfcte_0` +ORDER BY + `bfcol_2` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable/out.sql new file mode 100644 index 0000000000..959a31a2a3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable/out.sql @@ -0,0 +1,37 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +) +SELECT + `rowindex`, + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `float64_col`, + `rowindex` AS `rowindex_1`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col`, + `duration_col` +FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_columns_filters/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_columns_filters/out.sql new file mode 100644 index 0000000000..0d8a10c956 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_columns_filters/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` + WHERE + `rowindex` > 0 AND `string_col` IN ('Hello, World!') +) +SELECT + `rowindex`, + `int64_col`, + `string_col` +FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_json_types/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_json_types/out.sql new file mode 100644 index 0000000000..4b5750d7aa --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_json_types/out.sql @@ -0,0 +1,10 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +) +SELECT + `rowindex`, + `json_col` +FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_limit/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_limit/out.sql new file mode 100644 index 0000000000..856c7061da --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_limit/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +) +SELECT + `rowindex`, + `int64_col` +FROM `bfcte_0` +ORDER BY + `rowindex` ASC NULLS LAST +LIMIT 10 \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_nested_structs_types/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_nested_structs_types/out.sql new file mode 100644 index 0000000000..79ae1ac907 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_nested_structs_types/out.sql @@ -0,0 +1,11 @@ +WITH `bfcte_0` AS ( + SELECT + `id`, + `people` + FROM `bigframes-dev`.`sqlglot_test`.`nested_structs_types` +) +SELECT + `id`, + `id` AS `id_1`, + `people` +FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_ordering/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_ordering/out.sql new file mode 100644 index 0000000000..edb8d7fbf4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_ordering/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +) +SELECT + `rowindex`, + `int64_col` +FROM `bfcte_0` +ORDER BY + `int64_col` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_repeated_types/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_repeated_types/out.sql new file mode 100644 index 0000000000..a22c845ef1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_repeated_types/out.sql @@ -0,0 +1,23 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_list_col`, + `date_list_col`, + `date_time_list_col`, + `float_list_col`, + `int_list_col`, + `numeric_list_col`, + `rowindex`, + `string_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +) +SELECT + `rowindex`, + `rowindex` AS `rowindex_1`, + `int_list_col`, + `bool_list_col`, + `float_list_col`, + `date_list_col`, + `date_time_list_col`, + `numeric_list_col`, + `string_list_col` +FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_system_time/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_system_time/out.sql new file mode 100644 index 0000000000..59c3687080 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_system_time/out.sql @@ -0,0 +1,36 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` FOR SYSTEM_TIME AS OF '2025-11-09T03:04:05.678901+00:00' +) +SELECT + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `float64_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col`, + `duration_col` +FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql new file mode 100644 index 0000000000..e8fabd1129 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql @@ -0,0 +1,70 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + `bool_col` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + * + FROM `bfcte_1` + WHERE + NOT `bfcol_9` IS NULL +), `bfcte_3` AS ( + SELECT + *, + CASE + WHEN SUM(CAST(NOT `bfcol_7` IS NULL AS INT64)) OVER ( + PARTITION BY `bfcol_9` + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST + ROWS BETWEEN 3 PRECEDING AND CURRENT ROW + ) < 3 + THEN NULL + ELSE COALESCE( + SUM(CAST(`bfcol_7` AS INT64)) OVER ( + PARTITION BY `bfcol_9` + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST + ROWS BETWEEN 3 PRECEDING AND CURRENT ROW + ), + 0 + ) + END AS `bfcol_15` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + CASE + WHEN SUM(CAST(NOT `bfcol_8` IS NULL AS INT64)) OVER ( + PARTITION BY `bfcol_9` + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST + ROWS BETWEEN 3 PRECEDING AND CURRENT ROW + ) < 3 + THEN NULL + ELSE COALESCE( + SUM(`bfcol_8`) OVER ( + PARTITION BY `bfcol_9` + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST + ROWS BETWEEN 3 PRECEDING AND CURRENT ROW + ), + 0 + ) + END AS `bfcol_16` + FROM `bfcte_3` +) +SELECT + `bfcol_9` AS `bool_col`, + `bfcol_6` AS `rowindex`, + `bfcol_15` AS `bool_col_1`, + `bfcol_16` AS `int64_col` +FROM `bfcte_4` +ORDER BY + `bfcol_9` ASC NULLS LAST, + `rowindex` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_range_rolling/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_range_rolling/out.sql new file mode 100644 index 0000000000..581c81c6b4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_range_rolling/out.sql @@ -0,0 +1,30 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT(CAST('2025-01-01T00:00:00+00:00' AS TIMESTAMP), 0, 0), STRUCT(CAST('2025-01-01T00:00:01+00:00' AS TIMESTAMP), 1, 1), STRUCT(CAST('2025-01-01T00:00:02+00:00' AS TIMESTAMP), 2, 2), STRUCT(CAST('2025-01-01T00:00:03+00:00' AS TIMESTAMP), 3, 3), STRUCT(CAST('2025-01-01T00:00:04+00:00' AS TIMESTAMP), 0, 4), STRUCT(CAST('2025-01-01T00:00:05+00:00' AS TIMESTAMP), 1, 5), STRUCT(CAST('2025-01-01T00:00:06+00:00' AS TIMESTAMP), 2, 6), STRUCT(CAST('2025-01-01T00:00:07+00:00' AS TIMESTAMP), 3, 7), STRUCT(CAST('2025-01-01T00:00:08+00:00' AS TIMESTAMP), 0, 8), STRUCT(CAST('2025-01-01T00:00:09+00:00' AS TIMESTAMP), 1, 9), STRUCT(CAST('2025-01-01T00:00:10+00:00' AS TIMESTAMP), 2, 10), STRUCT(CAST('2025-01-01T00:00:11+00:00' AS TIMESTAMP), 3, 11), STRUCT(CAST('2025-01-01T00:00:12+00:00' AS TIMESTAMP), 0, 12), STRUCT(CAST('2025-01-01T00:00:13+00:00' AS TIMESTAMP), 1, 13), STRUCT(CAST('2025-01-01T00:00:14+00:00' AS TIMESTAMP), 2, 14), STRUCT(CAST('2025-01-01T00:00:15+00:00' AS TIMESTAMP), 3, 15), STRUCT(CAST('2025-01-01T00:00:16+00:00' AS TIMESTAMP), 0, 16), STRUCT(CAST('2025-01-01T00:00:17+00:00' AS TIMESTAMP), 1, 17), STRUCT(CAST('2025-01-01T00:00:18+00:00' AS TIMESTAMP), 2, 18), STRUCT(CAST('2025-01-01T00:00:19+00:00' AS TIMESTAMP), 3, 19)]) +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN SUM(CAST(NOT `bfcol_1` IS NULL AS INT64)) OVER ( + ORDER BY UNIX_MICROS(`bfcol_0`) ASC NULLS LAST + RANGE BETWEEN 2999999 PRECEDING AND CURRENT ROW + ) < 1 + THEN NULL + ELSE COALESCE( + SUM(`bfcol_1`) OVER ( + ORDER BY UNIX_MICROS(`bfcol_0`) ASC NULLS LAST + RANGE BETWEEN 2999999 PRECEDING AND CURRENT ROW + ), + 0 + ) + END AS `bfcol_6` + FROM `bfcte_0` +) +SELECT + `bfcol_0` AS `ts_col`, + `bfcol_6` AS `int_col` +FROM `bfcte_1` +ORDER BY + `bfcol_0` ASC NULLS LAST, + `bfcol_2` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql new file mode 100644 index 0000000000..788eb49ddf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql @@ -0,0 +1,24 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN SUM(CAST(NOT `int64_col` IS NULL AS INT64)) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) < 3 + THEN NULL + ELSE COALESCE( + SUM(`int64_col`) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), + 0 + ) + END AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `rowindex`, + `bfcol_4` AS `int64_col` +FROM `bfcte_1` +ORDER BY + `rowindex` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql new file mode 100644 index 0000000000..5ad435ddbb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN COUNT(CAST(NOT `int64_col` IS NULL AS INT64)) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) < 5 + THEN NULL + ELSE COUNT(`int64_col`) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) + END AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `rowindex`, + `bfcol_4` AS `int64_col` +FROM `bfcte_1` +ORDER BY + `rowindex` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/test_compile_aggregate.py b/tests/unit/core/compile/sqlglot/test_compile_aggregate.py new file mode 100644 index 0000000000..d59c5e5068 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_aggregate.py @@ -0,0 +1,33 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_compile_aggregate(scalar_types_df: bpd.DataFrame, snapshot): + result = scalar_types_df["int64_too"].groupby(scalar_types_df["bool_col"]).sum() + snapshot.assert_match(result.to_frame().sql, "out.sql") + + +def test_compile_aggregate_wo_dropna(scalar_types_df: bpd.DataFrame, snapshot): + result = ( + scalar_types_df["int64_too"] + .groupby(scalar_types_df["bool_col"], dropna=False) + .sum() + ) + snapshot.assert_match(result.to_frame().sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_concat.py b/tests/unit/core/compile/sqlglot/test_compile_concat.py new file mode 100644 index 0000000000..c176b2e116 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_concat.py @@ -0,0 +1,49 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import ordering +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_compile_concat(scalar_types_df: bpd.DataFrame, snapshot): + # TODO: concat two same dataframes, which SQL does not get reused. + df1 = scalar_types_df[["rowindex", "int64_col", "string_col"]] + concat_df = bpd.concat([df1, df1]) + snapshot.assert_match(concat_df.sql, "out.sql") + + +def test_compile_concat_filter_sorted(scalar_types_df: bpd.DataFrame, snapshot): + + scalars_array_value = scalar_types_df._block.expr + input_1 = scalars_array_value.select_columns(["float64_col", "int64_col"]).order_by( + [ordering.ascending_over("int64_col")] + ) + input_2 = scalars_array_value.filter_by_id("bool_col").select_columns( + ["float64_col", "int64_too"] + ) + + result = input_1.concat([input_2, input_1, input_2]) + + new_names = ["float64_col", "int64_col"] + col_ids = { + old_name: new_name for old_name, new_name in zip(result.column_ids, new_names) + } + result = result.rename_columns(col_ids).select_columns(new_names) + + sql = result.session._executor.to_sql(result, enable_cache=False) + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_explode.py b/tests/unit/core/compile/sqlglot/test_compile_explode.py new file mode 100644 index 0000000000..34adbbd23a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_explode.py @@ -0,0 +1,31 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +# TODO: check order by with offset +def test_compile_explode_series(repeated_types_df: bpd.DataFrame, snapshot): + s = repeated_types_df["int_list_col"].explode() + snapshot.assert_match(s.to_frame().sql, "out.sql") + + +def test_compile_explode_dataframe(repeated_types_df: bpd.DataFrame, snapshot): + exploded_columns = ["int_list_col", "string_list_col"] + df = repeated_types_df[["rowindex", *exploded_columns]].explode(exploded_columns) + snapshot.assert_match(df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_filter.py b/tests/unit/core/compile/sqlglot/test_compile_filter.py new file mode 100644 index 0000000000..0afb5eb45b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_filter.py @@ -0,0 +1,25 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_compile_filter(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["rowindex", "int64_col"]] + bf_filter = bf_df[bf_df["rowindex"] >= 1] + snapshot.assert_match(bf_filter.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_geo.py b/tests/unit/core/compile/sqlglot/test_compile_geo.py new file mode 100644 index 0000000000..50de1488e6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_geo.py @@ -0,0 +1,52 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.bigquery as bbq +import bigframes.geopandas as gpd + +pytest.importorskip("pytest_snapshot") + + +def test_st_regionstats(compiler_session, snapshot): + geos = gpd.GeoSeries(["POINT(1 1)"], session=compiler_session) + result = bbq.st_regionstats( + geos, + "ee://some/raster/uri", + band="band1", + include="some equation", + options={"scale": 100}, + ) + assert "area" in result.struct.dtypes.index + snapshot.assert_match(result.struct.explode().sql, "out.sql") + + +def test_st_regionstats_without_optional_args(compiler_session, snapshot): + geos = gpd.GeoSeries(["POINT(1 1)"], session=compiler_session) + result = bbq.st_regionstats( + geos, + "ee://some/raster/uri", + ) + assert "area" in result.struct.dtypes.index + snapshot.assert_match(result.struct.explode().sql, "out.sql") + + +def test_st_simplify(compiler_session, snapshot): + geos = gpd.GeoSeries(["POINT(1 1)"], session=compiler_session) + result = bbq.st_simplify( + geos, + tolerance_meters=123.125, + ) + snapshot.assert_match(result.to_frame().sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_isin.py b/tests/unit/core/compile/sqlglot/test_compile_isin.py new file mode 100644 index 0000000000..94a533abe6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_isin.py @@ -0,0 +1,39 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + +if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + allow_module_level=True, + ) + + +def test_compile_isin(scalar_types_df: bpd.DataFrame, snapshot): + bf_isin = scalar_types_df["int64_col"].isin(scalar_types_df["int64_too"]).to_frame() + snapshot.assert_match(bf_isin.sql, "out.sql") + + +def test_compile_isin_not_nullable(scalar_types_df: bpd.DataFrame, snapshot): + bf_isin = ( + scalar_types_df["rowindex_2"].isin(scalar_types_df["rowindex_2"]).to_frame() + ) + snapshot.assert_match(bf_isin.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_join.py b/tests/unit/core/compile/sqlglot/test_compile_join.py new file mode 100644 index 0000000000..ac016eec02 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_join.py @@ -0,0 +1,61 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_compile_join(scalar_types_df: bpd.DataFrame, snapshot): + left = scalar_types_df[["int64_col"]] + right = scalar_types_df.set_index("int64_col")[["int64_too"]] + join = left.join(right) + snapshot.assert_match(join.sql, "out.sql") + + +def test_compile_join_w_how(scalar_types_df: bpd.DataFrame): + left = scalar_types_df[["int64_col"]] + right = scalar_types_df.set_index("int64_col")[["int64_too"]] + + join_sql = left.join(right, how="left").sql + assert "LEFT JOIN" in join_sql + assert "ON" in join_sql + + join_sql = left.join(right, how="right").sql + assert "RIGHT JOIN" in join_sql + assert "ON" in join_sql + + join_sql = left.join(right, how="outer").sql + assert "FULL OUTER JOIN" in join_sql + assert "ON" in join_sql + + join_sql = left.join(right, how="inner").sql + assert "INNER JOIN" in join_sql + assert "ON" in join_sql + + join_sql = left.merge(right, how="cross").sql + assert "CROSS JOIN" in join_sql + assert "ON" not in join_sql + + +@pytest.mark.parametrize( + ("on"), + ["bool_col", "int64_col", "float64_col", "string_col", "time_col", "numeric_col"], +) +def test_compile_join_w_on(scalar_types_df: bpd.DataFrame, on: str, snapshot): + df = scalar_types_df[["rowindex", on]] + merge = df.merge(df, left_on=on, right_on=on) + snapshot.assert_match(merge.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_random_sample.py b/tests/unit/core/compile/sqlglot/test_compile_random_sample.py new file mode 100644 index 0000000000..486d994f87 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_random_sample.py @@ -0,0 +1,35 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import nodes +import bigframes.core as core +import bigframes.core.compile as compile + +pytest.importorskip("pytest_snapshot") + + +def test_compile_random_sample( + scalar_types_array_value: core.ArrayValue, + snapshot, +): + """This test verifies the SQL compilation of a RandomSampleNode. + + Because BigFrames doesn't expose a public API for creating a random sample + operation, this test constructs the node directly and then compiles it to SQL. + """ + node = nodes.RandomSampleNode(scalar_types_array_value.node, fraction=0.1) + sql = compile.sqlglot.compile_sql(compile.CompileRequest(node, sort_rows=True)).sql + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_readlocal.py b/tests/unit/core/compile/sqlglot/test_compile_readlocal.py new file mode 100644 index 0000000000..c5fabd99e6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_readlocal.py @@ -0,0 +1,83 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +import numpy as np +import pandas as pd +import pytest + +import bigframes +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_compile_readlocal( + scalar_types_pandas_df: pd.DataFrame, compiler_session: bigframes.Session, snapshot +): + bf_df = bpd.DataFrame(scalar_types_pandas_df, session=compiler_session) + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readlocal_w_structs_df( + nested_structs_pandas_df: pd.DataFrame, + compiler_session_w_nested_structs_types: bigframes.Session, + snapshot, +): + # TODO(b/427306734): Check why the output is different from the expected output. + bf_df = bpd.DataFrame( + nested_structs_pandas_df, session=compiler_session_w_nested_structs_types + ) + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readlocal_w_lists_df( + repeated_types_pandas_df: pd.DataFrame, + compiler_session_w_repeated_types: bigframes.Session, + snapshot, +): + bf_df = bpd.DataFrame( + repeated_types_pandas_df, session=compiler_session_w_repeated_types + ) + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readlocal_w_json_df( + json_pandas_df: pd.DataFrame, + compiler_session_w_json_types: bigframes.Session, + snapshot, +): + bf_df = bpd.DataFrame(json_pandas_df, session=compiler_session_w_json_types) + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readlocal_w_special_values( + compiler_session: bigframes.Session, snapshot +): + if sys.version_info < (3, 12): + pytest.skip("Skipping test due to inconsistent SQL formatting") + df = pd.DataFrame( + { + "col_none": [None, 1, 2], + "col_inf": [np.inf, 1.0, 2.0], + "col_neginf": [-np.inf, 1.0, 2.0], + "col_nan": [np.nan, 1.0, 2.0], + "col_struct_none": [None, {"foo": 1}, {"foo": 2}], + "col_struct_w_none": [{"foo": None}, {"foo": 1}, {"foo": 2}], + "col_list_none": [None, [1, 2], [3, 4]], + } + ) + bf_df = bpd.DataFrame(df, session=compiler_session) + snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_readtable.py b/tests/unit/core/compile/sqlglot/test_compile_readtable.py new file mode 100644 index 0000000000..dd776d9a8f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_readtable.py @@ -0,0 +1,81 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +import google.cloud.bigquery as bigquery +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_compile_readtable(scalar_types_df: bpd.DataFrame, snapshot): + snapshot.assert_match(scalar_types_df.sql, "out.sql") + + +def test_compile_readtable_w_repeated_types(repeated_types_df: bpd.DataFrame, snapshot): + snapshot.assert_match(repeated_types_df.sql, "out.sql") + + +def test_compile_readtable_w_nested_structs_types( + nested_structs_types_df: bpd.DataFrame, snapshot +): + snapshot.assert_match(nested_structs_types_df.sql, "out.sql") + + +def test_compile_readtable_w_json_types(json_types_df: bpd.DataFrame, snapshot): + snapshot.assert_match(json_types_df.sql, "out.sql") + + +def test_compile_readtable_w_ordering(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]] + bf_df = bf_df.sort_values("int64_col") + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readtable_w_limit(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]] + bf_df = bf_df.sort_index().head(10) + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readtable_w_system_time( + compiler_session, scalar_types_table_schema, snapshot +): + table_ref = bigquery.TableReference( + bigquery.DatasetReference("bigframes-dev", "sqlglot_test"), + "scalar_types", + ) + table = bigquery.Table(table_ref, tuple(scalar_types_table_schema)) + table._properties["location"] = compiler_session._location + compiler_session._loader._df_snapshot[str(table_ref)] = ( + datetime.datetime(2025, 11, 9, 3, 4, 5, 678901, tzinfo=datetime.timezone.utc), + table, + ) + bf_df = compiler_session.read_gbq_table(str(table_ref)) + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readtable_w_columns_filters(compiler_session, snapshot): + columns = ["rowindex", "int64_col", "string_col"] + filters = [("rowindex", ">", 0), ("string_col", "in", ["Hello, World!"])] + bf_df = compiler_session._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.scalar_types", + enable_snapshot=False, + columns=columns, + filters=filters, + ) + snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_window.py b/tests/unit/core/compile/sqlglot/test_compile_window.py new file mode 100644 index 0000000000..1fc70dc30f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_window.py @@ -0,0 +1,70 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +import numpy as np +import pandas as pd +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + allow_module_level=True, + ) + + +def test_compile_window_w_skips_nulls_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]].sort_index() + # The SumOp's skips_nulls is True + result = bf_df.rolling(window=3).sum() + snapshot.assert_match(result.sql, "out.sql") + + +def test_compile_window_wo_skips_nulls_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]].sort_index() + # The CountOp's skips_nulls is False + result = bf_df.rolling(window=5).count() + snapshot.assert_match(result.sql, "out.sql") + + +def test_compile_window_w_groupby_rolling(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "int64_col"]].sort_index() + result = ( + bf_df.groupby(scalar_types_df["bool_col"]) + .rolling(window=3, closed="both") + .sum() + ) + snapshot.assert_match(result.sql, "out.sql") + + +def test_compile_window_w_range_rolling(compiler_session, snapshot): + # TODO: use `duration_col` instead. + values = np.arange(20) + pd_df = pd.DataFrame( + { + "ts_col": pd.Timestamp("20250101", tz="UTC") + pd.to_timedelta(values, "s"), + "int_col": values % 4, + "float_col": values / 2, + } + ) + bf_df = compiler_session.read_pandas(pd_df) + bf_series = bf_df.set_index("ts_col")["int_col"].sort_index() + result = bf_series.rolling(window="3s").sum() + snapshot.assert_match(result.to_frame().sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_scalar_compiler.py b/tests/unit/core/compile/sqlglot/test_scalar_compiler.py new file mode 100644 index 0000000000..14d7b47389 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_scalar_compiler.py @@ -0,0 +1,226 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest.mock as mock + +import pytest +import sqlglot.expressions as sge + +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +import bigframes.operations as ops + + +def test_register_unary_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockUnaryOp(ops.UnaryOp): + name = "mock_unary_op" + + mock_op = MockUnaryOp() + mock_impl = mock.Mock() + + @compiler.register_unary_op(mock_op) + def _(expr: TypedExpr) -> sge.Expression: + mock_impl(expr) + return sge.Identifier(this="output") + + arg = TypedExpr(sge.Identifier(this="input"), "string") + result = compiler.compile_row_op(mock_op, [arg]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg) + + +def test_register_unary_op_pass_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockUnaryOp(ops.UnaryOp): + name = "mock_unary_op_pass_op" + + mock_op = MockUnaryOp() + mock_impl = mock.Mock() + + @compiler.register_unary_op(mock_op, pass_op=True) + def _(expr: TypedExpr, op: ops.UnaryOp) -> sge.Expression: + mock_impl(expr, op) + return sge.Identifier(this="output") + + arg = TypedExpr(sge.Identifier(this="input"), "string") + result = compiler.compile_row_op(mock_op, [arg]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg, mock_op) + + +def test_register_binary_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockBinaryOp(ops.BinaryOp): + name = "mock_binary_op" + + mock_op = MockBinaryOp() + mock_impl = mock.Mock() + + @compiler.register_binary_op(mock_op) + def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + mock_impl(left, right) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2) + + +def test_register_binary_op_pass_on(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockBinaryOp(ops.BinaryOp): + name = "mock_binary_op_pass_op" + + mock_op = MockBinaryOp() + mock_impl = mock.Mock() + + @compiler.register_binary_op(mock_op, pass_op=True) + def _(left: TypedExpr, right: TypedExpr, op: ops.BinaryOp) -> sge.Expression: + mock_impl(left, right, op) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2, mock_op) + + +def test_register_ternary_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockTernaryOp(ops.TernaryOp): + name = "mock_ternary_op" + + mock_op = MockTernaryOp() + mock_impl = mock.Mock() + + @compiler.register_ternary_op(mock_op) + def _(arg1: TypedExpr, arg2: TypedExpr, arg3: TypedExpr) -> sge.Expression: + mock_impl(arg1, arg2, arg3) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + arg3 = TypedExpr(sge.Identifier(this="input3"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2, arg3]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2, arg3) + + +def test_register_nary_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockNaryOp(ops.NaryOp): + name = "mock_nary_op" + + mock_op = MockNaryOp() + mock_impl = mock.Mock() + + @compiler.register_nary_op(mock_op) + def _(*args: TypedExpr) -> sge.Expression: + mock_impl(*args) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2) + + +def test_register_nary_op_pass_on(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockNaryOp(ops.NaryOp): + name = "mock_nary_op_pass_op" + + mock_op = MockNaryOp() + mock_impl = mock.Mock() + + @compiler.register_nary_op(mock_op, pass_op=True) + def _(*args: TypedExpr, op: ops.NaryOp) -> sge.Expression: + mock_impl(*args, op=op) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + arg3 = TypedExpr(sge.Identifier(this="input3"), "string") + arg4 = TypedExpr(sge.Identifier(this="input4"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2, arg3, arg4]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2, arg3, arg4, op=mock_op) + + +def test_binary_op_parentheses(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockAddOp(ops.BinaryOp): + name = "mock_add_op" + + class MockMulOp(ops.BinaryOp): + name = "mock_mul_op" + + add_op = MockAddOp() + mul_op = MockMulOp() + + @compiler.register_binary_op(add_op) + def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Add(this=left.expr, expression=right.expr) + + @compiler.register_binary_op(mul_op) + def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Mul(this=left.expr, expression=right.expr) + + a = TypedExpr(sge.Identifier(this="a"), "int") + b = TypedExpr(sge.Identifier(this="b"), "int") + c = TypedExpr(sge.Identifier(this="c"), "int") + + # (a + b) * c + add_expr = compiler.compile_row_op(add_op, [a, b]) + add_typed_expr = TypedExpr(add_expr, "int") + result1 = compiler.compile_row_op(mul_op, [add_typed_expr, c]) + assert result1.sql() == "(a + b) * c" + + # a * (b + c) + add_expr_2 = compiler.compile_row_op(add_op, [b, c]) + add_typed_expr_2 = TypedExpr(add_expr_2, "int") + result2 = compiler.compile_row_op(mul_op, [a, add_typed_expr_2]) + assert result2.sql() == "a * (b + c)" + + +def test_register_duplicate_op_raises(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockUnaryOp(ops.UnaryOp): + name = "mock_unary_op_duplicate" + + mock_op = MockUnaryOp() + + @compiler.register_unary_op(mock_op) + def _(expr: TypedExpr) -> sge.Expression: + return sge.Identifier(this="output") + + with pytest.raises(ValueError): + + @compiler.register_unary_op(mock_op) + def _(expr: TypedExpr) -> sge.Expression: + return sge.Identifier(this="output2") diff --git a/tests/unit/core/compile/sqlglot/test_sqlglot_types.py b/tests/unit/core/compile/sqlglot/test_sqlglot_types.py new file mode 100644 index 0000000000..5c2d84383d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_sqlglot_types.py @@ -0,0 +1,64 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pyarrow as pa + +import bigframes.core.compile.sqlglot.sqlglot_types as sgt +import bigframes.dtypes as dtypes + + +def test_from_bigframes_simple_dtypes(): + assert sgt.from_bigframes_dtype(dtypes.INT_DTYPE) == "INT64" + assert sgt.from_bigframes_dtype(dtypes.FLOAT_DTYPE) == "FLOAT64" + assert sgt.from_bigframes_dtype(dtypes.STRING_DTYPE) == "STRING" + assert sgt.from_bigframes_dtype(dtypes.BOOL_DTYPE) == "BOOLEAN" + assert sgt.from_bigframes_dtype(dtypes.DATE_DTYPE) == "DATE" + assert sgt.from_bigframes_dtype(dtypes.TIME_DTYPE) == "TIME" + assert sgt.from_bigframes_dtype(dtypes.DATETIME_DTYPE) == "DATETIME" + assert sgt.from_bigframes_dtype(dtypes.TIMESTAMP_DTYPE) == "TIMESTAMP" + assert sgt.from_bigframes_dtype(dtypes.BYTES_DTYPE) == "BYTES" + assert sgt.from_bigframes_dtype(dtypes.NUMERIC_DTYPE) == "NUMERIC" + assert sgt.from_bigframes_dtype(dtypes.BIGNUMERIC_DTYPE) == "BIGNUMERIC" + assert sgt.from_bigframes_dtype(dtypes.JSON_DTYPE) == "JSON" + assert sgt.from_bigframes_dtype(dtypes.GEO_DTYPE) == "GEOGRAPHY" + + +def test_from_bigframes_struct_dtypes(): + fields = [pa.field("int_col", pa.int64()), pa.field("bool_col", pa.bool_())] + struct_type = pd.ArrowDtype(pa.struct(fields)) + expected = "STRUCT" + assert sgt.from_bigframes_dtype(struct_type) == expected + + +def test_from_bigframes_array_dtypes(): + int_array_type = pd.ArrowDtype(pa.list_(pa.int64())) + assert sgt.from_bigframes_dtype(int_array_type) == "ARRAY" + + string_array_type = pd.ArrowDtype(pa.list_(pa.string())) + assert sgt.from_bigframes_dtype(string_array_type) == "ARRAY" + + +def test_from_bigframes_multi_nested_dtypes(): + fields = [ + pa.field("string_col", pa.string()), + pa.field("date_col", pa.date32()), + pa.field("array_col", pa.list_(pa.timestamp("us"))), + ] + array_type = pd.ArrowDtype(pa.list_(pa.struct(fields))) + + expected = ( + "ARRAY>>" + ) + assert sgt.from_bigframes_dtype(array_type) == expected diff --git a/tests/unit/core/rewrite/conftest.py b/tests/unit/core/rewrite/conftest.py new file mode 100644 index 0000000000..8c7ee290ae --- /dev/null +++ b/tests/unit/core/rewrite/conftest.py @@ -0,0 +1,83 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import unittest.mock as mock + +import google.cloud.bigquery +import pytest + +import bigframes.core as core +import bigframes.core.schema + +TABLE_REF = google.cloud.bigquery.TableReference.from_string("project.dataset.table") +SCHEMA = ( + google.cloud.bigquery.SchemaField("col_a", "INTEGER"), + google.cloud.bigquery.SchemaField("col_b", "INTEGER"), +) +TABLE = google.cloud.bigquery.Table( + table_ref=TABLE_REF, + schema=SCHEMA, +) +FAKE_SESSION = mock.create_autospec(bigframes.Session, instance=True) +type(FAKE_SESSION)._strictly_ordered = mock.PropertyMock(return_value=True) + + +@pytest.fixture +def table(): + table_ref = google.cloud.bigquery.TableReference.from_string( + "project.dataset.table" + ) + schema = ( + google.cloud.bigquery.SchemaField("col_a", "INTEGER"), + google.cloud.bigquery.SchemaField("col_b", "INTEGER"), + ) + return google.cloud.bigquery.Table( + table_ref=table_ref, + schema=schema, + ) + + +@pytest.fixture +def table_too(): + table_ref = google.cloud.bigquery.TableReference.from_string( + "project.dataset.table_too" + ) + schema = ( + google.cloud.bigquery.SchemaField("col_a", "INTEGER"), + google.cloud.bigquery.SchemaField("col_c", "INTEGER"), + ) + return google.cloud.bigquery.Table( + table_ref=table_ref, + schema=schema, + ) + + +@pytest.fixture +def fake_session(): + return FAKE_SESSION + + +@pytest.fixture +def leaf(fake_session, table): + return core.ArrayValue.from_table( + session=fake_session, + table=table, + ).node + + +@pytest.fixture +def leaf_too(fake_session, table_too): + return core.ArrayValue.from_table( + session=fake_session, + table=table_too, + ).node diff --git a/tests/unit/core/rewrite/test_identifiers.py b/tests/unit/core/rewrite/test_identifiers.py new file mode 100644 index 0000000000..09904ac4ba --- /dev/null +++ b/tests/unit/core/rewrite/test_identifiers.py @@ -0,0 +1,153 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import typing + +import bigframes.core as core +import bigframes.core.expression as ex +import bigframes.core.identifiers as identifiers +import bigframes.core.nodes as nodes +import bigframes.core.rewrite.identifiers as id_rewrite + + +def test_remap_variables_single_node(leaf): + node = leaf + id_generator = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node, mapping = id_rewrite.remap_variables(node, id_generator) + assert new_node is not node + assert len(mapping) == 2 + assert set(mapping.keys()) == {f.id for f in node.fields} + assert set(mapping.values()) == { + identifiers.ColumnId("id_0"), + identifiers.ColumnId("id_1"), + } + + +def test_remap_variables_projection(leaf): + node = nodes.ProjectionNode( + leaf, + ( + ( + core.expression.DerefOp(leaf.fields[0].id), + identifiers.ColumnId("new_col"), + ), + ), + ) + id_generator = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node, mapping = id_rewrite.remap_variables(node, id_generator) + assert new_node is not node + assert len(mapping) == 3 + assert set(mapping.keys()) == {f.id for f in node.fields} + assert set(mapping.values()) == {identifiers.ColumnId(f"id_{i}") for i in range(3)} + + +def test_remap_variables_nested_join_stability(leaf, fake_session, table): + # Create two more distinct leaf nodes + leaf2_uncached = core.ArrayValue.from_table( + session=fake_session, + table=table, + ).node + leaf2 = leaf2_uncached.remap_vars( + { + field.id: identifiers.ColumnId(f"leaf2_{field.id.name}") + for field in leaf2_uncached.fields + } + ) + leaf3_uncached = core.ArrayValue.from_table( + session=fake_session, + table=table, + ).node + leaf3 = leaf3_uncached.remap_vars( + { + field.id: identifiers.ColumnId(f"leaf3_{field.id.name}") + for field in leaf3_uncached.fields + } + ) + + # Create a nested join: (leaf JOIN leaf2) JOIN leaf3 + inner_join = nodes.JoinNode( + left_child=leaf, + right_child=leaf2, + conditions=( + ( + core.expression.DerefOp(leaf.fields[0].id), + core.expression.DerefOp(leaf2.fields[0].id), + ), + ), + type="inner", + propogate_order=False, + ) + outer_join = nodes.JoinNode( + left_child=inner_join, + right_child=leaf3, + conditions=( + ( + core.expression.DerefOp(inner_join.fields[0].id), + core.expression.DerefOp(leaf3.fields[0].id), + ), + ), + type="inner", + propogate_order=False, + ) + + # Run remap_variables twice and assert stability + id_generator1 = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node1, mapping1 = id_rewrite.remap_variables(outer_join, id_generator1) + + id_generator2 = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node2, mapping2 = id_rewrite.remap_variables(outer_join, id_generator2) + + assert new_node1 == new_node2 + assert mapping1 == mapping2 + + +def test_remap_variables_concat_self_stability(leaf): + # Create a concat node with the same child twice + node = nodes.ConcatNode( + children=(leaf, leaf), + output_ids=( + identifiers.ColumnId("concat_a"), + identifiers.ColumnId("concat_b"), + ), + ) + + # Run remap_variables twice and assert stability + id_generator1 = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node1, mapping1 = id_rewrite.remap_variables(node, id_generator1) + + id_generator2 = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node2, mapping2 = id_rewrite.remap_variables(node, id_generator2) + + assert new_node1 == new_node2 + assert mapping1 == mapping2 + + +def test_remap_variables_in_node_converts_dag_to_tree(leaf, leaf_too): + # Create an InNode with the same child twice, should create a tree from a DAG + right = nodes.SelectionNode( + leaf_too, (nodes.AliasedRef.identity(identifiers.ColumnId("col_a")),) + ) + node = nodes.InNode( + left_child=leaf, + right_child=right, + left_col=ex.DerefOp(identifiers.ColumnId("col_a")), + indicator_col=identifiers.ColumnId("indicator"), + ) + + id_generator = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node, _ = id_rewrite.remap_variables(node, id_generator) + new_node = typing.cast(nodes.InNode, new_node) + + left_col_id = new_node.left_col.id.name + new_node.validate_tree() + assert left_col_id.startswith("id_") diff --git a/tests/unit/core/rewrite/test_slices.py b/tests/unit/core/rewrite/test_slices.py new file mode 100644 index 0000000000..6d49ffb80a --- /dev/null +++ b/tests/unit/core/rewrite/test_slices.py @@ -0,0 +1,34 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import bigframes.core.nodes as nodes +import bigframes.core.rewrite.slices + + +def test_rewrite_noop_slice(leaf): + slice = nodes.SliceNode(leaf, None, None) + result = bigframes.core.rewrite.slices.rewrite_slice(slice) + assert result == leaf + + +def test_rewrite_reverse_slice(leaf): + slice = nodes.SliceNode(leaf, None, None, -1) + result = bigframes.core.rewrite.slices.rewrite_slice(slice) + assert result == nodes.ReversedNode(leaf) + + +def test_rewrite_filter_slice(leaf): + slice = nodes.SliceNode(leaf, None, 2) + result = bigframes.core.rewrite.slices.rewrite_slice(slice) + assert list(result.fields) == list(leaf.fields) + assert isinstance(result.child, nodes.FilterNode) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_basic/create_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_basic/create_model_basic.sql new file mode 100644 index 0000000000..9affd870e3 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_basic/create_model_basic.sql @@ -0,0 +1,3 @@ +CREATE MODEL `my_project.my_dataset.my_model` +OPTIONS(model_type = 'LINEAR_REG', input_label_cols = ['label']) +AS SELECT * FROM my_table diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_if_not_exists/create_model_if_not_exists.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_if_not_exists/create_model_if_not_exists.sql new file mode 100644 index 0000000000..b67ea13967 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_if_not_exists/create_model_if_not_exists.sql @@ -0,0 +1,3 @@ +CREATE MODEL IF NOT EXISTS `my_model` +OPTIONS(model_type = 'KMEANS') +AS SELECT * FROM t diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_list_option/create_model_list_option.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_list_option/create_model_list_option.sql new file mode 100644 index 0000000000..723a4b037d --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_list_option/create_model_list_option.sql @@ -0,0 +1,3 @@ +CREATE MODEL `my_model` +OPTIONS(hidden_units = [32, 16], dropout = 0.2) +AS SELECT * FROM t diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote/create_model_remote.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote/create_model_remote.sql new file mode 100644 index 0000000000..878afe0823 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote/create_model_remote.sql @@ -0,0 +1,5 @@ +CREATE MODEL `my_remote_model` +INPUT (prompt STRING) +OUTPUT (content STRING) +REMOTE WITH CONNECTION `my_project.us.my_connection` +OPTIONS(endpoint = 'gemini-pro') diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote_default/create_model_remote_default.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote_default/create_model_remote_default.sql new file mode 100644 index 0000000000..9bbea44259 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote_default/create_model_remote_default.sql @@ -0,0 +1,3 @@ +CREATE MODEL `my_remote_model` +REMOTE WITH CONNECTION DEFAULT +OPTIONS(endpoint = 'gemini-pro') diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_replace/create_model_replace.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_replace/create_model_replace.sql new file mode 100644 index 0000000000..7fe9d492da --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_replace/create_model_replace.sql @@ -0,0 +1,3 @@ +CREATE OR REPLACE MODEL `my_model` +OPTIONS(model_type = 'LOGISTIC_REG') +AS SELECT * FROM t diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_training_data_and_holiday/create_model_training_data_and_holiday.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_training_data_and_holiday/create_model_training_data_and_holiday.sql new file mode 100644 index 0000000000..da7b6ba672 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_training_data_and_holiday/create_model_training_data_and_holiday.sql @@ -0,0 +1,5 @@ +CREATE MODEL `my_arima_model` +OPTIONS(model_type = 'ARIMA_PLUS') +AS ( + training_data AS (SELECT * FROM sales), custom_holiday AS (SELECT * FROM holidays) +) \ No newline at end of file diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_transform/create_model_transform.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_transform/create_model_transform.sql new file mode 100644 index 0000000000..e460400be2 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_transform/create_model_transform.sql @@ -0,0 +1,4 @@ +CREATE MODEL `my_model` +TRANSFORM (ML.STANDARD_SCALER(c1) OVER() AS c1_scaled, c2) +OPTIONS(model_type = 'LINEAR_REG') +AS SELECT c1, c2, label FROM t diff --git a/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_basic/evaluate_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_basic/evaluate_model_basic.sql new file mode 100644 index 0000000000..5889e342e4 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_basic/evaluate_model_basic.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EVALUATE(MODEL `my_project.my_dataset.my_model`) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_options/evaluate_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_options/evaluate_model_with_options.sql new file mode 100644 index 0000000000..01eb4d3781 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_options/evaluate_model_with_options.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EVALUATE(MODEL `my_model`, STRUCT(False AS perform_aggregation, 10 AS horizon, 0.95 AS confidence_level)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_table/evaluate_model_with_table.sql b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_table/evaluate_model_with_table.sql new file mode 100644 index 0000000000..e1d4fdecd6 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_table/evaluate_model_with_table.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EVALUATE(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM evaluation_data)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_basic/explain_predict_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_basic/explain_predict_model_basic.sql new file mode 100644 index 0000000000..1d755b34dd --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_basic/explain_predict_model_basic.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EXPLAIN_PREDICT(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM new_data)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_with_options/explain_predict_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_with_options/explain_predict_model_with_options.sql new file mode 100644 index 0000000000..1214bba870 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_with_options/explain_predict_model_with_options.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EXPLAIN_PREDICT(MODEL `my_model`, (SELECT * FROM new_data), STRUCT(5 AS top_k_features)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_basic/global_explain_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_basic/global_explain_model_basic.sql new file mode 100644 index 0000000000..4fc8250dab --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_basic/global_explain_model_basic.sql @@ -0,0 +1 @@ +SELECT * FROM ML.GLOBAL_EXPLAIN(MODEL `my_project.my_dataset.my_model`) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_with_options/global_explain_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_with_options/global_explain_model_with_options.sql new file mode 100644 index 0000000000..1a3baa0c13 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_with_options/global_explain_model_with_options.sql @@ -0,0 +1 @@ +SELECT * FROM ML.GLOBAL_EXPLAIN(MODEL `my_model`, STRUCT(True AS class_level_explain)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_predict_model_basic/predict_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_predict_model_basic/predict_model_basic.sql new file mode 100644 index 0000000000..a1ac0b2b45 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_predict_model_basic/predict_model_basic.sql @@ -0,0 +1 @@ +SELECT * FROM ML.PREDICT(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM new_data)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_predict_model_with_options/predict_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_predict_model_with_options/predict_model_with_options.sql new file mode 100644 index 0000000000..96c8074e4c --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_predict_model_with_options/predict_model_with_options.sql @@ -0,0 +1 @@ +SELECT * FROM ML.PREDICT(MODEL `my_model`, (SELECT * FROM new_data), STRUCT(True AS keep_original_columns)) diff --git a/tests/unit/core/sql/test_ml.py b/tests/unit/core/sql/test_ml.py new file mode 100644 index 0000000000..fe8c1a04d4 --- /dev/null +++ b/tests/unit/core/sql/test_ml.py @@ -0,0 +1,171 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.core.sql.ml + +pytest.importorskip("pytest_snapshot") + + +def test_create_model_basic(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_project.my_dataset.my_model", + options={"model_type": "LINEAR_REG", "input_label_cols": ["label"]}, + training_data="SELECT * FROM my_table", + ) + snapshot.assert_match(sql, "create_model_basic.sql") + + +def test_create_model_replace(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_model", + replace=True, + options={"model_type": "LOGISTIC_REG"}, + training_data="SELECT * FROM t", + ) + snapshot.assert_match(sql, "create_model_replace.sql") + + +def test_create_model_if_not_exists(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_model", + if_not_exists=True, + options={"model_type": "KMEANS"}, + training_data="SELECT * FROM t", + ) + snapshot.assert_match(sql, "create_model_if_not_exists.sql") + + +def test_create_model_transform(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_model", + transform=["ML.STANDARD_SCALER(c1) OVER() AS c1_scaled", "c2"], + options={"model_type": "LINEAR_REG"}, + training_data="SELECT c1, c2, label FROM t", + ) + snapshot.assert_match(sql, "create_model_transform.sql") + + +def test_create_model_remote(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_remote_model", + connection_name="my_project.us.my_connection", + options={"endpoint": "gemini-pro"}, + input_schema={"prompt": "STRING"}, + output_schema={"content": "STRING"}, + ) + snapshot.assert_match(sql, "create_model_remote.sql") + + +def test_create_model_remote_default(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_remote_model", + connection_name="DEFAULT", + options={"endpoint": "gemini-pro"}, + ) + snapshot.assert_match(sql, "create_model_remote_default.sql") + + +def test_create_model_training_data_and_holiday(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_arima_model", + options={"model_type": "ARIMA_PLUS"}, + training_data="SELECT * FROM sales", + custom_holiday="SELECT * FROM holidays", + ) + snapshot.assert_match(sql, "create_model_training_data_and_holiday.sql") + + +def test_create_model_list_option(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_model", + options={"hidden_units": [32, 16], "dropout": 0.2}, + training_data="SELECT * FROM t", + ) + snapshot.assert_match(sql, "create_model_list_option.sql") + + +def test_evaluate_model_basic(snapshot): + sql = bigframes.core.sql.ml.evaluate( + model_name="my_project.my_dataset.my_model", + ) + snapshot.assert_match(sql, "evaluate_model_basic.sql") + + +def test_evaluate_model_with_table(snapshot): + sql = bigframes.core.sql.ml.evaluate( + model_name="my_project.my_dataset.my_model", + table="SELECT * FROM evaluation_data", + ) + snapshot.assert_match(sql, "evaluate_model_with_table.sql") + + +def test_evaluate_model_with_options(snapshot): + sql = bigframes.core.sql.ml.evaluate( + model_name="my_model", + perform_aggregation=False, + horizon=10, + confidence_level=0.95, + ) + snapshot.assert_match(sql, "evaluate_model_with_options.sql") + + +def test_predict_model_basic(snapshot): + sql = bigframes.core.sql.ml.predict( + model_name="my_project.my_dataset.my_model", + table="SELECT * FROM new_data", + ) + snapshot.assert_match(sql, "predict_model_basic.sql") + + +def test_predict_model_with_options(snapshot): + sql = bigframes.core.sql.ml.predict( + model_name="my_model", + table="SELECT * FROM new_data", + keep_original_columns=True, + ) + snapshot.assert_match(sql, "predict_model_with_options.sql") + + +def test_explain_predict_model_basic(snapshot): + sql = bigframes.core.sql.ml.explain_predict( + model_name="my_project.my_dataset.my_model", + table="SELECT * FROM new_data", + ) + snapshot.assert_match(sql, "explain_predict_model_basic.sql") + + +def test_explain_predict_model_with_options(snapshot): + sql = bigframes.core.sql.ml.explain_predict( + model_name="my_model", + table="SELECT * FROM new_data", + top_k_features=5, + ) + snapshot.assert_match(sql, "explain_predict_model_with_options.sql") + + +def test_global_explain_model_basic(snapshot): + sql = bigframes.core.sql.ml.global_explain( + model_name="my_project.my_dataset.my_model", + ) + snapshot.assert_match(sql, "global_explain_model_basic.sql") + + +def test_global_explain_model_with_options(snapshot): + sql = bigframes.core.sql.ml.global_explain( + model_name="my_model", + class_level_explain=True, + ) + snapshot.assert_match(sql, "global_explain_model_with_options.sql") diff --git a/tests/unit/core/test_bf_utils.py b/tests/unit/core/test_bf_utils.py index cb3b03d988..6fb796329f 100644 --- a/tests/unit/core/test_bf_utils.py +++ b/tests/unit/core/test_bf_utils.py @@ -46,7 +46,7 @@ def test_get_standardized_ids_indexes(): assert col_ids == ["duplicate_2"] assert idx_ids == [ "string", - "0", + "_0", utils.UNNAMED_INDEX_ID, "duplicate", "duplicate_1", @@ -59,7 +59,7 @@ def test_get_standardized_ids_tuple(): col_ids, _ = utils.get_standardized_ids(col_labels) - assert col_ids == ["('foo', 1)", "('foo', 2)", "('bar', 1)"] + assert col_ids == ["_'foo'_ 1_", "_'foo'_ 2_", "_'bar'_ 1_"] @pytest.mark.parametrize( diff --git a/tests/unit/core/test_blocks.py b/tests/unit/core/test_blocks.py index 8ed3acba0f..7c06bedfd3 100644 --- a/tests/unit/core/test_blocks.py +++ b/tests/unit/core/test_blocks.py @@ -20,7 +20,7 @@ import bigframes import bigframes.core.blocks as blocks -import bigframes.session.executor +import bigframes.session.bq_caching_executor @pytest.mark.parametrize( @@ -80,15 +80,21 @@ def test_block_from_local(data): expected = pandas.DataFrame(data) mock_session = mock.create_autospec(spec=bigframes.Session) mock_executor = mock.create_autospec( - spec=bigframes.session.executor.BigQueryCachingExecutor + spec=bigframes.session.bq_caching_executor.BigQueryCachingExecutor ) # hard-coded the returned dimension of the session for that each of the test case contains 3 rows. mock_session._executor = mock_executor - mock_executor.get_row_count.return_value = 3 block = blocks.Block.from_local(pandas.DataFrame(data), mock_session) pandas.testing.assert_index_equal(block.column_labels, expected.columns) assert tuple(block.index.names) == tuple(expected.index.names) - assert block.shape == expected.shape + + +def test_block_compute_dry_run__raises_error_when_sampling_is_enabled(): + mock_session = mock.create_autospec(spec=bigframes.Session) + block = blocks.Block.from_local(pandas.DataFrame(), mock_session) + + with pytest.raises(NotImplementedError): + block._compute_dry_run(sampling_method="UNIFORM") diff --git a/tests/unit/core/test_expression.py b/tests/unit/core/test_expression.py index ab6402a909..4c3d233879 100644 --- a/tests/unit/core/test_expression.py +++ b/tests/unit/core/test_expression.py @@ -12,43 +12,107 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing + +import pytest + +from bigframes.core import field import bigframes.core.expression as ex import bigframes.core.identifiers as ids import bigframes.dtypes as dtypes import bigframes.operations as ops -def test_expression_dtype_simple(): +def test_simple_expression_dtype(): expression = ops.add_op.as_expr("a", "b") - result = expression.output_type( - {ids.ColumnId("a"): dtypes.INT_DTYPE, ids.ColumnId("b"): dtypes.INT_DTYPE} + field_bindings = _create_field_bindings( + {"a": dtypes.INT_DTYPE, "b": dtypes.INT_DTYPE} ) - assert result == dtypes.INT_DTYPE + + result = ex.bind_schema_fields(expression, field_bindings) + + _assert_output_type(result, dtypes.INT_DTYPE) -def test_expression_dtype_nested(): +def test_nested_expression_dtype(): expression = ops.add_op.as_expr( "a", ops.abs_op.as_expr(ops.sub_op.as_expr("b", ex.const(3.14))) ) - - result = expression.output_type( - {ids.ColumnId("a"): dtypes.INT_DTYPE, ids.ColumnId("b"): dtypes.INT_DTYPE} + field_bindings = _create_field_bindings( + {"a": dtypes.INT_DTYPE, "b": dtypes.INT_DTYPE} ) - assert result == dtypes.FLOAT_DTYPE + result = ex.bind_schema_fields(expression, field_bindings) + _assert_output_type(result, dtypes.FLOAT_DTYPE) -def test_expression_dtype_where(): - expression = ops.where_op.as_expr(ex.const(3), ex.const(True), ex.const(None)) - result = expression.output_type({}) +def test_where_op_dtype(): + expression = ops.where_op.as_expr(ex.const(3), ex.const(True), ex.const(None)) - assert result == dtypes.INT_DTYPE + _assert_output_type(expression, dtypes.INT_DTYPE) -def test_expression_dtype_astype(): +def test_astype_op_dtype(): expression = ops.AsTypeOp(dtypes.INT_DTYPE).as_expr(ex.const(3.14159)) - result = expression.output_type({}) + _assert_output_type(expression, dtypes.INT_DTYPE) + + +def test_deref_op_dtype_unavailable(): + expression = ex.deref("mycol") + + assert not expression.is_resolved + with pytest.raises(ValueError): + expression.output_type + + +def test_deref_op_dtype_resolution(): + expression = ex.deref("mycol") + field_bindings = _create_field_bindings({"mycol": dtypes.STRING_DTYPE}) + + result = ex.bind_schema_fields(expression, field_bindings) + + _assert_output_type(result, dtypes.STRING_DTYPE) + + +def test_field_ref_expr_dtype_resolution_short_circuit(): + expression = ex.ResolvedDerefOp( + id=ids.ColumnId("mycol"), dtype=dtypes.INT_DTYPE, is_nullable=True + ) + field_bindings = _create_field_bindings({"anotherCol": dtypes.STRING_DTYPE}) + + result = ex.bind_schema_fields(expression, field_bindings) + + _assert_output_type(result, dtypes.INT_DTYPE) + + +def test_nested_expression_dtypes_are_cached(): + expression = ops.add_op.as_expr(ex.deref("left_col"), ex.deref("right_col")) + field_bindings = _create_field_bindings( + { + "right_col": dtypes.INT_DTYPE, + "left_col": dtypes.FLOAT_DTYPE, + } + ) + + result = ex.bind_schema_fields(expression, field_bindings) + + _assert_output_type(result, dtypes.FLOAT_DTYPE) + assert isinstance(result, ex.OpExpression) + _assert_output_type(result.inputs[0], dtypes.FLOAT_DTYPE) + _assert_output_type(result.inputs[1], dtypes.INT_DTYPE) + + +def _create_field_bindings( + col_dtypes: typing.Dict[str, dtypes.Dtype] +) -> typing.Dict[ids.ColumnId, field.Field]: + return { + ids.ColumnId(col): field.Field(ids.ColumnId(col), dtype) + for col, dtype in col_dtypes.items() + } + - assert result == dtypes.INT_DTYPE +def _assert_output_type(expr: ex.Expression, dtype: dtypes.Dtype): + assert expr.is_resolved + assert expr.output_type == dtype diff --git a/tests/unit/core/test_groupby.py b/tests/unit/core/test_groupby.py new file mode 100644 index 0000000000..4bef581b2f --- /dev/null +++ b/tests/unit/core/test_groupby.py @@ -0,0 +1,264 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pandas.testing +import pytest + +import bigframes.core.utils as utils +import bigframes.pandas as bpd +from bigframes.testing.utils import assert_series_equal + +pytest.importorskip("polars") +pytest.importorskip("pandas", minversion="2.0.0") + + +def test_groupby_df_iter_by_key_singular(polars_session): + pd_df = pd.DataFrame({"colA": ["a", "a", "b", "c", "c"], "colB": [1, 2, 3, 4, 5]}) + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip(bf_df.groupby("colA"), pd_df.groupby("colA")): # type: ignore + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_key_list(polars_session): + pd_df = pd.DataFrame({"colA": ["a", "a", "b", "c", "c"], "colB": [1, 2, 3, 4, 5]}) + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip(bf_df.groupby(["colA"]), pd_df.groupby(["colA"])): # type: ignore + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_key_list_multiple(polars_session): + pd_df = pd.DataFrame( + { + "colA": ["a", "a", "b", "c", "c"], + "colB": [1, 2, 3, 4, 5], + "colC": [True, False, True, False, True], + } + ) + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip( # type: ignore + bf_df.groupby(["colA", "colB"]), pd_df.groupby(["colA", "colB"]) + ): + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_level_singular(polars_session): + pd_df = pd.DataFrame( + {"colA": ["a", "a", "b", "c", "c"], "colB": [1, 2, 3, 4, 5]} + ).set_index("colA") + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip(bf_df.groupby(level=0), pd_df.groupby(level=0)): # type: ignore + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_level_list_one_item(polars_session): + pd_df = pd.DataFrame( + {"colA": ["a", "a", "b", "c", "c"], "colB": [1, 2, 3, 4, 5]} + ).set_index("colA") + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip(bf_df.groupby(level=[0]), pd_df.groupby(level=[0])): # type: ignore + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + + # In pandas 2.x, we get a warning from pandas: "Creating a Groupby + # object with a length-1 list-like level parameter will yield indexes + # as tuples in a future version. To keep indexes as scalars, create + # Groupby objects with a scalar level parameter instead. + if utils.is_list_like(pd_key): + assert bf_key == tuple(pd_key) + else: + assert bf_key == (pd_key,) + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_level_list_multiple(polars_session): + pd_df = pd.DataFrame( + { + "colA": ["a", "a", "b", "c", "c"], + "colB": [1, 2, 3, 4, 5], + "colC": [True, False, True, False, True], + } + ).set_index(["colA", "colB"]) + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip( # type: ignore + bf_df.groupby(level=[0, 1]), pd_df.groupby(level=[0, 1]) + ): + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_level_singular(polars_session): + series_index = ["a", "a", "b"] + pd_series = pd.Series([1, 2, 3], index=series_index) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby(level=0), pd_series.groupby(level=0) + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_level_list_one_item(polars_session): + series_index = ["a", "a", "b"] + pd_series = pd.Series([1, 2, 3], index=series_index) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby(level=[0]), pd_series.groupby(level=[0]) + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + + # In pandas 2.x, we get a warning from pandas: "Creating a Groupby + # object with a length-1 list-like level parameter will yield indexes + # as tuples in a future version. To keep indexes as scalars, create + # Groupby objects with a scalar level parameter instead. + if utils.is_list_like(pd_key): + assert bf_key == tuple(pd_key) + else: + assert bf_key == (pd_key,) + pandas.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_level_list_multiple(polars_session): + pd_df = pd.DataFrame( + { + "colA": ["a", "a", "b", "c", "c"], + "colB": [1, 2, 3, 4, 5], + "colC": [True, False, True, False, True], + } + ).set_index(["colA", "colB"]) + pd_series = pd_df["colC"] + bf_df = bpd.DataFrame(pd_df, session=polars_session) + bf_series = bf_df["colC"] + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby(level=[0, 1]), pd_series.groupby(level=[0, 1]) + ): + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_series(polars_session): + pd_groups = pd.Series(["a", "a", "b"]) + bf_groups = bpd.Series(pd_groups, session=polars_session) + pd_series = pd.Series([1, 2, 3]) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby(bf_groups), pd_series.groupby(pd_groups) + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_series_list_one_item(polars_session): + pd_groups = pd.Series(["a", "a", "b"]) + bf_groups = bpd.Series(pd_groups, session=polars_session) + pd_series = pd.Series([1, 2, 3]) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby([bf_groups]), pd_series.groupby([pd_groups]) + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_series_list_multiple(polars_session): + pd_group_a = pd.Series(["a", "a", "b", "c", "c"]) + bf_group_a = bpd.Series(pd_group_a, session=polars_session) + pd_group_b = pd.Series([0, 0, 0, 1, 1]) + bf_group_b = bpd.Series(pd_group_b, session=polars_session) + pd_series = pd.Series([1, 2, 3, 4, 5]) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby([bf_group_a, bf_group_b]), + pd_series.groupby([pd_group_a, pd_group_b]), + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) diff --git a/tests/unit/core/test_guid.py b/tests/unit/core/test_guid.py new file mode 100644 index 0000000000..c7334848ee --- /dev/null +++ b/tests/unit/core/test_guid.py @@ -0,0 +1,41 @@ +import types +import unittest + +from bigframes.core.guid import SequentialUIDGenerator + + +class TestSequentialUIDGenerator(unittest.TestCase): + def test_get_uid_stream_returns_generator(self): + generator = SequentialUIDGenerator() + stream = generator.get_uid_stream("prefix") + self.assertIsInstance(stream, types.GeneratorType) + + def test_generator_yields_correct_uids(self): + generator = SequentialUIDGenerator() + stream = generator.get_uid_stream("prefix") + self.assertEqual(next(stream), "prefix0") + self.assertEqual(next(stream), "prefix1") + self.assertEqual(next(stream), "prefix2") + + def test_generator_yields_different_uids_for_different_prefixes(self): + generator = SequentialUIDGenerator() + stream_a = generator.get_uid_stream("prefixA") + stream_b = generator.get_uid_stream("prefixB") + self.assertEqual(next(stream_a), "prefixA0") + self.assertEqual(next(stream_b), "prefixB0") + self.assertEqual(next(stream_a), "prefixA1") + self.assertEqual(next(stream_b), "prefixB1") + + def test_multiple_calls_continue_generation(self): + generator = SequentialUIDGenerator() + stream1 = generator.get_uid_stream("prefix") + self.assertEqual(next(stream1), "prefix0") + self.assertEqual(next(stream1), "prefix1") + + stream2 = generator.get_uid_stream("prefix") + self.assertEqual(next(stream2), "prefix2") + self.assertEqual(next(stream2), "prefix3") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/core/test_dtypes.py b/tests/unit/core/test_ibis_types.py similarity index 93% rename from tests/unit/core/test_dtypes.py rename to tests/unit/core/test_ibis_types.py index 3d420de51f..427e726179 100644 --- a/tests/unit/core/test_dtypes.py +++ b/tests/unit/core/test_ibis_types.py @@ -248,22 +248,3 @@ def test_literal_to_ibis_scalar_converts(literal, ibis_scalar): assert bigframes.core.compile.ibis_types.literal_to_ibis_scalar(literal).equals( ibis_scalar ) - - -def test_literal_to_ibis_scalar_throws_on_incompatible_literal(): - with pytest.raises( - ValueError, - ): - bigframes.core.compile.ibis_types.literal_to_ibis_scalar({"mykey": "myval"}) - - -def test_remote_function_io_types_are_supported_bigframes_types(): - from bigframes_vendored.ibis.expr.datatypes.core import ( - dtype as python_type_to_ibis_type, - ) - - from bigframes.dtypes import RF_SUPPORTED_IO_PYTHON_TYPES as rf_supported_io_types - - for python_type in rf_supported_io_types: - ibis_type = python_type_to_ibis_type(python_type) - assert ibis_type in bigframes.core.compile.ibis_types.IBIS_TO_BIGFRAMES diff --git a/tests/unit/core/test_log_adapter.py b/tests/unit/core/test_log_adapter.py index 6bc9c91f3a..c236bb6886 100644 --- a/tests/unit/core/test_log_adapter.py +++ b/tests/unit/core/test_log_adapter.py @@ -40,20 +40,84 @@ def method1(self): pass def method2(self): + self.method3() + + def method3(self): + pass + + @log_adapter.log_name_override("override_name") + def method4(self): pass + @property + def my_field(self): + return 0 + return TestClass() -def test_method_logging(test_instance): +@pytest.fixture +def test_method(): + @log_adapter.method_logger + def method1(): + pass + + return method1 + + +@pytest.fixture +def test_method_w_custom_base(): + def method1(): + pass + + _decorated_method = log_adapter.method_logger(method1, custom_base_name="pandas") + + return _decorated_method + + +def test_class_attribute_logging(test_instance): test_instance.method1() test_instance.method2() + test_instance.method4() # Check if the methods were added to the _api_methods list api_methods = log_adapter.get_and_reset_api_methods() - assert api_methods is not None assert "testclass-method1" in api_methods assert "testclass-method2" in api_methods + assert "testclass-method3" not in api_methods + assert "testclass-method4" not in api_methods + assert "testclass-override_name" in api_methods + + +def test_method_logging(test_method): + test_method() + api_methods = log_adapter.get_and_reset_api_methods() + assert "locals-method1" in api_methods + + +def test_method_logging_with_custom_base_name(test_method_w_custom_base): + test_method_w_custom_base() + api_methods = log_adapter.get_and_reset_api_methods() + assert "pandas-method1" in api_methods + + +def test_method_logging_with_custom_base__logger_as_decorator(): + @log_adapter.method_logger(custom_base_name="pandas") + def my_method(): + pass + + my_method() + + api_methods = log_adapter.get_and_reset_api_methods() + assert "pandas-my_method" in api_methods + + +def test_property_logging(test_instance): + test_instance.my_field + + # Check if the properties were added to the _api_methods list + api_methods = log_adapter.get_and_reset_api_methods() + assert "testclass-my_field" in api_methods def test_add_api_method_limit(test_instance): @@ -129,6 +193,20 @@ def test_get_and_reset_api_methods(test_instance): "args_count": 0, }, ), + ( + "pandas", + "concat", + [[None, None]], + {"axis": 1}, + log_adapter.PANDAS_API_TRACKING_TASK, + { + "task": log_adapter.PANDAS_API_TRACKING_TASK, + "class_name": "pandas", + "method_name": "concat", + "args_count": 1, + "kwargs_0": "axis", + }, + ), ), ) def test_submit_pandas_labels( diff --git a/tests/unit/core/test_pyarrow_utils.py b/tests/unit/core/test_pyarrow_utils.py new file mode 100644 index 0000000000..155c36d268 --- /dev/null +++ b/tests/unit/core/test_pyarrow_utils.py @@ -0,0 +1,65 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import itertools + +import numpy as np +import pyarrow as pa +import pytest + +from bigframes.core import pyarrow_utils + +PA_TABLE = pa.table({f"col_{i}": np.random.rand(1000) for i in range(10)}) + +# 17, 3, 929 coprime +N = 17 +MANY_SMALL_BATCHES = PA_TABLE.to_batches(max_chunksize=3) +FEW_BIG_BATCHES = PA_TABLE.to_batches(max_chunksize=929) + + +@pytest.mark.parametrize( + ["batches", "page_size"], + [ + (MANY_SMALL_BATCHES, N), + (FEW_BIG_BATCHES, N), + ], +) +def test_chunk_by_row_count(batches, page_size): + results = list(pyarrow_utils.chunk_by_row_count(batches, page_size=page_size)) + + for i, batches in enumerate(results): + if i != len(results) - 1: + assert sum(map(lambda x: x.num_rows, batches)) == page_size + else: + # final page can be smaller + assert sum(map(lambda x: x.num_rows, batches)) <= page_size + + reconstructed = pa.Table.from_batches(itertools.chain.from_iterable(results)) + assert reconstructed.equals(PA_TABLE) + + +@pytest.mark.parametrize( + ["batches", "max_rows"], + [ + (MANY_SMALL_BATCHES, N), + (FEW_BIG_BATCHES, N), + ], +) +def test_truncate_pyarrow_iterable(batches, max_rows): + results = list( + pyarrow_utils.truncate_pyarrow_iterable(batches, max_results=max_rows) + ) + + reconstructed = pa.Table.from_batches(results) + assert reconstructed.equals(PA_TABLE.slice(length=max_rows)) diff --git a/tests/unit/core/test_pyformat.py b/tests/unit/core/test_pyformat.py new file mode 100644 index 0000000000..db7cedba8f --- /dev/null +++ b/tests/unit/core/test_pyformat.py @@ -0,0 +1,513 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the pyformat feature.""" + +# TODO(tswast): consolidate with pandas-gbq and bigquery-magics. See: +# https://github.com/googleapis/python-bigquery-magics/blob/main/tests/unit/bigquery/test_pyformat.py + +from __future__ import annotations + +import datetime +import decimal +from typing import Any, Dict, List + +import db_dtypes # type: ignore +import geopandas # type: ignore +import google.cloud.bigquery +import google.cloud.bigquery.table +import numpy +import pandas +import pyarrow +import pytest +import shapely.geometry # type: ignore + +from bigframes.core import pyformat +from bigframes.testing import mocks + + +@pytest.fixture +def session(): + return mocks.create_bigquery_session() + + +@pytest.mark.parametrize( + ("sql_template", "expected"), + ( + ( + "{my_project}.{my_dataset}.{my_table}", + ["my_project", "my_dataset", "my_table"], + ), + ( + "{{not a format variable}}", + [], + ), + ), +) +def test_parse_fields(sql_template: str, expected: List[str]): + fields = pyformat._parse_fields(sql_template) + fields.sort() + expected.sort() + assert fields == expected + + +def test_pyformat_with_unsupported_type_raises_typeerror(session): + pyformat_args = {"my_object": object()} + sql = "SELECT {my_object}" + + with pytest.raises(TypeError, match="my_object has unsupported type: "): + pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) + + +def test_pyformat_with_missing_variable_raises_keyerror(session): + pyformat_args: Dict[str, Any] = {} + sql = "SELECT {my_object}" + + with pytest.raises(KeyError, match="my_object"): + pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) + + +def test_pyformat_with_no_variables(session): + pyformat_args: Dict[str, Any] = {} + sql = "SELECT '{{escaped curly brackets}}'" + expected_sql = "SELECT '{escaped curly brackets}'" + got_sql = pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) + assert got_sql == expected_sql + + +@pytest.mark.parametrize( + ("df_pd", "expected_struct"), + ( + pytest.param( + pandas.DataFrame(), + "STRUCT<>", + id="empty", + ), + pytest.param( + # Empty columns default to floating point, just like pandas. + pandas.DataFrame({"empty column": []}), + "STRUCT<`empty column` FLOAT64>", + id="empty column", + ), + # Regression tests for b/428190014. + # + # Test every BigQuery type we support, especially those where the legacy + # SQL type name differs from the GoogleSQL type name. + # + # See: + # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types + # and compare to the legacy types at + # https://cloud.google.com/bigquery/docs/data-types + # + # Test these against the real BigQuery dry run API in + # tests/system/small/pandas/io/api/test_read_gbq_colab.py + pytest.param( + pandas.DataFrame( + { + "ints": pandas.Series( + [[1], [2], [3]], + dtype=pandas.ArrowDtype(pyarrow.list_(pyarrow.int64())), + ), + "floats": pandas.Series( + [[1.0], [2.0], [3.0]], + dtype=pandas.ArrowDtype(pyarrow.list_(pyarrow.float64())), + ), + } + ), + "STRUCT<`ints` ARRAY, `floats` ARRAY>", + id="arrays", + ), + pytest.param( + pandas.DataFrame( + { + "bool": pandas.Series([True, False, True], dtype="bool"), + "boolean": pandas.Series([True, None, True], dtype="boolean"), + "object": pandas.Series([True, None, True], dtype="object"), + "arrow": pandas.Series( + [True, None, True], dtype=pandas.ArrowDtype(pyarrow.bool_()) + ), + } + ), + "STRUCT<`bool` BOOL, `boolean` BOOL, `object` BOOL, `arrow` BOOL>", + id="bools", + ), + pytest.param( + pandas.DataFrame( + { + "bytes": pandas.Series([b"a", b"b", b"c"], dtype=numpy.bytes_), + "object": pandas.Series([b"a", None, b"c"], dtype="object"), + "arrow": pandas.Series( + [b"a", None, b"c"], dtype=pandas.ArrowDtype(pyarrow.binary()) + ), + } + ), + "STRUCT<`bytes` BYTES, `object` BYTES, `arrow` BYTES>", + id="bytes", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + datetime.date(2023, 11, 23), + None, + datetime.date(1970, 1, 1), + ], + dtype="object", + ), + "arrow": pandas.Series( + [ + datetime.date(2023, 11, 23), + None, + datetime.date(1970, 1, 1), + ], + dtype=pandas.ArrowDtype(pyarrow.date32()), + ), + } + ), + "STRUCT<`object` DATE, `arrow` DATE>", + id="dates", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + datetime.datetime(2023, 11, 23, 13, 14, 15), + None, + datetime.datetime(1970, 1, 1, 0, 0, 0), + ], + dtype="object", + ), + "datetime64": pandas.Series( + [ + datetime.datetime(2023, 11, 23, 13, 14, 15), + None, + datetime.datetime(1970, 1, 1, 0, 0, 0), + ], + dtype="datetime64[us]", + ), + "arrow": pandas.Series( + [ + datetime.datetime(2023, 11, 23, 13, 14, 15), + None, + datetime.datetime(1970, 1, 1, 0, 0, 0), + ], + dtype=pandas.ArrowDtype(pyarrow.timestamp("us")), + ), + } + ), + "STRUCT<`object` DATETIME, `datetime64` DATETIME, `arrow` DATETIME>", + id="datetimes", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + shapely.geometry.Point(145.0, -37.8), + None, + shapely.geometry.Point(-122.3, 47.6), + ], + dtype="object", + ), + "geopandas": geopandas.GeoSeries( + [ + shapely.geometry.Point(145.0, -37.8), + None, + shapely.geometry.Point(-122.3, 47.6), + ] + ), + } + ), + "STRUCT<`object` GEOGRAPHY, `geopandas` GEOGRAPHY>", + id="geographys", + ), + # TODO(tswast): Add INTERVAL once BigFrames supports it. + pytest.param( + pandas.DataFrame( + { + # TODO(tswast): Is there an equivalent object type we can use here? + # TODO(tswast): Add built-in Arrow extension type + "db_dtypes": pandas.Series( + ["{}", None, "123"], + dtype=pandas.ArrowDtype(db_dtypes.JSONArrowType()), + ), + } + ), + "STRUCT<`db_dtypes` JSON>", + id="jsons", + ), + pytest.param( + pandas.DataFrame( + { + "int64": pandas.Series([1, 2, 3], dtype="int64"), + "Int64": pandas.Series([1, None, 3], dtype="Int64"), + "object": pandas.Series([1, None, 3], dtype="object"), + "arrow": pandas.Series( + [1, None, 3], dtype=pandas.ArrowDtype(pyarrow.int64()) + ), + } + ), + "STRUCT<`int64` INT64, `Int64` INT64, `object` INT64, `arrow` INT64>", + id="ints", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [decimal.Decimal("1.23"), None, decimal.Decimal("4.56")], + dtype="object", + ), + "arrow": pandas.Series( + [decimal.Decimal("1.23"), None, decimal.Decimal("4.56")], + dtype=pandas.ArrowDtype(pyarrow.decimal128(38, 9)), + ), + } + ), + "STRUCT<`object` NUMERIC, `arrow` NUMERIC>", + id="numerics", + ), + pytest.param( + pandas.DataFrame( + { + # TODO(tswast): Add object type for BIGNUMERIC. Can bigframes disambiguate? + "arrow": pandas.Series( + [decimal.Decimal("1.23"), None, decimal.Decimal("4.56")], + dtype=pandas.ArrowDtype(pyarrow.decimal256(76, 38)), + ), + } + ), + "STRUCT<`arrow` BIGNUMERIC>", + id="bignumerics", + ), + pytest.param( + pandas.DataFrame( + { + "float64": pandas.Series([1.23, None, 4.56], dtype="float64"), + "Float64": pandas.Series([1.23, None, 4.56], dtype="Float64"), + "object": pandas.Series([1.23, None, 4.56], dtype="object"), + "arrow": pandas.Series( + [1.23, None, 4.56], dtype=pandas.ArrowDtype(pyarrow.float64()) + ), + } + ), + "STRUCT<`float64` FLOAT64, `Float64` FLOAT64, `object` FLOAT64, `arrow` FLOAT64>", + id="floats", + ), + # TODO(tswast): Add RANGE once BigFrames supports it. + pytest.param( + pandas.DataFrame( + { + "string": pandas.Series(["a", "b", "c"], dtype="string[python]"), + "object": pandas.Series(["a", None, "c"], dtype="object"), + "arrow": pandas.Series(["a", None, "c"], dtype="string[pyarrow]"), + } + ), + "STRUCT<`string` STRING, `object` STRING, `arrow` STRING>", + id="strings", + ), + pytest.param( + pandas.DataFrame( + { + # TODO(tswast): Add object type for STRUCT? How to tell apart from JSON? + "arrow": pandas.Series( + [{"a": 1, "b": 1.0, "c": "c"}], + dtype=pandas.ArrowDtype( + pyarrow.struct( + [ + ("a", pyarrow.int64()), + ("b", pyarrow.float64()), + ("c", pyarrow.string()), + ] + ) + ), + ), + } + ), + "STRUCT<`arrow` STRUCT<`a` INT64, `b` FLOAT64, `c` STRING>>", + id="structs", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + datetime.time(0, 0, 0), + None, + datetime.time(13, 7, 11), + ], + dtype="object", + ), + "arrow": pandas.Series( + [ + datetime.time(0, 0, 0), + None, + datetime.time(13, 7, 11), + ], + dtype=pandas.ArrowDtype(pyarrow.time64("us")), + ), + } + ), + "STRUCT<`object` TIME, `arrow` TIME>", + id="times", + ), + pytest.param( + pandas.DataFrame( + { + "object": pandas.Series( + [ + datetime.datetime( + 2023, 11, 23, 13, 14, 15, tzinfo=datetime.timezone.utc + ), + None, + datetime.datetime( + 1970, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ), + ], + dtype="object", + ), + "datetime64": pandas.Series( + [ + datetime.datetime(2023, 11, 23, 13, 14, 15), + None, + datetime.datetime(1970, 1, 1, 0, 0, 0), + ], + dtype="datetime64[us]", + ).dt.tz_localize("UTC"), + "arrow": pandas.Series( + [ + datetime.datetime( + 2023, 11, 23, 13, 14, 15, tzinfo=datetime.timezone.utc + ), + None, + datetime.datetime( + 1970, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ), + ], + dtype=pandas.ArrowDtype(pyarrow.timestamp("us", "UTC")), + ), + } + ), + "STRUCT<`object` TIMESTAMP, `datetime64` TIMESTAMP, `arrow` TIMESTAMP>", + id="timestamps", + ), + # More complicated edge cases: + pytest.param( + pandas.DataFrame( + { + "array of struct col": [ + [{"subfield": {"subsubfield": 1}, "subfield2": 2}], + ], + } + ), + "STRUCT<`array of struct col` ARRAY, `subfield2` INT64>>>", + id="array_of_structs", + ), + pytest.param( + pandas.DataFrame({"c1": [1, 2, 3], "c2": ["a", "b", "c"]}).rename( + columns={"c1": "c", "c2": "c"} + ), + "STRUCT<`c` INT64, `c_1` STRING>", + id="duplicate_column_names", + ), + ), +) +def test_pyformat_with_pandas_dataframe_dry_run_no_session(df_pd, expected_struct): + pyformat_args: Dict[str, Any] = {"my_pandas_df": df_pd} + sql = "SELECT * FROM {my_pandas_df}" + expected_sql = f"SELECT * FROM UNNEST(ARRAY<{expected_struct}>[])" + got_sql = pyformat.pyformat( + sql, pyformat_args=pyformat_args, dry_run=True, session=None + ) + assert got_sql == expected_sql + + +def test_pyformat_with_pandas_dataframe_not_dry_run_no_session_raises_valueerror(): + pyformat_args: Dict[str, Any] = {"my_pandas_df": pandas.DataFrame()} + sql = "SELECT * FROM {my_pandas_df}" + + with pytest.raises(ValueError, match="my_pandas_df"): + pyformat.pyformat(sql, pyformat_args=pyformat_args) + + +def test_pyformat_with_query_string_replaces_variables(session): + pyformat_args = { + "my_string": "`my_table`", + "max_value": 2.25, + "year": 2025, + "null_value": None, + # Unreferenced values of unsupported type shouldn't cause issues. + "my_object": object(), + } + + sql = """ + SELECT {year} - year AS age, + @myparam AS myparam, + '{{my_string}}' AS escaped_string, + * + FROM {my_string} + WHERE height < {max_value} + """.strip() + + expected_sql = """ + SELECT 2025 - year AS age, + @myparam AS myparam, + '{my_string}' AS escaped_string, + * + FROM `my_table` + WHERE height < 2.25 + """.strip() + + got_sql = pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) + assert got_sql == expected_sql + + +@pytest.mark.parametrize( + ("table", "expected_sql"), + ( + ( + google.cloud.bigquery.Table("my-project.my_dataset.my_table"), + "SELECT * FROM `my-project`.`my_dataset`.`my_table`", + ), + ( + google.cloud.bigquery.TableReference( + google.cloud.bigquery.DatasetReference("some-project", "some_dataset"), + "some_table", + ), + "SELECT * FROM `some-project`.`some_dataset`.`some_table`", + ), + ( + google.cloud.bigquery.table.TableListItem( + { + "tableReference": { + "projectId": "ListedProject", + "datasetId": "ListedDataset", + "tableId": "ListedTable", + } + } + ), + "SELECT * FROM `ListedProject`.`ListedDataset`.`ListedTable`", + ), + ), +) +def test_pyformat_with_table_replaces_variables(table, expected_sql, session=session): + pyformat_args = { + "table": table, + # Unreferenced values of unsupported type shouldn't cause issues. + "my_object": object(), + } + sql = "SELECT * FROM {table}" + got_sql = pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) + assert got_sql == expected_sql diff --git a/tests/unit/core/test_rewrite.py b/tests/unit/core/test_rewrite.py deleted file mode 100644 index 1f1a2c3db9..0000000000 --- a/tests/unit/core/test_rewrite.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import unittest.mock as mock - -import google.cloud.bigquery - -import bigframes.core as core -import bigframes.core.nodes as nodes -import bigframes.core.rewrite.slices -import bigframes.core.schema - -TABLE_REF = google.cloud.bigquery.TableReference.from_string("project.dataset.table") -SCHEMA = ( - google.cloud.bigquery.SchemaField("col_a", "INTEGER"), - google.cloud.bigquery.SchemaField("col_b", "INTEGER"), -) -TABLE = google.cloud.bigquery.Table( - table_ref=TABLE_REF, - schema=SCHEMA, -) -FAKE_SESSION = mock.create_autospec(bigframes.Session, instance=True) -type(FAKE_SESSION)._strictly_ordered = mock.PropertyMock(return_value=True) -LEAF = core.ArrayValue.from_table( - session=FAKE_SESSION, - table=TABLE, - schema=bigframes.core.schema.ArraySchema.from_bq_table(TABLE), -).node - - -def test_rewrite_noop_slice(): - slice = nodes.SliceNode(LEAF, None, None) - result = bigframes.core.rewrite.slices.rewrite_slice(slice) - assert result == LEAF - - -def test_rewrite_reverse_slice(): - slice = nodes.SliceNode(LEAF, None, None, -1) - result = bigframes.core.rewrite.slices.rewrite_slice(slice) - assert result == nodes.ReversedNode(LEAF) - - -def test_rewrite_filter_slice(): - slice = nodes.SliceNode(LEAF, None, 2) - result = bigframes.core.rewrite.slices.rewrite_slice(slice) - assert list(result.fields) == list(LEAF.fields) - assert isinstance(result.child, nodes.FilterNode) diff --git a/tests/unit/core/test_sql.py b/tests/unit/core/test_sql.py index ca286cafff..17da3008fc 100644 --- a/tests/unit/core/test_sql.py +++ b/tests/unit/core/test_sql.py @@ -14,15 +14,16 @@ import datetime import decimal +import re import pytest -import shapely # type: ignore +import shapely.geometry # type: ignore from bigframes.core import sql @pytest.mark.parametrize( - ("value", "expected"), + ("value", "expected_pattern"), ( # Try to have some literals for each scalar data type: # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types @@ -32,44 +33,105 @@ (False, "False"), ( b"\x01\x02\x03ABC", - r"b'\x01\x02\x03ABC'", + re.escape(r"b'\x01\x02\x03ABC'"), ), ( datetime.date(2025, 1, 1), - "DATE('2025-01-01')", + re.escape("DATE('2025-01-01')"), ), ( datetime.datetime(2025, 1, 2, 3, 45, 6, 789123), - "DATETIME('2025-01-02T03:45:06.789123')", + re.escape("DATETIME('2025-01-02T03:45:06.789123')"), ), ( - shapely.Point(0, 1), - "ST_GEOGFROMTEXT('POINT (0 1)')", + shapely.geometry.Point(0, 1), + r"ST_GEOGFROMTEXT\('POINT \(0[.]?0* 1[.]?0*\)'\)", ), # TODO: INTERVAL type (e.g. from dateutil.relativedelta) # TODO: JSON type (TBD what Python object that would correspond to) - (123, "123"), - (decimal.Decimal("123.75"), "CAST('123.75' AS NUMERIC)"), + (123, re.escape("123")), + (decimal.Decimal("123.75"), re.escape("CAST('123.75' AS NUMERIC)")), # TODO: support BIGNUMERIC by looking at precision/scale of the DECIMAL - (123.75, "123.75"), + (123.75, re.escape("123.75")), # TODO: support RANGE type - ("abc", "'abc'"), + ("abc", re.escape("'abc'")), # TODO: support STRUCT type (possibly another method?) ( datetime.time(12, 34, 56, 789123), - "TIME(DATETIME('1970-01-01 12:34:56.789123'))", + re.escape("TIME(DATETIME('1970-01-01 12:34:56.789123'))"), ), ( datetime.datetime( 2025, 1, 2, 3, 45, 6, 789123, tzinfo=datetime.timezone.utc ), - "TIMESTAMP('2025-01-02T03:45:06.789123+00:00')", + re.escape("TIMESTAMP('2025-01-02T03:45:06.789123+00:00')"), ), ), ) -def test_simple_literal(value, expected): +def test_simple_literal(value, expected_pattern): got = sql.simple_literal(value) - assert got == expected + assert re.match(expected_pattern, got) is not None + + +@pytest.mark.parametrize( + ("value", "expected_pattern"), + ( + # Try to have some list of literals for each scalar data type: + # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types + ([None, None], re.escape("[NULL, NULL]")), + ([True, False], re.escape("[True, False]")), + ( + [b"\x01\x02\x03ABC", b"\x01\x02\x03ABC"], + re.escape("[b'\\x01\\x02\\x03ABC', b'\\x01\\x02\\x03ABC']"), + ), + ( + [datetime.date(2025, 1, 1), datetime.date(2025, 1, 1)], + re.escape("[DATE('2025-01-01'), DATE('2025-01-01')]"), + ), + ( + [datetime.datetime(2025, 1, 2, 3, 45, 6, 789123)], + re.escape("[DATETIME('2025-01-02T03:45:06.789123')]"), + ), + ( + [shapely.geometry.Point(0, 1), shapely.geometry.Point(0, 2)], + r"\[ST_GEOGFROMTEXT\('POINT \(0[.]?0* 1[.]?0*\)'\), ST_GEOGFROMTEXT\('POINT \(0[.]?0* 2[.]?0*\)'\)\]", + ), + # TODO: INTERVAL type (e.g. from dateutil.relativedelta) + # TODO: JSON type (TBD what Python object that would correspond to) + ([123, 456], re.escape("[123, 456]")), + ( + [decimal.Decimal("123.75"), decimal.Decimal("456.78")], + re.escape("[CAST('123.75' AS NUMERIC), CAST('456.78' AS NUMERIC)]"), + ), + # TODO: support BIGNUMERIC by looking at precision/scale of the DECIMAL + ([123.75, 456.78], re.escape("[123.75, 456.78]")), + # TODO: support RANGE type + (["abc", "def"], re.escape("['abc', 'def']")), + # TODO: support STRUCT type (possibly another method?) + ( + [datetime.time(12, 34, 56, 789123), datetime.time(11, 25, 56, 789123)], + re.escape( + "[TIME(DATETIME('1970-01-01 12:34:56.789123')), TIME(DATETIME('1970-01-01 11:25:56.789123'))]" + ), + ), + ( + [ + datetime.datetime( + 2025, 1, 2, 3, 45, 6, 789123, tzinfo=datetime.timezone.utc + ), + datetime.datetime( + 2025, 2, 1, 4, 45, 6, 789123, tzinfo=datetime.timezone.utc + ), + ], + re.escape( + "[TIMESTAMP('2025-01-02T03:45:06.789123+00:00'), TIMESTAMP('2025-02-01T04:45:06.789123+00:00')]" + ), + ), + ), +) +def test_simple_literal_w_list(value: list, expected_pattern: str): + got = sql.simple_literal(value) + assert re.match(expected_pattern, got) is not None def test_create_vector_search_sql_simple(): diff --git a/tests/unit/core/test_windowspec.py b/tests/unit/core/test_windowspec.py new file mode 100644 index 0000000000..b9de764136 --- /dev/null +++ b/tests/unit/core/test_windowspec.py @@ -0,0 +1,50 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import window_spec + + +@pytest.mark.parametrize(("start", "end"), [(-1, -2), (1, -2), (2, 1)]) +def test_invalid_rows_window_boundary_raise_error(start, end): + with pytest.raises(ValueError): + window_spec.RowsWindowBounds(start, end) + + +@pytest.mark.parametrize(("start", "end"), [(-1, -2), (1, -2), (2, 1)]) +def test_invalid_range_window_boundary_raise_error(start, end): + with pytest.raises(ValueError): + window_spec.RangeWindowBounds(start, end) + + +@pytest.mark.parametrize( + ("window", "closed", "start", "end"), + [ + pytest.param(3, "left", -3, -1, id="left"), + pytest.param(3, "right", -2, 0, id="right"), + pytest.param(3, "neither", -2, -1, id="neither"), + pytest.param(3, "both", -3, 0, id="both"), + ], +) +def test_rows_window_bounds_from_window_size(window, closed, start, end): + actual_result = window_spec.RowsWindowBounds.from_window_size(window, closed) + + expected_result = window_spec.RowsWindowBounds(start, end) + assert actual_result == expected_result + + +def test_rows_window_bounds_from_window_size_invalid_closed_raise_error(): + with pytest.raises(ValueError): + window_spec.RowsWindowBounds.from_window_size(3, "whatever") # type:ignore diff --git a/tests/unit/core/tools/__init__.py b/tests/unit/core/tools/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/tests/unit/core/tools/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unit/core/tools/test_bigquery_schema.py b/tests/unit/core/tools/test_bigquery_schema.py new file mode 100644 index 0000000000..aed8ae0323 --- /dev/null +++ b/tests/unit/core/tools/test_bigquery_schema.py @@ -0,0 +1,191 @@ +from google.cloud import bigquery +import pytest + +from bigframes.core.tools import bigquery_schema + + +# --- Tests for _type_to_sql --- +@pytest.mark.parametrize( + "field, expected_sql", + [ + # Simple types + # Note: the REST API will return Legacy SQL data types, but we need to + # map to GoogleSQL. See internal issue b/428190014. + (bigquery.SchemaField("test_field", "INTEGER"), "INT64"), + (bigquery.SchemaField("test_field", "STRING"), "STRING"), + (bigquery.SchemaField("test_field", "BOOLEAN"), "BOOL"), + # RECORD/STRUCT types with nested fields directly + ( + bigquery.SchemaField( + "test_field", + "RECORD", + fields=(bigquery.SchemaField("sub_field", "STRING"),), + ), + "STRUCT<`sub_field` STRING>", + ), + ( + bigquery.SchemaField( + "test_field", + "STRUCT", + fields=( + bigquery.SchemaField("sub_field", "INTEGER"), + bigquery.SchemaField("another", "BOOLEAN"), + ), + ), + "STRUCT<`sub_field` INT64, `another` BOOL>", + ), + # Array is handled by _field_to_sql, instead. + (bigquery.SchemaField("test_field", "NUMERIC", mode="REPEATED"), "NUMERIC"), + ( + bigquery.SchemaField( + "test_field", + "RECORD", + mode="REPEATED", + fields=(bigquery.SchemaField("sub_field", "STRING"),), + ), + "STRUCT<`sub_field` STRING>", + ), + ], +) +def test_type_to_sql(field, expected_sql): + assert bigquery_schema._type_to_sql(field) == expected_sql + + +# --- Tests for _field_to_sql --- +@pytest.mark.parametrize( + "field, expected_sql", + [ + # Simple field + # Note: the REST API will return Legacy SQL data types, but we need to + # map to GoogleSQL. See internal issue b/428190014. + (bigquery.SchemaField("id", "INTEGER", "NULLABLE"), "`id` INT64"), + (bigquery.SchemaField("name", "STRING", "NULLABLE"), "`name` STRING"), + # Repeated field + (bigquery.SchemaField("tags", "STRING", "REPEATED"), "`tags` ARRAY"), + # Repeated RECORD + ( + bigquery.SchemaField( + "addresses", + "RECORD", + "REPEATED", + fields=( + bigquery.SchemaField("street", "STRING"), + bigquery.SchemaField("zip", "INTEGER"), + ), + ), + "`addresses` ARRAY>", + ), + # Simple STRUCT + ( + bigquery.SchemaField( + "person", + "STRUCT", + "NULLABLE", + fields=( + bigquery.SchemaField("age", "INTEGER"), + bigquery.SchemaField("city", "STRING"), + ), + ), + "`person` STRUCT<`age` INT64, `city` STRING>", + ), + ], +) +def test_field_to_sql(field, expected_sql): + assert bigquery_schema._field_to_sql(field) == expected_sql + + +# --- Tests for _to_struct --- +@pytest.mark.parametrize( + "bqschema, expected_sql", + [ + # Empty schema + ((), "STRUCT<>"), + # Simple fields + ( + ( + bigquery.SchemaField("id", "INTEGER"), + bigquery.SchemaField("name", "STRING"), + ), + "STRUCT<`id` INT64, `name` STRING>", + ), + # Nested RECORD/STRUCT + ( + ( + bigquery.SchemaField("item_id", "INTEGER"), + bigquery.SchemaField( + "details", + "RECORD", + "NULLABLE", + fields=( + bigquery.SchemaField("price", "NUMERIC"), + bigquery.SchemaField("currency", "STRING"), + ), + ), + ), + "STRUCT<`item_id` INT64, `details` STRUCT<`price` NUMERIC, `currency` STRING>>", + ), + # Repeated field + ( + ( + bigquery.SchemaField("user_id", "STRING"), + bigquery.SchemaField("emails", "STRING", "REPEATED"), + ), + "STRUCT<`user_id` STRING, `emails` ARRAY>", + ), + # Mixed types including complex nested repeated + ( + ( + bigquery.SchemaField("event_name", "STRING"), + bigquery.SchemaField( + "participants", + "RECORD", + "REPEATED", + fields=( + bigquery.SchemaField("p_id", "INTEGER"), + bigquery.SchemaField("roles", "STRING", "REPEATED"), + ), + ), + bigquery.SchemaField("timestamp", "TIMESTAMP"), + ), + "STRUCT<`event_name` STRING, `participants` ARRAY>>, `timestamp` TIMESTAMP>", + ), + ], +) +def test_to_struct(bqschema, expected_sql): + assert bigquery_schema._to_struct(bqschema) == expected_sql + + +# --- Tests for to_sql_dry_run --- +@pytest.mark.parametrize( + "bqschema, expected_sql", + [ + # Empty schema + ((), "UNNEST(ARRAY>[])"), + # Simple schema + ( + ( + bigquery.SchemaField("id", "INTEGER"), + bigquery.SchemaField("name", "STRING"), + ), + "UNNEST(ARRAY>[])", + ), + # Complex schema with nested and repeated fields + ( + ( + bigquery.SchemaField("order_id", "STRING"), + bigquery.SchemaField( + "items", + "RECORD", + "REPEATED", + fields=( + bigquery.SchemaField("item_name", "STRING"), + bigquery.SchemaField("quantity", "INTEGER"), + ), + ), + ), + "UNNEST(ARRAY>>>[])", + ), + ], +) +def test_to_sql_dry_run(bqschema, expected_sql): + assert bigquery_schema.to_sql_dry_run(bqschema) == expected_sql diff --git a/tests/unit/core/tools/test_datetimes.py b/tests/unit/core/tools/test_datetimes.py new file mode 100644 index 0000000000..96a6b14ef8 --- /dev/null +++ b/tests/unit/core/tools/test_datetimes.py @@ -0,0 +1,43 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import cast +from unittest import mock + +import bigframes.core.tools.datetimes +import bigframes.dtypes +import bigframes.pandas +import bigframes.testing.mocks + + +def test_to_datetime_with_series_and_format_doesnt_cache(monkeypatch): + df = bigframes.testing.mocks.create_dataframe(monkeypatch) + series = mock.Mock(spec=bigframes.pandas.Series, wraps=df["col"]) + dt_series = cast( + bigframes.pandas.Series, + bigframes.core.tools.datetimes.to_datetime(series, format="%Y%m%d"), + ) + series._cached.assert_not_called() + assert dt_series.dtype == bigframes.dtypes.DATETIME_DTYPE + + +def test_to_datetime_with_series_and_format_utc_doesnt_cache(monkeypatch): + df = bigframes.testing.mocks.create_dataframe(monkeypatch) + series = mock.Mock(spec=bigframes.pandas.Series, wraps=df["col"]) + dt_series = cast( + bigframes.pandas.Series, + bigframes.core.tools.datetimes.to_datetime(series, format="%Y%m%d", utc=True), + ) + series._cached.assert_not_called() + assert dt_series.dtype == bigframes.dtypes.TIMESTAMP_DTYPE diff --git a/tests/unit/display/test_anywidget.py b/tests/unit/display/test_anywidget.py new file mode 100644 index 0000000000..2ca8c0da2f --- /dev/null +++ b/tests/unit/display/test_anywidget.py @@ -0,0 +1,80 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import signal +import unittest.mock as mock + +import pandas as pd +import pytest + +import bigframes + +# Skip if anywidget/traitlets not installed, though they should be in the dev env +pytest.importorskip("anywidget") +pytest.importorskip("traitlets") + + +def test_navigation_to_invalid_page_resets_to_valid_page_without_deadlock(): + """ + Given a widget on a page beyond available data, when navigating, + then it should reset to the last valid page without deadlock. + """ + from bigframes.display.anywidget import TableWidget + + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) + mock_df.columns = ["col1"] + mock_df.dtypes = {"col1": "object"} + + mock_block = mock.Mock() + mock_block.has_index = False + mock_df._block = mock_block + + # We mock _initial_load to avoid complex setup + with mock.patch.object(TableWidget, "_initial_load"): + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 10 + ): + widget = TableWidget(mock_df) + + # Simulate "loaded data but unknown total rows" state + widget.page_size = 10 + widget.row_count = None + widget._all_data_loaded = True + + # Populate cache with 1 page of data (10 rows). Page 0 is valid, page 1+ are invalid. + widget._cached_batches = [pd.DataFrame({"col1": range(10)})] + + # Mark initial load as complete so observers fire + widget._initial_load_complete = True + + # Setup timeout to fail fast if deadlock occurs + # signal.SIGALRM is not available on Windows + has_sigalrm = hasattr(signal, "SIGALRM") + if has_sigalrm: + + def handler(signum, frame): + raise TimeoutError("Deadlock detected!") + + signal.signal(signal.SIGALRM, handler) + signal.alarm(2) # 2 seconds timeout + + try: + # Trigger navigation to page 5 (invalid), which should reset to page 0 + widget.page = 5 + + assert widget.page == 0 + + finally: + if has_sigalrm: + signal.alarm(0) diff --git a/tests/unit/display/test_html.py b/tests/unit/display/test_html.py new file mode 100644 index 0000000000..0762a2fd8d --- /dev/null +++ b/tests/unit/display/test_html.py @@ -0,0 +1,150 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +import pandas as pd +import pyarrow as pa +import pytest + +import bigframes as bf +import bigframes.display.html as bf_html + + +@pytest.mark.parametrize( + ("data", "expected_alignments", "expected_strings"), + [ + pytest.param( + { + "string_col": ["a", "b", "c"], + "int_col": [1, 2, 3], + "float_col": [1.1, 2.2, 3.3], + "bool_col": [True, False, True], + }, + { + "string_col": "left", + "int_col": "right", + "float_col": "right", + "bool_col": "left", + }, + ["1.100000", "2.200000", "3.300000"], + id="scalars", + ), + pytest.param( + { + "timestamp_col": pa.array( + [ + datetime.datetime.fromisoformat(value) + for value in [ + "2024-01-01 00:00:00", + "2024-01-01 00:00:01", + "2024-01-01 00:00:02", + ] + ], + pa.timestamp("us", tz="UTC"), + ), + "datetime_col": pa.array( + [ + datetime.datetime.fromisoformat(value) + for value in [ + "2027-06-05 04:03:02.001", + "2027-01-01 00:00:01", + "2027-01-01 00:00:02", + ] + ], + pa.timestamp("us"), + ), + "date_col": pa.array( + [ + datetime.date(1999, 1, 1), + datetime.date(1999, 1, 2), + datetime.date(1999, 1, 3), + ], + pa.date32(), + ), + "time_col": pa.array( + [ + datetime.time(11, 11, 0), + datetime.time(11, 11, 1), + datetime.time(11, 11, 2), + ], + pa.time64("us"), + ), + }, + { + "timestamp_col": "left", + "datetime_col": "left", + "date_col": "left", + "time_col": "left", + }, + [ + "2024-01-01 00:00:00", + "2027-06-05 04:03:02.001", + "1999-01-01", + "11:11:01", + ], + id="datetimes", + ), + pytest.param( + { + "array_col": pd.Series( + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + dtype=pd.ArrowDtype(pa.list_(pa.int64())), + ), + }, + { + "array_col": "left", + }, + ["[1, 2, 3]", "[4, 5, 6]", "[7, 8, 9]"], + id="array", + ), + pytest.param( + { + "struct_col": pd.Series( + [{"v": 1}, {"v": 2}, {"v": 3}], + dtype=pd.ArrowDtype(pa.struct([("v", pa.int64())])), + ), + }, + { + "struct_col": "left", + }, + ["{'v': 1}", "{'v': 2}", "{'v': 3}"], + id="struct", + ), + ], +) +def test_render_html_alignment_and_precision( + data, expected_alignments, expected_strings +): + df = pd.DataFrame(data) + html = bf_html.render_html(dataframe=df, table_id="test-table") + + for align in expected_alignments.values(): + assert f'class="cell-align-{align}"' in html + + for expected_string in expected_strings: + assert expected_string in html + + +def test_render_html_precision(): + data = {"float_col": [3.14159265]} + df = pd.DataFrame(data) + + with bf.option_context("display.precision", 4): + html = bf_html.render_html(dataframe=df, table_id="test-table") + assert "3.1416" in html + + # Make sure we reset to default + html = bf_html.render_html(dataframe=df, table_id="test-table") + assert "3.141593" in html diff --git a/tests/unit/functions/test_function_typing.py b/tests/unit/functions/test_function_typing.py new file mode 100644 index 0000000000..46ae19555a --- /dev/null +++ b/tests/unit/functions/test_function_typing.py @@ -0,0 +1,50 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import decimal + +import pytest + +from bigframes.functions import function_typing + + +def test_unsupported_type_error_init_with_dict(): + err = function_typing.UnsupportedTypeError( + decimal.Decimal, {int: "INT64", float: "FLOAT64"} + ) + + message = str(err) + + assert "Decimal" in message + assert "float, int" in message + + +def test_unsupported_type_error_init_with_set(): + err = function_typing.UnsupportedTypeError(decimal.Decimal, {int, float}) + + message = str(err) + + assert "Decimal" in message + assert "float, int" in message + + +def test_sdk_type_from_python_type_raises_unsupported_type_error(): + with pytest.raises(function_typing.UnsupportedTypeError) as excinfo: + function_typing.sdk_type_from_python_type(datetime.datetime) + + message = str(excinfo.value) + + assert "datetime" in message + assert "bool, bytes, float, int, str" in message diff --git a/tests/unit/functions/test_remote_function.py b/tests/unit/functions/test_remote_function.py index 413a694680..e9e0d0df67 100644 --- a/tests/unit/functions/test_remote_function.py +++ b/tests/unit/functions/test_remote_function.py @@ -14,35 +14,20 @@ import re -import bigframes_vendored.ibis.backends.bigquery.datatypes as third_party_ibis_bqtypes -from bigframes_vendored.ibis.expr import datatypes as ibis_types import pandas import pytest -import bigframes.core.compile.ibis_types -import bigframes.dtypes +import bigframes.exceptions import bigframes.functions.function as bff -import bigframes.series -from tests.unit import resources - - -@pytest.mark.parametrize( - "series_type", - ( - pytest.param( - pandas.Series, - id="pandas.Series", - ), - pytest.param( - bigframes.series.Series, - id="bigframes.series.Series", - ), - ), -) -def test_series_input_types_to_str(series_type): +from bigframes.testing import mocks + + +def test_series_input_types_to_str(): """Check that is_row_processor=True uses str as the input type to serialize a row.""" - session = resources.create_bigquery_session() - remote_function_decorator = bff.remote_function(session=session) + session = mocks.create_bigquery_session() + remote_function_decorator = bff.remote_function( + session=session, cloud_function_service_account="default" + ) with pytest.warns( bigframes.exceptions.PreviewWarning, @@ -50,30 +35,18 @@ def test_series_input_types_to_str(series_type): ): @remote_function_decorator - def axis_1_function(myparam: series_type) -> str: # type: ignore + def axis_1_function(myparam: pandas.Series) -> str: # type: ignore return "Hello, " + myparam["str_col"] + "!" # type: ignore # Still works as a normal function. assert axis_1_function(pandas.Series({"str_col": "World"})) == "Hello, World!" - assert axis_1_function.ibis_node is not None - - -def test_supported_types_correspond(): - # The same types should be representable by the supported Python and BigQuery types. - ibis_types_from_python = { - ibis_types.dtype(t) for t in bigframes.dtypes.RF_SUPPORTED_IO_PYTHON_TYPES - } - ibis_types_from_bigquery = { - third_party_ibis_bqtypes.BigQueryType.to_ibis(tk) - for tk in bigframes.dtypes.RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS - } - - assert ibis_types_from_python == ibis_types_from_bigquery def test_missing_input_types(): - session = resources.create_bigquery_session() - remote_function_decorator = bff.remote_function(session=session) + session = mocks.create_bigquery_session() + remote_function_decorator = bff.remote_function( + session=session, cloud_function_service_account="default" + ) def function_without_parameter_annotations(myparam) -> str: return str(myparam) @@ -88,8 +61,10 @@ def function_without_parameter_annotations(myparam) -> str: def test_missing_output_type(): - session = resources.create_bigquery_session() - remote_function_decorator = bff.remote_function(session=session) + session = mocks.create_bigquery_session() + remote_function_decorator = bff.remote_function( + session=session, cloud_function_service_account="default" + ) def function_without_return_annotation(myparam: int): return str(myparam) @@ -101,3 +76,57 @@ def function_without_return_annotation(myparam: int): match="'output_type' was not set .* missing a return type annotation", ): remote_function_decorator(function_without_return_annotation) + + +def test_deploy_remote_function(): + session = mocks.create_bigquery_session() + + def my_remote_func(x: int) -> int: + return x * 2 + + deployed = session.deploy_remote_function( + my_remote_func, cloud_function_service_account="test_sa@example.com" + ) + + # Test that the function would have been deployed somewhere. + assert deployed.bigframes_bigquery_function + + +def test_deploy_remote_function_with_name(): + session = mocks.create_bigquery_session() + + def my_remote_func(x: int) -> int: + return x * 2 + + deployed = session.deploy_remote_function( + my_remote_func, + name="my_custom_name", + cloud_function_service_account="test_sa@example.com", + ) + + # Test that the function would have been deployed somewhere. + assert "my_custom_name" in deployed.bigframes_bigquery_function + + +def test_deploy_udf(): + session = mocks.create_bigquery_session() + + def my_remote_func(x: int) -> int: + return x * 2 + + deployed = session.deploy_udf(my_remote_func) + + # Test that the function would have been deployed somewhere. + assert deployed.bigframes_bigquery_function + + +def test_deploy_udf_with_name(): + session = mocks.create_bigquery_session() + + def my_remote_func(x: int) -> int: + return x * 2 + + deployed = session.deploy_udf(my_remote_func, name="my_custom_name") + + # Test that the function would have been deployed somewhere. + assert "my_custom_name" in deployed.bigframes_bigquery_function diff --git a/tests/unit/functions/test_remote_function_utils.py b/tests/unit/functions/test_remote_function_utils.py index 0bcfee5c4e..812d65bbad 100644 --- a/tests/unit/functions/test_remote_function_utils.py +++ b/tests/unit/functions/test_remote_function_utils.py @@ -12,10 +12,397 @@ # See the License for the specific language governing permissions and # limitations under the License. +import inspect +import sys +from unittest.mock import patch + +import bigframes_vendored.constants as constants import pytest -import bigframes.dtypes -from bigframes.functions import _utils +from bigframes.functions import _utils, function_typing + + +@pytest.mark.parametrize( + ("input_location", "expected_bq_location", "expected_cf_region"), + [ + (None, "us", "us-central1"), + ("us", "us", "us-central1"), + ("eu", "eu", "europe-west1"), + ("US-east4", "us-east4", "us-east4"), + ], +) +def test_get_remote_function_locations( + input_location, expected_bq_location, expected_cf_region +): + """Tests getting remote function locations for various locations.""" + bq_location, cf_region = _utils.get_remote_function_locations(input_location) + + assert bq_location == expected_bq_location + assert cf_region == expected_cf_region + + +@pytest.mark.parametrize( + "func_hash, session_id, uniq_suffix, expected_name", + [ + ( + "hash123", + None, + None, + "bigframes-hash123", + ), + ( + "hash456", + "session789", + None, + "bigframes-session789-hash456", + ), + ( + "hash123", + None, + "suffixABC", + "bigframes-hash123-suffixABC", + ), + ( + "hash456", + "session789", + "suffixDEF", + "bigframes-session789-hash456-suffixDEF", + ), + ], +) +def test_get_cloud_function_name(func_hash, session_id, uniq_suffix, expected_name): + """Tests the construction of the cloud function name from its parts.""" + result = _utils.get_cloud_function_name(func_hash, session_id, uniq_suffix) + + assert result == expected_name + + +@pytest.mark.parametrize( + "function_hash, session_id, uniq_suffix, expected_name", + [ + ( + "hash123", + "session456", + None, + "bigframes_session456_hash123", + ), + ( + "hash789", + "sessionABC", + "suffixDEF", + "bigframes_sessionABC_hash789_suffixDEF", + ), + ], +) +def test_get_bigframes_function_name( + function_hash, session_id, uniq_suffix, expected_name +): + """Tests the construction of the BigQuery function name from its parts.""" + result = _utils.get_bigframes_function_name(function_hash, session_id, uniq_suffix) + + assert result == expected_name + + +def test_get_updated_package_requirements_no_extra_package(): + """Tests with no extra package.""" + result = _utils.get_updated_package_requirements(capture_references=False) + + assert result is None + + initial_packages = ["xgboost"] + result = _utils.get_updated_package_requirements( + initial_packages, capture_references=False + ) + + assert result == initial_packages + + +@patch("bigframes.functions._utils.numpy.__version__", "1.24.4") +@patch("bigframes.functions._utils.pyarrow.__version__", "14.0.1") +@patch("bigframes.functions._utils.pandas.__version__", "2.0.3") +@patch("bigframes.functions._utils.cloudpickle.__version__", "2.2.1") +def test_get_updated_package_requirements_is_row_processor_with_versions(): + """Tests with is_row_processor=True and specific versions.""" + expected = [ + "cloudpickle==2.2.1", + "numpy==1.24.4", + "pandas==2.0.3", + "pyarrow==14.0.1", + ] + result = _utils.get_updated_package_requirements(is_row_processor=True) + + assert result == expected + + +@patch("bigframes.functions._utils.warnings.warn") +@patch("bigframes.functions._utils.cloudpickle.__version__", "2.2.1") +def test_get_updated_package_requirements_ignore_version(mock_warn): + """ + Tests with is_row_processor=True and ignore_package_version=True. + Should add packages without versions and raise a warning. + """ + expected = ["cloudpickle==2.2.1", "numpy", "pandas", "pyarrow"] + result = _utils.get_updated_package_requirements( + is_row_processor=True, ignore_package_version=True + ) + + assert result == expected + # Verify that a warning was issued. + mock_warn.assert_called_once() + + +@patch("bigframes.functions._utils.numpy.__version__", "1.24.4") +@patch("bigframes.functions._utils.pyarrow.__version__", "14.0.1") +@patch("bigframes.functions._utils.pandas.__version__", "2.0.3") +def test_get_updated_package_requirements_capture_references_false(): + """ + Tests with capture_references=False. + Should not add cloudpickle but should add others if requested. + """ + # Case 1: Only capture_references=False. + result_1 = _utils.get_updated_package_requirements(capture_references=False) + + assert result_1 is None + + # Case 2: capture_references=False but is_row_processor=True. + expected_2 = ["numpy==1.24.4", "pandas==2.0.3", "pyarrow==14.0.1"] + result_2 = _utils.get_updated_package_requirements( + is_row_processor=True, capture_references=False + ) + + assert result_2 == expected_2 + + +@patch("bigframes.functions._utils.numpy.__version__", "1.24.4") +@patch("bigframes.functions._utils.pyarrow.__version__", "14.0.1") +@patch("bigframes.functions._utils.pandas.__version__", "2.0.3") +@patch("bigframes.functions._utils.cloudpickle.__version__", "2.2.1") +def test_get_updated_package_requirements_non_overlapping_packages(): + """Tests providing an initial list of packages that do not overlap.""" + initial_packages = ["scikit-learn==1.3.0", "xgboost"] + expected = [ + "cloudpickle==2.2.1", + "numpy==1.24.4", + "pandas==2.0.3", + "pyarrow==14.0.1", + "scikit-learn==1.3.0", + "xgboost", + ] + result = _utils.get_updated_package_requirements( + package_requirements=initial_packages, is_row_processor=True + ) + + assert result == expected + + +@patch("bigframes.functions._utils.numpy.__version__", "1.24.4") +@patch("bigframes.functions._utils.pyarrow.__version__", "14.0.1") +@patch("bigframes.functions._utils.pandas.__version__", "2.0.3") +@patch("bigframes.functions._utils.cloudpickle.__version__", "2.2.1") +def test_get_updated_package_requirements_overlapping_packages(): + """Tests that packages are not added if they already exist.""" + # The function should respect the pre-existing pandas version. + initial_packages = ["pandas==1.5.3", "numpy"] + expected = [ + "cloudpickle==2.2.1", + "numpy", + "pandas==1.5.3", + "pyarrow==14.0.1", + ] + result = _utils.get_updated_package_requirements( + package_requirements=initial_packages, is_row_processor=True + ) + + assert result == expected + + +@patch("bigframes.functions._utils.cloudpickle.__version__", "2.2.1") +def test_get_updated_package_requirements_with_existing_cloudpickle(): + """Tests that cloudpickle is not added if it already exists.""" + initial_packages = ["cloudpickle==2.0.0"] + expected = ["cloudpickle==2.0.0"] + result = _utils.get_updated_package_requirements( + package_requirements=initial_packages + ) + + assert result == expected + + +# Dynamically generate expected python versions for the test +_major = sys.version_info.major +_minor = sys.version_info.minor +_compat_version = f"python{_major}{_minor}" +_standard_version = f"python-{_major}.{_minor}" + + +@pytest.mark.parametrize( + "is_compat, expected_version", + [ + (True, _compat_version), + (False, _standard_version), + ], +) +def test_get_python_version(is_compat, expected_version): + """Tests the python version for both standard and compat modes.""" + result = _utils.get_python_version(is_compat=is_compat) + assert result == expected_version + + +def test_package_existed_helper(): + """Tests the _package_existed helper function directly.""" + reqs = ["pandas==1.0", "numpy", "scikit-learn>=1.2.0"] + + # Exact match + assert _utils._package_existed(reqs, "pandas==1.0") + # Different version + assert _utils._package_existed(reqs, "pandas==2.0") + # No version specified + assert _utils._package_existed(reqs, "numpy") + # Not in list + assert not _utils._package_existed(reqs, "xgboost") + # Empty list + assert not _utils._package_existed([], "pandas") + + +def _function_add_one(x): + return x + 1 + + +def _function_add_two(x): + return x + 2 + + +@pytest.mark.parametrize( + "func1, func2, should_be_equal, description", + [ + ( + _function_add_one, + _function_add_one, + True, + "Identical functions should have the same hash.", + ), + ( + _function_add_one, + _function_add_two, + False, + "Different functions should have different hashes.", + ), + ], +) +def test_get_hash_without_package_requirements( + func1, func2, should_be_equal, description +): + """Tests function hashes without any requirements.""" + hash1 = _utils.get_hash(func1) + hash2 = _utils.get_hash(func2) + + if should_be_equal: + assert hash1 == hash2, f"FAILED: {description}" + else: + assert hash1 != hash2, f"FAILED: {description}" + + +@pytest.mark.parametrize( + "reqs1, reqs2, should_be_equal, description", + [ + ( + None, + ["pandas>=1.0"], + False, + "Hash with or without requirements should differ from hash.", + ), + ( + ["pandas", "numpy", "scikit-learn"], + ["numpy", "scikit-learn", "pandas"], + True, + "Same requirements should produce the same hash.", + ), + ( + ["pandas==1.0"], + ["pandas==2.0"], + False, + "Different requirement versions should produce different hashes.", + ), + ], +) +def test_get_hash_with_package_requirements(reqs1, reqs2, should_be_equal, description): + """Tests how package requirements affect the final hash.""" + hash1 = _utils.get_hash(_function_add_one, package_requirements=reqs1) + hash2 = _utils.get_hash(_function_add_one, package_requirements=reqs2) + + if should_be_equal: + assert hash1 == hash2, f"FAILED: {description}" + else: + assert hash1 != hash2, f"FAILED: {description}" + + +# Helper functions for signature inspection tests +def _func_one_arg_annotated(x: int) -> int: + """A function with one annotated arg and an annotated return type.""" + return x + + +def _func_one_arg_unannotated(x): + """A function with one unannotated arg and no return type annotation.""" + return x + + +def _func_two_args_annotated(x: int, y: str): + """A function with two annotated args and no return type annotation.""" + return f"{x}{y}" + + +def _func_two_args_unannotated(x, y): + """A function with two unannotated args and no return type annotation.""" + return f"{x}{y}" + + +def test_has_conflict_input_type_too_few_inputs(): + """Tests conflict when there are fewer input types than parameters.""" + signature = inspect.signature(_func_one_arg_annotated) + assert _utils.has_conflict_input_type(signature, input_types=[]) + + +def test_has_conflict_input_type_too_many_inputs(): + """Tests conflict when there are more input types than parameters.""" + signature = inspect.signature(_func_one_arg_annotated) + assert _utils.has_conflict_input_type(signature, input_types=[int, str]) + + +def test_has_conflict_input_type_type_mismatch(): + """Tests has_conflict_input_type with a conflicting type annotation.""" + signature = inspect.signature(_func_two_args_annotated) + + # The second type (bool) conflicts with the annotation (str). + assert _utils.has_conflict_input_type(signature, input_types=[int, bool]) + + +def test_has_conflict_input_type_no_conflict_annotated(): + """Tests that a matching, annotated signature is compatible.""" + signature = inspect.signature(_func_two_args_annotated) + assert not _utils.has_conflict_input_type(signature, input_types=[int, str]) + + +def test_has_conflict_input_type_no_conflict_unannotated(): + """Tests that a signature with no annotations is always compatible.""" + signature = inspect.signature(_func_two_args_unannotated) + assert not _utils.has_conflict_input_type(signature, input_types=[int, float]) + + +def test_has_conflict_output_type_no_conflict(): + """Tests has_conflict_output_type with type annotation.""" + signature = inspect.signature(_func_one_arg_annotated) + + assert _utils.has_conflict_output_type(signature, output_type=float) + assert not _utils.has_conflict_output_type(signature, output_type=int) + + +def test_has_conflict_output_type_no_annotation(): + """Tests has_conflict_output_type without type annotation.""" + signature = inspect.signature(_func_one_arg_unannotated) + + assert not _utils.has_conflict_output_type(signature, output_type=int) + assert not _utils.has_conflict_output_type(signature, output_type=float) @pytest.mark.parametrize( @@ -54,6 +441,7 @@ ), ) def test_get_bigframes_metadata(metadata_options, metadata_string): + assert _utils.get_bigframes_metadata(**metadata_options) == metadata_string @@ -72,8 +460,9 @@ def test_get_bigframes_metadata(metadata_options, metadata_string): def test_get_bigframes_metadata_array_type_not_serializable(output_type): with pytest.raises(ValueError) as context: _utils.get_bigframes_metadata(python_output_type=output_type) + assert str(context.value) == ( - f"python_output_type {output_type} is not serializable." + f"python_output_type {output_type} is not serializable. {constants.FEEDBACK_LINK}" ) @@ -125,6 +514,7 @@ def test_get_bigframes_metadata_array_type_not_serializable(output_type): def test_get_python_output_type_from_bigframes_metadata( metadata_string, python_output_type ): + assert ( _utils.get_python_output_type_from_bigframes_metadata(metadata_string) == python_output_type @@ -132,7 +522,8 @@ def test_get_python_output_type_from_bigframes_metadata( def test_metadata_roundtrip_supported_array_types(): - for array_of in bigframes.dtypes.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES: + for array_of in function_typing.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES: ser = _utils.get_bigframes_metadata(python_output_type=list[array_of]) # type: ignore deser = _utils.get_python_output_type_from_bigframes_metadata(ser) + assert deser == list[array_of] # type: ignore diff --git a/tests/unit/ml/test_api_primitives.py b/tests/unit/ml/test_api_primitives.py index 00a51ccfe9..dd2ceff143 100644 --- a/tests/unit/ml/test_api_primitives.py +++ b/tests/unit/ml/test_api_primitives.py @@ -13,8 +13,6 @@ # limitations under the License. import pytest -import sklearn.decomposition as sklearn_decomposition # type: ignore -import sklearn.linear_model as sklearn_linear_model # type: ignore import bigframes.ml.decomposition import bigframes.ml.linear_model @@ -35,8 +33,9 @@ def test_base_estimator_repr(): assert pca_estimator.__repr__() == "PCA(n_components=7)" -@pytest.mark.skipif(sklearn_linear_model is None, reason="requires sklearn") def test_base_estimator_repr_matches_sklearn(): + sklearn_decomposition = pytest.importorskip("sklearn.decomposition") + sklearn_linear_model = pytest.importorskip("sklearn.linear_model") estimator = bigframes.ml.linear_model.LinearRegression() sklearn_estimator = sklearn_linear_model.LinearRegression() assert estimator.__repr__() == sklearn_estimator.__repr__() diff --git a/tests/unit/ml/test_compose.py b/tests/unit/ml/test_compose.py index 395296f3e4..86cbb111f4 100644 --- a/tests/unit/ml/test_compose.py +++ b/tests/unit/ml/test_compose.py @@ -15,8 +15,6 @@ from google.cloud import bigquery import pytest -import sklearn.compose as sklearn_compose # type: ignore -import sklearn.preprocessing as sklearn_preprocessing # type: ignore from bigframes.ml import compose, preprocessing from bigframes.ml.compose import ColumnTransformer, SQLScalarColumnTransformer @@ -119,6 +117,8 @@ def test_columntransformer_repr(): def test_columntransformer_repr_matches_sklearn(): + sklearn_compose = pytest.importorskip("sklearn.compose") + sklearn_preprocessing = pytest.importorskip("sklearn.preprocessing") bf_column_transformer = compose.ColumnTransformer( [ ( @@ -281,7 +281,7 @@ def test_customtransformer_compile_sql(mock_X): ] -def create_bq_model_mock(mocker, transform_columns, feature_columns=None): +def create_bq_model_mock(monkeypatch, transform_columns, feature_columns=None): properties = {"transformColumns": transform_columns} mock_bq_model = bigquery.Model("model_project.model_dataset.model_id") type(mock_bq_model)._properties = mock.PropertyMock(return_value=properties) @@ -289,18 +289,19 @@ def create_bq_model_mock(mocker, transform_columns, feature_columns=None): result = [ bigquery.standard_sql.StandardSqlField(col, None) for col in feature_columns ] - mocker.patch( - "google.cloud.bigquery.model.Model.feature_columns", - new_callable=mock.PropertyMock(return_value=result), + monkeypatch.setattr( + type(mock_bq_model), + "feature_columns", + mock.PropertyMock(return_value=result), ) return mock_bq_model @pytest.fixture -def bq_model_good(mocker): +def bq_model_good(monkeypatch): return create_bq_model_mock( - mocker, + monkeypatch, [ { "name": "ident_culmen_length_mm", @@ -337,9 +338,9 @@ def bq_model_good(mocker): @pytest.fixture -def bq_model_merge(mocker): +def bq_model_merge(monkeypatch): return create_bq_model_mock( - mocker, + monkeypatch, [ { "name": "labelencoded_county", @@ -357,9 +358,9 @@ def bq_model_merge(mocker): @pytest.fixture -def bq_model_no_merge(mocker): +def bq_model_no_merge(monkeypatch): return create_bq_model_mock( - mocker, + monkeypatch, [ { "name": "ident_culmen_length_mm", @@ -372,9 +373,9 @@ def bq_model_no_merge(mocker): @pytest.fixture -def bq_model_unknown_ML(mocker): +def bq_model_unknown_ML(monkeypatch): return create_bq_model_mock( - mocker, + monkeypatch, [ { "name": "unknownml_culmen_length_mm", @@ -391,9 +392,9 @@ def bq_model_unknown_ML(mocker): @pytest.fixture -def bq_model_flexnames(mocker): +def bq_model_flexnames(monkeypatch): return create_bq_model_mock( - mocker, + monkeypatch, [ { "name": "Flex Name culmen_length_mm", diff --git a/tests/unit/ml/test_golden_sql.py b/tests/unit/ml/test_golden_sql.py index 97d1d2d7d1..7f6843aacf 100644 --- a/tests/unit/ml/test_golden_sql.py +++ b/tests/unit/ml/test_golden_sql.py @@ -17,10 +17,10 @@ from google.cloud import bigquery import pandas as pd import pytest -import pytest_mock import bigframes -from bigframes.ml import core, linear_model +from bigframes.ml import core, decomposition, linear_model +import bigframes.ml.core import bigframes.pandas as bpd TEMP_MODEL_ID = bigquery.ModelReference.from_string( @@ -50,10 +50,11 @@ def mock_session(): @pytest.fixture -def bqml_model_factory(mocker: pytest_mock.MockerFixture): - mocker.patch( - "bigframes.ml.core.BqmlModelFactory._create_model_ref", - return_value=TEMP_MODEL_ID, +def bqml_model_factory(monkeypatch): + monkeypatch.setattr( + bigframes.ml.core.BqmlModelFactory, + "_create_model_ref", + mock.Mock(return_value=TEMP_MODEL_ID), ) bqml_model_factory = core.BqmlModelFactory() @@ -66,6 +67,7 @@ def mock_y(mock_session): mock_y._session = mock_session mock_y.columns = pd.Index(["input_column_label"]) mock_y.cache.return_value = mock_y + mock_y.copy.return_value = mock_y return mock_y @@ -79,6 +81,8 @@ def mock_X(mock_y, mock_session): ["index_column_id"], ["index_column_label"], ) + type(mock_X).sql = mock.PropertyMock(return_value="input_X_sql_property") + mock_X.reset_index(drop=True).cache().sql = "input_X_no_index_sql" mock_X.join(mock_y).sql = "input_X_y_sql" mock_X.join(mock_y).cache.return_value = mock_X.join(mock_y) mock_X.join(mock_y)._to_sql_query.return_value = ( @@ -98,6 +102,7 @@ def mock_X(mock_y, mock_session): ) mock_X.cache.return_value = mock_X + mock_X.copy.return_value = mock_X return mock_X @@ -138,9 +143,10 @@ def test_linear_regression_predict(mock_session, bqml_model, mock_X): model._bqml_model = bqml_model model.predict(mock_X) - mock_session.read_gbq.assert_called_once_with( + mock_session.read_gbq_query.assert_called_once_with( "SELECT * FROM ML.PREDICT(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql))", index_col=["index_column_id"], + allow_large_results=True, ) @@ -149,8 +155,9 @@ def test_linear_regression_score(mock_session, bqml_model, mock_X, mock_y): model._bqml_model = bqml_model model.score(mock_X, mock_y) - mock_session.read_gbq.assert_called_once_with( - "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))" + mock_session.read_gbq_query.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))", + allow_large_results=True, ) @@ -162,7 +169,7 @@ def test_logistic_regression_default_fit( model.fit(mock_X, mock_y) mock_session._start_query_ml_ddl.assert_called_once_with( - "CREATE OR REPLACE MODEL `test-project`.`_anon123`.`temp_model_id`\nOPTIONS(\n model_type='LOGISTIC_REG',\n data_split_method='NO_SPLIT',\n fit_intercept=True,\n auto_class_weights=False,\n optimize_strategy='auto_strategy',\n l2_reg=0.0,\n max_iterations=20,\n learn_rate_strategy='line_search',\n min_rel_progress=0.01,\n calculate_p_values=False,\n enable_global_explain=False,\n INPUT_LABEL_COLS=['input_column_label'])\nAS input_X_y_no_index_sql" + "CREATE OR REPLACE MODEL `test-project`.`_anon123`.`temp_model_id`\nOPTIONS(\n model_type='LOGISTIC_REG',\n data_split_method='NO_SPLIT',\n fit_intercept=True,\n auto_class_weights=False,\n optimize_strategy='auto_strategy',\n l2_reg=0.0,\n max_iterations=20,\n learn_rate_strategy='line_search',\n min_rel_progress=0.01,\n calculate_p_values=False,\n enable_global_explain=False,\n INPUT_LABEL_COLS=['input_column_label'])\nAS input_X_y_no_index_sql", ) @@ -193,9 +200,10 @@ def test_logistic_regression_predict(mock_session, bqml_model, mock_X): model._bqml_model = bqml_model model.predict(mock_X) - mock_session.read_gbq.assert_called_once_with( + mock_session.read_gbq_query.assert_called_once_with( "SELECT * FROM ML.PREDICT(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql))", index_col=["index_column_id"], + allow_large_results=True, ) @@ -204,6 +212,77 @@ def test_logistic_regression_score(mock_session, bqml_model, mock_X, mock_y): model._bqml_model = bqml_model model.score(mock_X, mock_y) - mock_session.read_gbq.assert_called_once_with( - "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))" + mock_session.read_gbq_query.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))", + allow_large_results=True, + ) + + +def test_decomposition_mf_default_fit(bqml_model_factory, mock_session, mock_X): + model = decomposition.MatrixFactorization( + num_factors=34, + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + model._bqml_model_factory = bqml_model_factory + model.fit(mock_X) + + mock_session._start_query_ml_ddl.assert_called_once_with( + "CREATE OR REPLACE MODEL `test-project`.`_anon123`.`temp_model_id`\nOPTIONS(\n model_type='matrix_factorization',\n feedback_type='explicit',\n user_col='user_id',\n item_col='item_col',\n rating_col='rating_col',\n l2_reg=9.83,\n num_factors=34)\nAS input_X_no_index_sql" + ) + + +def test_decomposition_mf_predict(mock_session, bqml_model, mock_X): + model = decomposition.MatrixFactorization( + num_factors=34, + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + model._bqml_model = bqml_model + model.predict(mock_X) + + mock_session.read_gbq_query.assert_called_once_with( + "SELECT * FROM ML.RECOMMEND(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql))", + index_col=["index_column_id"], + allow_large_results=True, + ) + + +def test_decomposition_mf_score(mock_session, bqml_model): + model = decomposition.MatrixFactorization( + num_factors=34, + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + model._bqml_model = bqml_model + model.score() + mock_session.read_gbq_query.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`)", + allow_large_results=True, + ) + + +def test_decomposition_mf_score_with_x(mock_session, bqml_model, mock_X): + model = decomposition.MatrixFactorization( + num_factors=34, + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + model._bqml_model = bqml_model + model.score(mock_X) + mock_session.read_gbq_query.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql_property))", + allow_large_results=True, ) diff --git a/tests/unit/ml/test_matrix_factorization.py b/tests/unit/ml/test_matrix_factorization.py new file mode 100644 index 0000000000..92691ba9d4 --- /dev/null +++ b/tests/unit/ml/test_matrix_factorization.py @@ -0,0 +1,182 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import pytest + +from bigframes.ml import decomposition + + +def test_decomposition_mf_model(): + model = decomposition.MatrixFactorization( + num_factors=16, + feedback_type="implicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9, + ) + assert model.num_factors == 16 + assert model.feedback_type == "implicit" + assert model.user_col == "user_id" + assert model.item_col == "item_col" + assert model.rating_col == "rating_col" + + +def test_decomposition_mf_feedback_type_explicit(): + model = decomposition.MatrixFactorization( + num_factors=16, + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + assert model.feedback_type == "explicit" + + +def test_decomposition_mf_invalid_feedback_type_raises(): + feedback_type = "explimp" + with pytest.raises( + ValueError, + match="Expected feedback_type to be `explicit` or `implicit`.", + ): + decomposition.MatrixFactorization( + # Intentionally pass in the wrong type. This will fail if the user is using + # a type checker, but we can't assume that everyone is doing so, especially + # not in notebook environments. + num_factors=16, + feedback_type=feedback_type, # type: ignore + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + + +def test_decomposition_mf_num_factors_low(): + model = decomposition.MatrixFactorization( + num_factors=0, + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + assert model.num_factors == 0 + + +def test_decomposition_mf_negative_num_factors_raises(): + num_factors = -2 + with pytest.raises( + ValueError, + match=f"Expected num_factors to be a positive integer, but got {num_factors}.", + ): + decomposition.MatrixFactorization( + num_factors=num_factors, # type: ignore + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + + +def test_decomposition_mf_invalid_num_factors_raises(): + num_factors = 0.5 + with pytest.raises( + TypeError, + match=f"Expected num_factors to be an int, but got {type(num_factors)}.", + ): + decomposition.MatrixFactorization( + num_factors=num_factors, # type: ignore + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + + +def test_decomposition_mf_invalid_user_col_raises(): + user_col = 123 + with pytest.raises( + TypeError, match=f"Expected user_col to be a str, but got {type(user_col)}." + ): + decomposition.MatrixFactorization( + num_factors=16, + feedback_type="explicit", + user_col=user_col, # type: ignore + item_col="item_col", + rating_col="rating_col", + l2_reg=9.83, + ) + + +def test_decomposition_mf_invalid_item_col_raises(): + item_col = 123 + with pytest.raises( + TypeError, match=f"Expected item_col to be STR, but got {type(item_col)}." + ): + decomposition.MatrixFactorization( + num_factors=16, + feedback_type="explicit", + user_col="user_id", + item_col=item_col, # type: ignore + rating_col="rating_col", + l2_reg=9.83, + ) + + +def test_decomposition_mf_invalid_rating_col_raises(): + rating_col = 4 + with pytest.raises( + TypeError, match=f"Expected rating_col to be a str, but got {type(rating_col)}." + ): + decomposition.MatrixFactorization( + num_factors=16, + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col=rating_col, # type: ignore + l2_reg=9.83, + ) + + +def test_decomposition_mf_l2_reg(): + model = decomposition.MatrixFactorization( + num_factors=16, + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=6.02, # type: ignore + ) + assert model.l2_reg == 6.02 + + +def test_decomposition_mf_invalid_l2_reg_raises(): + l2_reg = "6.02" + with pytest.raises( + TypeError, + match=f"Expected l2_reg to be a float or int, but got {type(l2_reg)}.", + ): + decomposition.MatrixFactorization( + num_factors=16, + feedback_type="explicit", + user_col="user_id", + item_col="item_col", + rating_col="rating_col", + l2_reg=l2_reg, # type: ignore + ) diff --git a/tests/unit/ml/test_pipeline.py b/tests/unit/ml/test_pipeline.py index ed5c621b1d..beebb9f282 100644 --- a/tests/unit/ml/test_pipeline.py +++ b/tests/unit/ml/test_pipeline.py @@ -13,10 +13,6 @@ # limitations under the License. import pytest -import sklearn.compose as sklearn_compose # type: ignore -import sklearn.linear_model as sklearn_linear_model # type: ignore -import sklearn.pipeline as sklearn_pipeline # type: ignore -import sklearn.preprocessing as sklearn_preprocessing # type: ignore from bigframes.ml import compose, forecasting, linear_model, pipeline, preprocessing @@ -57,8 +53,11 @@ def test_pipeline_repr(): ) -@pytest.mark.skipif(sklearn_pipeline is None, reason="requires sklearn") def test_pipeline_repr_matches_sklearn(): + sklearn_compose = pytest.importorskip("sklearn.compose") + sklearn_linear_model = pytest.importorskip("sklearn.linear_model") + sklearn_pipeline = pytest.importorskip("sklearn.pipeline") + sklearn_preprocessing = pytest.importorskip("sklearn.preprocessing") bf_pl = pipeline.Pipeline( [ ( diff --git a/tests/unit/ml/test_sql.py b/tests/unit/ml/test_sql.py index 5a7220fc38..d605b571f3 100644 --- a/tests/unit/ml/test_sql.py +++ b/tests/unit/ml/test_sql.py @@ -155,6 +155,33 @@ def test_polynomial_expand( assert sql == "ML.POLYNOMIAL_EXPAND(STRUCT(`col_a`, `col_b`), 2) AS `poly_exp`" +def test_ai_forecast_correct( + base_sql_generator: ml_sql.BaseSqlGenerator, + mock_df: bpd.DataFrame, +): + sql = base_sql_generator.ai_forecast( + source_sql=mock_df.sql, + options={ + "model": "TimesFM 2.0", + "data_col": "data1", + "timestamp_col": "time1", + "id_cols": ("id1", "id2"), + "horizon": 10, + "confidence_level": 0.95, + }, + ) + assert ( + sql + == """SELECT * FROM AI.FORECAST((input_X_y_sql), + model => 'TimesFM 2.0', + data_col => 'data1', + timestamp_col => 'time1', + id_cols => ['id1', 'id2'], + horizon => 10, + confidence_level => 0.95)""" + ) + + def test_create_model_correct( model_creation_sql_generator: ml_sql.ModelCreationSqlGenerator, mock_df: bpd.DataFrame, diff --git a/tests/unit/operations/test_output_schemas.py b/tests/unit/operations/test_output_schemas.py new file mode 100644 index 0000000000..c609098c98 --- /dev/null +++ b/tests/unit/operations/test_output_schemas.py @@ -0,0 +1,99 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pyarrow as pa +import pytest + +from bigframes.operations import output_schemas + + +@pytest.mark.parametrize( + ("sql", "expected"), + [ + ("INT64", pa.int64()), + (" INT64 ", pa.int64()), + ("int64", pa.int64()), + ("FLOAT64", pa.float64()), + ("STRING", pa.string()), + ("BOOL", pa.bool_()), + ("ARRAY", pa.list_(pa.int64())), + ( + "STRUCT", + pa.struct((pa.field("x", pa.int64()), pa.field("y", pa.float64()))), + ), + ( + "STRUCT< x INT64, y FLOAT64>", + pa.struct((pa.field("x", pa.int64()), pa.field("y", pa.float64()))), + ), + ( + "STRUCT", + pa.struct((pa.field("x", pa.float64()), pa.field("y", pa.int64()))), + ), + ( + "ARRAY>", + pa.list_(pa.struct((pa.field("x", pa.int64()), pa.field("y", pa.int64())))), + ), + ( + "STRUCT, x ARRAY>", + pa.struct( + ( + pa.field("x", pa.list_(pa.float64())), + pa.field( + "y", + pa.struct( + (pa.field("a", pa.bool_()), pa.field("b", pa.string())) + ), + ), + ) + ), + ), + ], +) +def test_parse_sql_to_pyarrow_dtype(sql, expected): + assert output_schemas.parse_sql_type(sql) == expected + + +@pytest.mark.parametrize( + "sql", + [ + "a INT64", + "ARRAY<>", + "ARRAY" "ARRAY" "STRUCT<>", + "DATE", + "STRUCT", + "ARRAY>", + ], +) +def test_parse_sql_to_pyarrow_dtype_invalid_input_raies_error(sql): + with pytest.raises(ValueError): + output_schemas.parse_sql_type(sql) + + +@pytest.mark.parametrize( + ("sql", "expected"), + [ + ("x INT64", (pa.field("x", pa.int64()),)), + ( + "x INT64, y FLOAT64", + (pa.field("x", pa.int64()), pa.field("y", pa.float64())), + ), + ( + "y FLOAT64, x INT64", + (pa.field("x", pa.int64()), pa.field("y", pa.float64())), + ), + ], +) +def test_parse_sql_fields(sql, expected): + assert output_schemas.parse_sql_fields(sql) == expected diff --git a/tests/unit/pandas/io/test_api.py b/tests/unit/pandas/io/test_api.py new file mode 100644 index 0000000000..dbdf427d91 --- /dev/null +++ b/tests/unit/pandas/io/test_api.py @@ -0,0 +1,129 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import mock + +import google.cloud.bigquery +import pytest + +import bigframes._config.auth +import bigframes.dataframe +import bigframes.pandas +import bigframes.pandas.io.api as bf_io_api +import bigframes.session +import bigframes.session.clients + +# _read_gbq_colab requires the polars engine. +pytest.importorskip("polars") + + +@mock.patch( + "bigframes.pandas.io.api._set_default_session_location_if_possible_deferred_query" +) +@mock.patch("bigframes.core.global_session.with_default_session") +def test_read_gbq_colab_dry_run_doesnt_call_set_location( + mock_with_default_session, mock_set_location +): + """ + Ensure that we don't bind to a location too early. If it's a dry run, the + user might not be done typing. + """ + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame) + mock_with_default_session.return_value = mock_df + + query_or_table = "SELECT {param1} AS param1" + sample_pyformat_args = {"param1": "value1"} + bf_io_api._read_gbq_colab( + query_or_table, pyformat_args=sample_pyformat_args, dry_run=True + ) + + mock_set_location.assert_not_called() + + +@mock.patch("bigframes._config.auth.pydata_google_auth.default") +@mock.patch("bigframes.core.global_session.with_default_session") +def test_read_gbq_colab_dry_run_doesnt_authenticate_multiple_times( + mock_with_default_session, mock_get_credentials, monkeypatch +): + """ + Ensure that we authenticate too often, which is an expensive operation, + performance-wise (2+ seconds). + """ + bigframes.pandas.close_session() + + mock_get_credentials.return_value = (mock.Mock(), "unit-test-project") + mock_create_bq_client = mock.Mock() + mock_bq_client = mock.create_autospec(google.cloud.bigquery.Client, instance=True) + mock_create_bq_client.return_value = mock_bq_client + mock_query_job = mock.create_autospec(google.cloud.bigquery.QueryJob, instance=True) + type(mock_query_job).schema = mock.PropertyMock(return_value=[]) + mock_query_job._properties = {} + mock_bq_client.query.return_value = mock_query_job + monkeypatch.setattr( + bigframes.session.clients.ClientsProvider, + "_create_bigquery_client", + mock_create_bq_client, + ) + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame) + mock_with_default_session.return_value = mock_df + + bigframes._config.auth._cached_credentials = None + query_or_table = "SELECT {param1} AS param1" + sample_pyformat_args = {"param1": "value1"} + bf_io_api._read_gbq_colab( + query_or_table, pyformat_args=sample_pyformat_args, dry_run=True + ) + + mock_get_credentials.assert_called() + mock_with_default_session.assert_not_called() + mock_get_credentials.reset_mock() + + # Repeat the operation so that the credentials would have have been cached. + bf_io_api._read_gbq_colab( + query_or_table, pyformat_args=sample_pyformat_args, dry_run=True + ) + mock_get_credentials.assert_not_called() + + +@mock.patch( + "bigframes.pandas.io.api._set_default_session_location_if_possible_deferred_query" +) +@mock.patch("bigframes.core.global_session.with_default_session") +def test_read_gbq_colab_calls_set_location( + mock_with_default_session, mock_set_location +): + # Configure the mock for with_default_session to return a DataFrame mock + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame) + mock_with_default_session.return_value = mock_df + + query_or_table = "SELECT {param1} AS param1" + sample_pyformat_args = {"param1": "'value1'"} + result = bf_io_api._read_gbq_colab( + query_or_table, pyformat_args=sample_pyformat_args, dry_run=False + ) + + # Make sure that we format the SQL first to prevent syntax errors. + formatted_query = "SELECT 'value1' AS param1" + mock_set_location.assert_called_once() + args, _ = mock_set_location.call_args + assert formatted_query == args[0]() + mock_with_default_session.assert_called_once() + + # Check the actual arguments passed to with_default_session + args, kwargs = mock_with_default_session.call_args + assert args[0] == bigframes.session.Session._read_gbq_colab + assert args[1] == query_or_table + assert kwargs["pyformat_args"] == sample_pyformat_args + assert not kwargs["dry_run"] + assert isinstance(result, bigframes.dataframe.DataFrame) diff --git a/tests/unit/resources.py b/tests/unit/resources.py deleted file mode 100644 index c091eac2a2..0000000000 --- a/tests/unit/resources.py +++ /dev/null @@ -1,119 +0,0 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import datetime -from typing import Optional, Sequence -import unittest.mock as mock - -import google.auth.credentials -import google.cloud.bigquery -import pytest - -import bigframes -import bigframes.clients -import bigframes.core.ordering -import bigframes.dataframe -import bigframes.session.clients -import bigframes.session.executor -import bigframes.session.metrics - -"""Utilities for creating test resources.""" - - -TEST_SCHEMA = (google.cloud.bigquery.SchemaField("col", "INTEGER"),) - - -def create_bigquery_session( - bqclient: Optional[mock.Mock] = None, - session_id: str = "abcxyz", - table_schema: Sequence[google.cloud.bigquery.SchemaField] = TEST_SCHEMA, - anonymous_dataset: Optional[google.cloud.bigquery.DatasetReference] = None, - location: str = "test-region", -) -> bigframes.Session: - credentials = mock.create_autospec( - google.auth.credentials.Credentials, instance=True - ) - - if anonymous_dataset is None: - anonymous_dataset = google.cloud.bigquery.DatasetReference( - "test-project", - "test_dataset", - ) - - if bqclient is None: - bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) - bqclient.project = "test-project" - bqclient.location = location - - # Mock the location. - table = mock.create_autospec(google.cloud.bigquery.Table, instance=True) - table._properties = {} - type(table).location = mock.PropertyMock(return_value=location) - type(table).schema = mock.PropertyMock(return_value=table_schema) - type(table).reference = mock.PropertyMock( - return_value=anonymous_dataset.table("test_table") - ) - type(table).num_rows = mock.PropertyMock(return_value=1000000000) - bqclient.get_table.return_value = table - - def query_mock(query, *args, **kwargs): - query_job = mock.create_autospec(google.cloud.bigquery.QueryJob) - type(query_job).destination = mock.PropertyMock( - return_value=anonymous_dataset.table("test_table"), - ) - type(query_job).session_info = google.cloud.bigquery.SessionInfo( - {"sessionInfo": {"sessionId": session_id}}, - ) - - if query.startswith("SELECT CURRENT_TIMESTAMP()"): - query_job.result = mock.MagicMock(return_value=[[datetime.datetime.now()]]) - else: - type(query_job).schema = mock.PropertyMock(return_value=table_schema) - - return query_job - - existing_query_and_wait = bqclient.query_and_wait - - def query_and_wait_mock(query, *args, **kwargs): - if query.startswith("SELECT CURRENT_TIMESTAMP()"): - return iter([[datetime.datetime.now()]]) - else: - return existing_query_and_wait(query, *args, **kwargs) - - bqclient.query = query_mock - bqclient.query_and_wait = query_and_wait_mock - - clients_provider = mock.create_autospec(bigframes.session.clients.ClientsProvider) - type(clients_provider).bqclient = mock.PropertyMock(return_value=bqclient) - clients_provider._credentials = credentials - - bqoptions = bigframes.BigQueryOptions(credentials=credentials, location=location) - session = bigframes.Session(context=bqoptions, clients_provider=clients_provider) - session._bq_connection_manager = mock.create_autospec( - bigframes.clients.BqConnectionManager, instance=True - ) - return session - - -def create_dataframe( - monkeypatch: pytest.MonkeyPatch, session: Optional[bigframes.Session] = None -) -> bigframes.dataframe.DataFrame: - if session is None: - session = create_bigquery_session() - - # Since this may create a ReadLocalNode, the session we explicitly pass in - # might not actually be used. Mock out the global session, too. - monkeypatch.setattr(bigframes.core.global_session, "_global_session", session) - bigframes.options.bigquery._session_started = True - return bigframes.dataframe.DataFrame({"col": []}, session=session) diff --git a/tests/unit/session/test_clients.py b/tests/unit/session/test_clients.py index 30ba2f9091..5304c99466 100644 --- a/tests/unit/session/test_clients.py +++ b/tests/unit/session/test_clients.py @@ -12,25 +12,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Optional +import os +import pathlib +import tempfile +from typing import cast, Optional import unittest.mock as mock -import google.api_core.client_info -import google.api_core.client_options -import google.api_core.exceptions -import google.api_core.gapic_v1.client_info import google.auth.credentials import google.cloud.bigquery import google.cloud.bigquery_connection_v1 import google.cloud.bigquery_storage_v1 import google.cloud.functions_v2 import google.cloud.resourcemanager_v3 +import requests.adapters import bigframes.session.clients as clients import bigframes.version -def create_clients_provider(application_name: Optional[str] = None): +def create_clients_provider(application_name: Optional[str] = None, **kwargs): credentials = mock.create_autospec(google.auth.credentials.Credentials) return clients.ClientsProvider( project="test-project", @@ -39,12 +39,15 @@ def create_clients_provider(application_name: Optional[str] = None): credentials=credentials, application_name=application_name, bq_kms_key_name="projects/my-project/locations/us/keyRings/myKeyRing/cryptoKeys/myKey", + **kwargs, ) def monkeypatch_client_constructors(monkeypatch): bqclient = mock.create_autospec(google.cloud.bigquery.Client) bqclient.return_value = bqclient + # Assume we have a new client library in the unit tests. + bqclient.default_job_creation_mode = None # type: ignore monkeypatch.setattr(google.cloud.bigquery, "Client", bqclient) bqconnectionclient = mock.create_autospec( @@ -82,6 +85,11 @@ def monkeypatch_client_constructors(monkeypatch): ) +def assert_bqclient_sets_default_job_creation_mode(provider: clients.ClientsProvider): + bqclient = provider.bqclient + assert bqclient.default_job_creation_mode == "JOB_CREATION_OPTIONAL" + + def assert_constructed_w_user_agent(mock_client: mock.Mock, expected_user_agent: str): assert ( expected_user_agent @@ -99,6 +107,51 @@ def assert_clients_w_user_agent( assert_constructed_w_user_agent(provider.resourcemanagerclient, expected_user_agent) +def assert_constructed_wo_user_agent( + mock_client: mock.Mock, not_expected_user_agent: str +): + assert ( + not_expected_user_agent + not in mock_client.call_args.kwargs["client_info"].to_user_agent() + ) + + +def assert_clients_wo_user_agent( + provider: clients.ClientsProvider, not_expected_user_agent: str +): + assert_constructed_wo_user_agent(provider.bqclient, not_expected_user_agent) + assert_constructed_wo_user_agent( + provider.bqconnectionclient, not_expected_user_agent + ) + assert_constructed_wo_user_agent( + provider.bqstoragereadclient, not_expected_user_agent + ) + assert_constructed_wo_user_agent( + provider.cloudfunctionsclient, not_expected_user_agent + ) + assert_constructed_wo_user_agent( + provider.resourcemanagerclient, not_expected_user_agent + ) + + +def test_requests_transport_adapters_pool_maxsize(monkeypatch): + monkeypatch_client_constructors(monkeypatch) + requests_transport_adapters = ( + ("https://", requests.adapters.HTTPAdapter(pool_maxsize=123)), + ("https://", requests.adapters.HTTPAdapter(pool_maxsize=123)), + ) # doctest: +SKIP + provider = create_clients_provider( + requests_transport_adapters=requests_transport_adapters + ) + + _, kwargs = cast(mock.Mock, provider.bqclient).call_args + requests_session = kwargs.get("_http") + adapter: requests.adapters.HTTPAdapter = requests_session.get_adapter( + "https://bigquery.googleapis.com/" + ) + assert adapter._pool_maxsize == 123 # type: ignore + + def test_user_agent_default(monkeypatch): monkeypatch_client_constructors(monkeypatch) provider = create_clients_provider(application_name=None) @@ -113,3 +166,107 @@ def test_user_agent_custom(monkeypatch): # We still need to include attribution to bigframes, even if there's also a # partner using the package. assert_clients_w_user_agent(provider, f"bigframes/{bigframes.version.__version__}") + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_user_agent_not_in_vscode(monkeypatch): + monkeypatch_client_constructors(monkeypatch) + provider = create_clients_provider() + assert_clients_wo_user_agent(provider, "vscode") + assert_clients_wo_user_agent(provider, "googlecloudtools.cloudcode") + + # We still need to include attribution to bigframes + assert_clients_w_user_agent(provider, f"bigframes/{bigframes.version.__version__}") + + +@mock.patch.dict(os.environ, {"VSCODE_PID": "12345"}, clear=True) +def test_user_agent_in_vscode(monkeypatch): + monkeypatch_client_constructors(monkeypatch) + provider = create_clients_provider() + assert_clients_w_user_agent(provider, "vscode") + assert_clients_wo_user_agent(provider, "googlecloudtools.cloudcode") + + # We still need to include attribution to bigframes + assert_clients_w_user_agent(provider, f"bigframes/{bigframes.version.__version__}") + + +@mock.patch.dict(os.environ, {"VSCODE_PID": "12345"}, clear=True) +def test_user_agent_in_vscode_w_extension(monkeypatch): + monkeypatch_client_constructors(monkeypatch) + + with tempfile.TemporaryDirectory() as tmpdir: + user_home = pathlib.Path(tmpdir) + extension_dir = ( + user_home / ".vscode" / "extensions" / "googlecloudtools.cloudcode-0.12" + ) + extension_config = extension_dir / "package.json" + + # originally extension config does not exist + assert not extension_config.exists() + + # simulate extension installation by creating extension config on disk + extension_dir.mkdir(parents=True) + with open(extension_config, "w") as f: + f.write("{}") + + with mock.patch("pathlib.Path.home", return_value=user_home): + provider = create_clients_provider() + assert_clients_w_user_agent(provider, "vscode") + assert_clients_w_user_agent(provider, "googlecloudtools.cloudcode") + + # We still need to include attribution to bigframes + assert_clients_w_user_agent( + provider, f"bigframes/{bigframes.version.__version__}" + ) + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_user_agent_not_in_jupyter(monkeypatch): + monkeypatch_client_constructors(monkeypatch) + provider = create_clients_provider() + assert_clients_wo_user_agent(provider, "jupyter") + assert_clients_wo_user_agent(provider, "bigquery_jupyter_plugin") + + # We still need to include attribution to bigframes + assert_clients_w_user_agent(provider, f"bigframes/{bigframes.version.__version__}") + + +@mock.patch.dict(os.environ, {"JPY_PARENT_PID": "12345"}, clear=True) +def test_user_agent_in_jupyter(monkeypatch): + monkeypatch_client_constructors(monkeypatch) + provider = create_clients_provider() + assert_clients_w_user_agent(provider, "jupyter") + assert_clients_wo_user_agent(provider, "bigquery_jupyter_plugin") + + # We still need to include attribution to bigframes + assert_clients_w_user_agent(provider, f"bigframes/{bigframes.version.__version__}") + + +@mock.patch.dict(os.environ, {"JPY_PARENT_PID": "12345"}, clear=True) +def test_user_agent_in_jupyter_with_plugin(monkeypatch): + monkeypatch_client_constructors(monkeypatch) + + def custom_import_module_side_effect(name, package=None): + if name == "bigquery_jupyter_plugin": + return mock.MagicMock() + else: + import importlib + + return importlib.import_module(name, package) + + assert isinstance( + custom_import_module_side_effect("bigquery_jupyter_plugin"), mock.MagicMock + ) + assert custom_import_module_side_effect("bigframes") is bigframes + + with mock.patch( + "importlib.import_module", side_effect=custom_import_module_side_effect + ): + provider = create_clients_provider() + assert_clients_w_user_agent(provider, "jupyter") + assert_clients_w_user_agent(provider, "bigquery_jupyter_plugin") + + # We still need to include attribution to bigframes + assert_clients_w_user_agent( + provider, f"bigframes/{bigframes.version.__version__}" + ) diff --git a/tests/unit/session/test_io_arrow.py b/tests/unit/session/test_io_arrow.py new file mode 100644 index 0000000000..d5266220d9 --- /dev/null +++ b/tests/unit/session/test_io_arrow.py @@ -0,0 +1,133 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +import pyarrow as pa +import pytest + +import bigframes.pandas as bpd +from bigframes.testing import mocks + + +@pytest.fixture(scope="module") +def session(): + # Use the mock session from bigframes.testing + return mocks.create_bigquery_session() + + +def test_read_arrow_empty_table(session): + empty_table = pa.Table.from_pydict( + { + "col_a": pa.array([], type=pa.int64()), + "col_b": pa.array([], type=pa.string()), + } + ) + df = session.read_arrow(empty_table) + assert isinstance(df, bpd.DataFrame) + assert df.shape == (0, 2) + assert list(df.columns) == ["col_a", "col_b"] + pd_df = df.to_pandas() + assert pd_df.empty + assert list(pd_df.columns) == ["col_a", "col_b"] + assert pd_df["col_a"].dtype == "Int64" + assert pd_df["col_b"].dtype == "string[pyarrow]" + + +@pytest.mark.parametrize( + "data,arrow_type,expected_bq_type_kind", + [ + ([1, 2], pa.int8(), "INTEGER"), + ([1, 2], pa.int16(), "INTEGER"), + ([1, 2], pa.int32(), "INTEGER"), + ([1, 2], pa.int64(), "INTEGER"), + ([1.0, 2.0], pa.float32(), "FLOAT"), + ([1.0, 2.0], pa.float64(), "FLOAT"), + ([True, False], pa.bool_(), "BOOLEAN"), + (["a", "b"], pa.string(), "STRING"), + (["a", "b"], pa.large_string(), "STRING"), + ([b"a", b"b"], pa.binary(), "BYTES"), + ([b"a", b"b"], pa.large_binary(), "BYTES"), + ( + [ + pa.scalar(1000, type=pa.duration("s")), + pa.scalar(2000, type=pa.duration("s")), + ], + pa.duration("s"), + "INTEGER", + ), + ([datetime.date(2023, 1, 1)], pa.date32(), "DATE"), + ( + [datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)], + pa.timestamp("s", tz="UTC"), + "TIMESTAMP", + ), + ( + [datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)], + pa.timestamp("ms", tz="UTC"), + "TIMESTAMP", + ), + ( + [datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)], + pa.timestamp("us", tz="UTC"), + "TIMESTAMP", + ), + ([datetime.time(12, 34, 56, 789000)], pa.time64("us"), "TIME"), + ], +) +def test_read_arrow_type_mappings(session, data, arrow_type, expected_bq_type_kind): + """ + Tests that various arrow types are mapped to the expected BigQuery types. + This is an indirect check via the resulting DataFrame's schema. + """ + pa_table = pa.Table.from_arrays([pa.array(data, type=arrow_type)], names=["col"]) + df = session.read_arrow(pa_table) + + bigquery_schema = df._block.expr.schema.to_bigquery() + assert len(bigquery_schema) == 2 # offsets + value + field = bigquery_schema[-1] + assert field.field_type.upper() == expected_bq_type_kind + + # Also check pandas dtype after conversion for good measure + pd_df = df.to_pandas() + assert pd_df["col"].shape == (len(data),) + + +def test_read_arrow_list_type(session): + pa_table = pa.Table.from_arrays( + [pa.array([[1, 2], [3, 4, 5]], type=pa.list_(pa.int64()))], names=["list_col"] + ) + df = session.read_arrow(pa_table) + + bigquery_schema = df._block.expr.schema.to_bigquery() + assert len(bigquery_schema) == 2 # offsets + value + field = bigquery_schema[-1] + assert field.mode.upper() == "REPEATED" + assert field.field_type.upper() == "INTEGER" + + +def test_read_arrow_struct_type(session): + struct_type = pa.struct([("a", pa.int64()), ("b", pa.string())]) + pa_table = pa.Table.from_arrays( + [pa.array([{"a": 1, "b": "x"}, {"a": 2, "b": "y"}], type=struct_type)], + names=["struct_col"], + ) + df = session.read_arrow(pa_table) + + bigquery_schema = df._block.expr.schema.to_bigquery() + assert len(bigquery_schema) == 2 # offsets + value + field = bigquery_schema[-1] + assert field.field_type.upper() == "RECORD" + assert field.fields[0].name == "a" + assert field.fields[1].name == "b" diff --git a/tests/unit/session/test_io_bigquery.py b/tests/unit/session/test_io_bigquery.py index fa05fffcb2..4349c1b6ee 100644 --- a/tests/unit/session/test_io_bigquery.py +++ b/tests/unit/session/test_io_bigquery.py @@ -14,23 +14,28 @@ import datetime import re -from typing import Iterable +from typing import Iterable, Optional +from unittest import mock import google.cloud.bigquery as bigquery +import google.cloud.bigquery.job +import google.cloud.bigquery.table import pytest import bigframes from bigframes.core import log_adapter +import bigframes.core.events import bigframes.pandas as bpd +import bigframes.session._io.bigquery import bigframes.session._io.bigquery as io_bq -from tests.unit import resources +from bigframes.testing import mocks @pytest.fixture(scope="function") -def mock_bq_client(mocker): - mock_client = mocker.Mock(spec=bigquery.Client) - mock_query_job = mocker.Mock(spec=bigquery.QueryJob) - mock_row_iterator = mocker.Mock(spec=bigquery.table.RowIterator) +def mock_bq_client(): + mock_client = mock.create_autospec(bigquery.Client) + mock_query_job = mock.create_autospec(bigquery.QueryJob) + mock_row_iterator = mock.create_autospec(google.cloud.bigquery.table.RowIterator) mock_query_job.result.return_value = mock_row_iterator @@ -96,41 +101,36 @@ def test_create_job_configs_labels_log_adaptor_call_method_under_length_limit(): cur_labels = { "source": "bigquery-dataframes-temp", } - df = bpd.DataFrame( - {"col1": [1, 2], "col2": [3, 4]}, session=resources.create_bigquery_session() - ) - # Test running two methods - df.head() - df.max() - df.columns - api_methods = log_adapter._api_methods + api_methods = [ + "dataframe-columns", + "dataframe-max", + "dataframe-head", + "dataframe-__init__", + ] labels = io_bq.create_job_configs_labels( job_configs_labels=cur_labels, api_methods=api_methods ) - expected_dict = { + expected_labels = { "source": "bigquery-dataframes-temp", "bigframes-api": "dataframe-columns", "recent-bigframes-api-0": "dataframe-max", "recent-bigframes-api-1": "dataframe-head", "recent-bigframes-api-2": "dataframe-__init__", } - assert labels == expected_dict + # Asserts that all items in expected_labels are present in labels + assert labels.items() >= expected_labels.items() def test_create_job_configs_labels_length_limit_met_and_labels_is_none(): log_adapter.get_and_reset_api_methods() - df = bpd.DataFrame( - {"col1": [1, 2], "col2": [3, 4]}, session=resources.create_bigquery_session() - ) # Test running methods more than the labels' length limit - for i in range(100): - df.head() - api_methods = log_adapter._api_methods + api_methods = list(["dataframe-head"] * 100) - labels = io_bq.create_job_configs_labels( - job_configs_labels=None, api_methods=api_methods - ) + with bpd.option_context("compute.extra_query_labels", {}): + labels = io_bq.create_job_configs_labels( + job_configs_labels=None, api_methods=api_methods + ) assert labels is not None assert len(labels) == log_adapter.MAX_LABELS_COUNT assert "dataframe-head" in labels.values() @@ -147,19 +147,15 @@ def test_create_job_configs_labels_length_limit_met(): value = f"test{i}" cur_labels[key] = value # If cur_labels length is 62, we can only add one label from api_methods - df = bpd.DataFrame( - {"col1": [1, 2], "col2": [3, 4]}, session=resources.create_bigquery_session() - ) # Test running two methods - df.head() - df.max() - api_methods = log_adapter._api_methods + api_methods = ["dataframe-max", "dataframe-head"] + + with bpd.option_context("compute.extra_query_labels", {}): + labels = io_bq.create_job_configs_labels( + job_configs_labels=cur_labels, api_methods=api_methods + ) - labels = io_bq.create_job_configs_labels( - job_configs_labels=cur_labels, api_methods=api_methods - ) assert labels is not None - assert len(labels) == 56 assert "dataframe-max" in labels.values() assert "dataframe-head" not in labels.values() assert "bigframes-api" in labels.keys() @@ -178,10 +174,10 @@ def test_add_and_trim_labels_length_limit_met(): cur_labels[key] = value df = bpd.DataFrame( - {"col1": [1, 2], "col2": [3, 4]}, session=resources.create_bigquery_session() + {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() ) - job_config = bigquery.job.QueryJobConfig() + job_config = google.cloud.bigquery.job.QueryJobConfig() job_config.labels = cur_labels df.max() @@ -198,11 +194,11 @@ def test_add_and_trim_labels_length_limit_met(): @pytest.mark.parametrize( - ("max_results", "timeout", "api_name"), - [(None, None, None), (100, 30.0, "test_api")], + ("timeout", "api_name"), + [(None, None), (30.0, "test_api")], ) def test_start_query_with_client_labels_length_limit_met( - mock_bq_client, max_results, timeout, api_name + mock_bq_client: bigquery.Client, timeout: Optional[float], api_name ): sql = "select * from abc" cur_labels = { @@ -215,10 +211,10 @@ def test_start_query_with_client_labels_length_limit_met( cur_labels[key] = value df = bpd.DataFrame( - {"col1": [1, 2], "col2": [3, 4]}, session=resources.create_bigquery_session() + {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() ) - job_config = bigquery.job.QueryJobConfig() + job_config = google.cloud.bigquery.job.QueryJobConfig() job_config.labels = cur_labels df.max() @@ -228,10 +224,13 @@ def test_start_query_with_client_labels_length_limit_met( io_bq.start_query_with_client( mock_bq_client, sql, - job_config, - max_results=max_results, + job_config=job_config, + location=None, + project=None, timeout=timeout, - api_name=api_name, + metrics=None, + query_with_job=True, + publisher=bigframes.core.events.Publisher(), ) assert job_config.labels is not None @@ -248,7 +247,7 @@ def test_create_temp_table_default_expiration(): 2023, 11, 2, 13, 44, 55, 678901, datetime.timezone.utc ) - session = resources.create_bigquery_session() + session = mocks.create_bigquery_session() table_ref = bigquery.TableReference.from_string( "test-project.test_dataset.bqdf_new_random_table" ) diff --git a/tests/unit/session/test_io_pandas.py b/tests/unit/session/test_io_pandas.py index 2fa07aed35..224f343c7e 100644 --- a/tests/unit/session/test_io_pandas.py +++ b/tests/unit/session/test_io_pandas.py @@ -29,8 +29,7 @@ import bigframes.features import bigframes.pandas import bigframes.session._io.pandas - -from .. import resources +from bigframes.testing import mocks _LIST_OF_SCALARS = [ [1, 2, 3], @@ -496,7 +495,7 @@ def test_arrow_to_pandas_wrong_size_dtypes( def test_read_pandas_with_bigframes_dataframe(): - session = resources.create_bigquery_session() + session = mocks.create_bigquery_session() df = mock.create_autospec(bigframes.pandas.DataFrame, instance=True) with pytest.raises( diff --git a/tests/unit/session/test_local_scan_executor.py b/tests/unit/session/test_local_scan_executor.py new file mode 100644 index 0000000000..fc59253153 --- /dev/null +++ b/tests/unit/session/test_local_scan_executor.py @@ -0,0 +1,101 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import pyarrow +import pytest + +from bigframes.core import identifiers, local_data, nodes +from bigframes.session import local_scan_executor +from bigframes.testing import mocks + + +@pytest.fixture +def object_under_test(): + return local_scan_executor.LocalScanExecutor() + + +def create_read_local_node(arrow_table: pyarrow.Table): + session = mocks.create_bigquery_session() + local_data_source = local_data.ManagedArrowTable.from_pyarrow(arrow_table) + return nodes.ReadLocalNode( + local_data_source=local_data_source, + session=session, + scan_list=nodes.ScanList( + items=tuple( + nodes.ScanItem( + id=identifiers.ColumnId(column_name), + source_id=column_name, + ) + for column_name in arrow_table.column_names + ), + ), + ) + + +@pytest.mark.parametrize( + ("start", "stop", "expected_rows"), + ( + # No-op slices. + (None, None, 10), + (0, None, 10), + (None, 10, 10), + # Slices equivalent to limits. + (None, 7, 7), + (0, 3, 3), + ), +) +def test_local_scan_executor_with_slice(start, stop, expected_rows, object_under_test): + pyarrow_table = pyarrow.Table.from_pydict( + { + "rowindex": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "letters": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], + } + ) + assert pyarrow_table.num_rows == 10 + + local_node = create_read_local_node(pyarrow_table) + plan = nodes.SliceNode( + child=local_node, + start=start, + stop=stop, + ) + + result = object_under_test.execute(plan, ordered=True) + result_table = pyarrow.Table.from_batches(result.batches().arrow_batches) + assert result_table.num_rows == expected_rows + + +@pytest.mark.parametrize( + ("start", "stop", "step"), + ( + (-1, None, 1), + (None, -1, 1), + (None, None, 2), + (None, None, -1), + (4, None, 6), + (1, 9, 8), + ), +) +def test_local_scan_executor_with_slice_unsupported_inputs( + start, stop, step, object_under_test +): + local_node = create_read_local_node(pyarrow.Table.from_pydict({"col": [1, 2, 3]})) + plan = nodes.SliceNode( + child=local_node, + start=start, + stop=stop, + step=step, + ) + assert object_under_test.execute(plan, ordered=True) is None diff --git a/tests/unit/session/test_metrics.py b/tests/unit/session/test_metrics.py new file mode 100644 index 0000000000..7c2f01c5b9 --- /dev/null +++ b/tests/unit/session/test_metrics.py @@ -0,0 +1,247 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import os +import unittest.mock + +import google.cloud.bigquery as bigquery +import pytest + +import bigframes.session.metrics as metrics + +NOW = datetime.datetime.now(datetime.timezone.utc) + + +def test_count_job_stats_with_row_iterator(): + row_iterator = unittest.mock.create_autospec( + bigquery.table.RowIterator, instance=True + ) + row_iterator.total_bytes_processed = 1024 + row_iterator.query = "SELECT * FROM table" + row_iterator.slot_millis = 1234 + execution_metrics = metrics.ExecutionMetrics() + execution_metrics.count_job_stats(row_iterator=row_iterator) + + assert execution_metrics.execution_count == 1 + assert execution_metrics.bytes_processed == 1024 + assert execution_metrics.query_char_count == 19 + assert execution_metrics.slot_millis == 1234 + + +def test_count_job_stats_with_row_iterator_missing_stats(): + row_iterator = unittest.mock.create_autospec( + bigquery.table.RowIterator, instance=True + ) + # Simulate properties not being present on the object + del row_iterator.total_bytes_processed + del row_iterator.query + del row_iterator.slot_millis + execution_metrics = metrics.ExecutionMetrics() + execution_metrics.count_job_stats(row_iterator=row_iterator) + + assert execution_metrics.execution_count == 1 + assert execution_metrics.bytes_processed == 0 + assert execution_metrics.query_char_count == 0 + assert execution_metrics.slot_millis == 0 + + +def test_count_job_stats_with_row_iterator_none_stats(): + row_iterator = unittest.mock.create_autospec( + bigquery.table.RowIterator, instance=True + ) + row_iterator.total_bytes_processed = None + row_iterator.query = None + row_iterator.slot_millis = None + execution_metrics = metrics.ExecutionMetrics() + execution_metrics.count_job_stats(row_iterator=row_iterator) + + assert execution_metrics.execution_count == 1 + assert execution_metrics.bytes_processed == 0 + assert execution_metrics.query_char_count == 0 + assert execution_metrics.slot_millis == 0 + + +def test_count_job_stats_with_dry_run(): + query_job = unittest.mock.create_autospec(bigquery.QueryJob, instance=True) + query_job.configuration.dry_run = True + query_job.query = "SELECT * FROM table" + execution_metrics = metrics.ExecutionMetrics() + execution_metrics.count_job_stats(query_job=query_job) + + # Dry run jobs shouldn't count as "executed" + assert execution_metrics.execution_count == 0 + assert execution_metrics.bytes_processed == 0 + assert execution_metrics.query_char_count == 0 + assert execution_metrics.slot_millis == 0 + + +def test_count_job_stats_with_valid_job(): + query_job = unittest.mock.create_autospec(bigquery.QueryJob, instance=True) + query_job.configuration.dry_run = False + query_job.query = "SELECT * FROM table" + query_job.total_bytes_processed = 2048 + query_job.slot_millis = 5678 + query_job.created = NOW + query_job.ended = NOW + datetime.timedelta(seconds=2) + execution_metrics = metrics.ExecutionMetrics() + execution_metrics.count_job_stats(query_job=query_job) + + assert execution_metrics.execution_count == 1 + assert execution_metrics.bytes_processed == 2048 + assert execution_metrics.query_char_count == 19 + assert execution_metrics.slot_millis == 5678 + assert execution_metrics.execution_secs == pytest.approx(2.0) + + +def test_count_job_stats_with_cached_job(): + query_job = unittest.mock.create_autospec(bigquery.QueryJob, instance=True) + query_job.configuration.dry_run = False + query_job.query = "SELECT * FROM table" + # Cache hit jobs don't have total_bytes_processed or slot_millis + query_job.total_bytes_processed = None + query_job.slot_millis = None + query_job.created = NOW + query_job.ended = NOW + datetime.timedelta(seconds=1) + execution_metrics = metrics.ExecutionMetrics() + execution_metrics.count_job_stats(query_job=query_job) + + assert execution_metrics.execution_count == 1 + assert execution_metrics.bytes_processed == 0 + assert execution_metrics.query_char_count == 19 + assert execution_metrics.slot_millis == 0 + assert execution_metrics.execution_secs == pytest.approx(1.0) + + +def test_count_job_stats_with_unsupported_job(): + query_job = unittest.mock.create_autospec(bigquery.QueryJob, instance=True) + query_job.configuration.dry_run = False + query_job.query = "SELECT * FROM table" + # Some jobs, such as scripts, don't have these properties. + query_job.total_bytes_processed = None + query_job.slot_millis = None + query_job.created = None + query_job.ended = None + execution_metrics = metrics.ExecutionMetrics() + execution_metrics.count_job_stats(query_job=query_job) + + # Don't count jobs if we can't get performance stats. + assert execution_metrics.execution_count == 0 + assert execution_metrics.bytes_processed == 0 + assert execution_metrics.query_char_count == 0 + assert execution_metrics.slot_millis == 0 + assert execution_metrics.execution_secs == pytest.approx(0.0) + + +def test_get_performance_stats_with_valid_job(): + query_job = unittest.mock.create_autospec(bigquery.QueryJob, instance=True) + query_job.configuration.dry_run = False + query_job.query = "SELECT * FROM table" + query_job.total_bytes_processed = 2048 + query_job.slot_millis = 5678 + query_job.created = NOW + query_job.ended = NOW + datetime.timedelta(seconds=2) + stats = metrics.get_performance_stats(query_job) + assert stats is not None + query_char_count, bytes_processed, slot_millis, exec_seconds = stats + assert query_char_count == 19 + assert bytes_processed == 2048 + assert slot_millis == 5678 + assert exec_seconds == pytest.approx(2.0) + + +def test_get_performance_stats_with_dry_run(): + query_job = unittest.mock.create_autospec(bigquery.QueryJob, instance=True) + query_job.configuration.dry_run = True + stats = metrics.get_performance_stats(query_job) + assert stats is None + + +def test_get_performance_stats_with_missing_timestamps(): + query_job = unittest.mock.create_autospec(bigquery.QueryJob, instance=True) + query_job.configuration.dry_run = False + query_job.created = None + query_job.ended = NOW + stats = metrics.get_performance_stats(query_job) + assert stats is None + + query_job.created = NOW + query_job.ended = None + stats = metrics.get_performance_stats(query_job) + assert stats is None + + +def test_get_performance_stats_with_mocked_types(): + query_job = unittest.mock.create_autospec(bigquery.QueryJob, instance=True) + query_job.configuration.dry_run = False + query_job.created = NOW + query_job.ended = NOW + query_job.total_bytes_processed = unittest.mock.Mock() + query_job.slot_millis = 123 + stats = metrics.get_performance_stats(query_job) + assert stats is None + + query_job.total_bytes_processed = 123 + query_job.slot_millis = unittest.mock.Mock() + stats = metrics.get_performance_stats(query_job) + assert stats is None + + +@pytest.fixture +def mock_environ(monkeypatch): + """Fixture to mock os.environ.""" + monkeypatch.setenv(metrics.LOGGING_NAME_ENV_VAR, "my_test_case") + + +def test_write_stats_to_disk_writes_files(tmp_path, mock_environ): + os.chdir(tmp_path) + test_name = os.environ[metrics.LOGGING_NAME_ENV_VAR] + metrics.write_stats_to_disk( + query_char_count=100, + bytes_processed=200, + slot_millis=300, + exec_seconds=1.23, + ) + + slot_file = tmp_path / (test_name + ".slotmillis") + assert slot_file.exists() + with open(slot_file) as f: + assert f.read() == "300\n" + + exec_time_file = tmp_path / (test_name + ".bq_exec_time_seconds") + assert exec_time_file.exists() + with open(exec_time_file) as f: + assert f.read() == "1.23\n" + + query_char_count_file = tmp_path / (test_name + ".query_char_count") + assert query_char_count_file.exists() + with open(query_char_count_file) as f: + assert f.read() == "100\n" + + bytes_file = tmp_path / (test_name + ".bytesprocessed") + assert bytes_file.exists() + with open(bytes_file) as f: + assert f.read() == "200\n" + + +def test_write_stats_to_disk_no_env_var(tmp_path, monkeypatch): + monkeypatch.delenv(metrics.LOGGING_NAME_ENV_VAR, raising=False) + os.chdir(tmp_path) + metrics.write_stats_to_disk( + query_char_count=100, + bytes_processed=200, + slot_millis=300, + exec_seconds=1.23, + ) + assert len(list(tmp_path.iterdir())) == 0 diff --git a/tests/unit/session/test_read_gbq_colab.py b/tests/unit/session/test_read_gbq_colab.py new file mode 100644 index 0000000000..cc0508b75a --- /dev/null +++ b/tests/unit/session/test_read_gbq_colab.py @@ -0,0 +1,128 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for read_gbq_colab helper functions.""" + +import itertools +import textwrap +from unittest import mock + +from google.cloud import bigquery +import numpy +import pandas +import pytest + +from bigframes.testing import mocks + + +def test_read_gbq_colab_includes_label(): + """Make sure we can tell direct colab usage apart from regular read_gbq usage.""" + bqclient = mock.create_autospec(bigquery.Client, instance=True) + bqclient.project = "proj" + session = mocks.create_bigquery_session(bqclient=bqclient) + _ = session._read_gbq_colab("SELECT 'read-gbq-colab-test'") + + label_values = [] + for kall in itertools.chain( + bqclient.query_and_wait.call_args_list, + bqclient._query_and_wait_bigframes.call_args_list, + bqclient.query.call_args_list, + ): + job_config = kall.kwargs.get("job_config") + if job_config is None: + continue + label_values.extend(job_config.labels.values()) + + assert "session-read_gbq_colab" in label_values + + +@pytest.mark.parametrize("dry_run", [True, False]) +def test_read_gbq_colab_includes_formatted_values_in_dry_run(monkeypatch, dry_run): + bqclient = mock.create_autospec(bigquery.Client, instance=True) + bqclient.project = "proj" + session = mocks.create_bigquery_session(bqclient=bqclient) + bf_df = mocks.create_dataframe(monkeypatch, session=session) + session._create_temp_table = mock.Mock( # type: ignore + return_value=bigquery.TableReference.from_string("proj.dset.temp_table") + ) + session._create_temp_view = mock.Mock( # type: ignore + return_value=bigquery.TableReference.from_string("proj.dset.temp_view") + ) + + # To avoid trouble with get_table() calls getting out of sync with mock + # "uploaded" data, make sure this is small enough to inline in the SQL as a + # view. + pd_df = pandas.DataFrame({"rowindex": numpy.arange(3), "value": numpy.arange(3)}) + + pyformat_args = { + "some_integer": 123, + "some_string": "some_column", + "bf_df": bf_df, + "pd_df": pd_df, + # This is not a supported type, but ignored if not referenced. + "some_object": object(), + } + + _ = session._read_gbq_colab( + textwrap.dedent( + """ + SELECT {some_integer} as some_integer, + {some_string} as some_string, + '{{escaped}}' as escaped + FROM {bf_df} AS bf_df + FULL OUTER JOIN {pd_df} AS pd_df + ON bf_df.rowindex = pd_df.rowindex + """ + ), + pyformat_args=pyformat_args, + dry_run=dry_run, + ) + expected = textwrap.dedent( + f""" + SELECT 123 as some_integer, + some_column as some_string, + '{{escaped}}' as escaped + FROM `proj`.`dset`.`temp_{"table" if dry_run else "view"}` AS bf_df + FULL OUTER JOIN `proj`.`dset`.`temp_{"table" if dry_run else "view"}` AS pd_df + ON bf_df.rowindex = pd_df.rowindex + """ + ) + + # This should be the most recent query. + query = session._queries[-1] # type: ignore + config = session._job_configs[-1] # type: ignore + + if dry_run: + assert config.dry_run + else: + # Allow for any "False-y" value. + assert not config.dry_run + + assert query.strip() == expected.strip() + + +def test_read_gbq_colab_doesnt_set_destination_table(): + """For best performance, we don't try to workaround the 10 GB query results limitation.""" + session = mocks.create_bigquery_session() + + _ = session._read_gbq_colab("SELECT 'my-test-query';") + queries = session._queries # type: ignore + configs = session._job_configs # type: ignore + + for query, config in zip(queries, configs): + if query == "SELECT 'my-test-query';" and not config.dry_run: + break + + assert query == "SELECT 'my-test-query';" + assert config.destination is None diff --git a/tests/unit/session/test_read_gbq_query.py b/tests/unit/session/test_read_gbq_query.py new file mode 100644 index 0000000000..d078c64af7 --- /dev/null +++ b/tests/unit/session/test_read_gbq_query.py @@ -0,0 +1,38 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for read_gbq_query functions.""" + +from bigframes.testing import mocks + + +def test_read_gbq_query_sets_destination_table(): + """Workaround the 10 GB query results limitation by setting a destination table. + + See internal issue b/303057336. + """ + # Use partial ordering mode to skip column uniqueness checks. + session = mocks.create_bigquery_session(ordering_mode="partial") + + _ = session.read_gbq_query("SELECT 'my-test-query';", allow_large_results=True) + queries = session._queries # type: ignore + configs = session._job_configs # type: ignore + + for query, config in zip(queries, configs): + if query == "SELECT 'my-test-query';" and not config.dry_run: + break + + assert query == "SELECT 'my-test-query';" + assert config.destination is not None + session.close() diff --git a/tests/unit/session/test_read_gbq_table.py b/tests/unit/session/test_read_gbq_table.py index 8f01820fd3..ce9b587d6b 100644 --- a/tests/unit/session/test_read_gbq_table.py +++ b/tests/unit/session/test_read_gbq_table.py @@ -15,23 +15,24 @@ """Unit tests for read_gbq_table helper functions.""" import unittest.mock as mock +import warnings import google.cloud.bigquery import pytest +import bigframes.enums +import bigframes.exceptions import bigframes.session._io.bigquery.read_gbq_table as bf_read_gbq_table - -from .. import resources +from bigframes.testing import mocks @pytest.mark.parametrize( - ("index_cols", "primary_keys", "values_distinct", "expected"), + ("index_cols", "primary_keys", "expected"), ( - (["col1", "col2"], ["col1", "col2", "col3"], False, ("col1", "col2", "col3")), + (["col1", "col2"], ["col1", "col2", "col3"], ("col1", "col2", "col3")), ( ["col1", "col2", "col3"], ["col1", "col2", "col3"], - True, ("col1", "col2", "col3"), ), ( @@ -40,15 +41,14 @@ "col3", "col2", ], - True, ("col2", "col3"), ), - (["col1", "col2"], [], False, ()), - ([], ["col1", "col2", "col3"], False, ("col1", "col2", "col3")), - ([], [], False, ()), + (["col1", "col2"], [], ()), + ([], ["col1", "col2", "col3"], ("col1", "col2", "col3")), + ([], [], ()), ), ) -def test_infer_unique_columns(index_cols, primary_keys, values_distinct, expected): +def test_infer_unique_columns(index_cols, primary_keys, expected): """If a primary key is set on the table, we use that as the index column by default, no error should be raised in this case. @@ -80,18 +80,109 @@ def test_infer_unique_columns(index_cols, primary_keys, values_distinct, expecte "columns": primary_keys, }, } + + result = bf_read_gbq_table.infer_unique_columns(table, index_cols) + + assert result == expected + + +@pytest.mark.parametrize( + ("index_cols", "values_distinct", "expected"), + ( + ( + ["col1", "col2", "col3"], + True, + ("col1", "col2", "col3"), + ), + ( + ["col2", "col3", "col1"], + True, + ("col2", "col3", "col1"), + ), + (["col1", "col2"], False, ()), + ([], False, ()), + ), +) +def test_check_if_index_columns_are_unique(index_cols, values_distinct, expected): + table = google.cloud.bigquery.Table.from_api_repr( + { + "tableReference": { + "projectId": "my-project", + "datasetId": "my_dataset", + "tableId": "my_table", + }, + "clustering": { + "fields": ["col1", "col2"], + }, + }, + ) + table.schema = ( + google.cloud.bigquery.SchemaField("col1", "INT64"), + google.cloud.bigquery.SchemaField("col2", "INT64"), + google.cloud.bigquery.SchemaField("col3", "INT64"), + google.cloud.bigquery.SchemaField("col4", "INT64"), + ) + bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) bqclient.project = "test-project" - bqclient.get_table.return_value = table + session = mocks.create_bigquery_session( + bqclient=bqclient, table_schema=table.schema + ) - bqclient.query_and_wait.return_value = ( + # Mock bqclient _after_ creating session to override its mocks. + bqclient.get_table.return_value = table + bqclient._query_and_wait_bigframes.side_effect = None + bqclient._query_and_wait_bigframes.return_value = ( {"total_count": 3, "distinct_count": 3 if values_distinct else 2}, ) - session = resources.create_bigquery_session( - bqclient=bqclient, table_schema=table.schema - ) + table._properties["location"] = session._location - result = bf_read_gbq_table.infer_unique_columns(bqclient, table, index_cols, "") + result = bf_read_gbq_table.check_if_index_columns_are_unique( + bqclient=bqclient, + table=table, + index_cols=index_cols, + publisher=session._publisher, + ) assert result == expected + + +def test_get_index_cols_warns_if_clustered_but_sequential_index(): + table = google.cloud.bigquery.Table.from_api_repr( + { + "tableReference": { + "projectId": "my-project", + "datasetId": "my_dataset", + "tableId": "my_table", + }, + "clustering": { + "fields": ["col1", "col2"], + }, + }, + ) + table.schema = ( + google.cloud.bigquery.SchemaField("col1", "INT64"), + google.cloud.bigquery.SchemaField("col2", "INT64"), + google.cloud.bigquery.SchemaField("col3", "INT64"), + google.cloud.bigquery.SchemaField("col4", "INT64"), + ) + + with pytest.warns(bigframes.exceptions.DefaultIndexWarning, match="is clustered"): + bf_read_gbq_table.get_index_cols( + table, + index_col=(), + default_index_type=bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64, + ) + + # Ensure that we don't raise if using a NULL index by default, such as in + # partial ordering mode. See: internal issue b/356872356. + with warnings.catch_warnings(): + warnings.simplefilter( + "error", category=bigframes.exceptions.DefaultIndexWarning + ) + bf_read_gbq_table.get_index_cols( + table, + index_col=(), + default_index_type=bigframes.enums.DefaultIndexKind.NULL, + ) diff --git a/tests/unit/session/test_session.py b/tests/unit/session/test_session.py index 13531acbea..fe73643b0c 100644 --- a/tests/unit/session/test_session.py +++ b/tests/unit/session/test_session.py @@ -21,14 +21,14 @@ import google.api_core.exceptions import google.cloud.bigquery -import google.cloud.bigquery.table +import pandas as pd import pytest import bigframes +from bigframes import version import bigframes.enums import bigframes.exceptions - -from .. import resources +from bigframes.testing import mocks TABLE_REFERENCE = { "projectId": "my-project", @@ -108,21 +108,6 @@ @pytest.mark.parametrize( ("kwargs", "match"), [ - pytest.param( - {"engine": "bigquery", "names": []}, - "BigQuery engine does not support these arguments", - id="with_names", - ), - pytest.param( - {"engine": "bigquery", "dtype": {}}, - "BigQuery engine does not support these arguments", - id="with_dtype", - ), - pytest.param( - {"engine": "bigquery", "index_col": 5}, - "BigQuery engine only supports a single column name for `index_col`.", - id="with_index_col_not_str", - ), pytest.param( {"engine": "bigquery", "usecols": [1, 2]}, "BigQuery engine only supports an iterable of strings for `usecols`.", @@ -135,8 +120,8 @@ ), ], ) -def test_read_csv_bq_engine_throws_not_implemented_error(kwargs, match): - session = resources.create_bigquery_session() +def test_read_csv_w_bq_engine_raises_error(kwargs, match): + session = mocks.create_bigquery_session() with pytest.raises(NotImplementedError, match=match): session.read_csv("", **kwargs) @@ -148,10 +133,11 @@ def test_read_csv_bq_engine_throws_not_implemented_error(kwargs, match): ("c",), ("python",), ("pyarrow",), + ("python-fwf",), ), ) -def test_read_csv_pandas_engines_index_col_sequential_int64_not_supported(engine): - session = resources.create_bigquery_session() +def test_read_csv_w_pandas_engines_raises_error_for_sequential_int64_index_col(engine): + session = mocks.create_bigquery_session() with pytest.raises(NotImplementedError, match="index_col"): session.read_csv( @@ -161,6 +147,22 @@ def test_read_csv_pandas_engines_index_col_sequential_int64_not_supported(engine ) +@pytest.mark.parametrize( + ("kwargs"), + [ + pytest.param({"chunksize": 5}, id="with_chunksize"), + pytest.param({"iterator": True}, id="with_iterator"), + ], +) +def test_read_csv_w_pandas_engines_raises_error_for_unsupported_args(kwargs): + session = mocks.create_bigquery_session() + with pytest.raises( + NotImplementedError, + match="'chunksize' and 'iterator' arguments are not supported.", + ): + session.read_csv("path/to/csv.csv", **kwargs) + + @pytest.mark.parametrize( ("engine", "write_engine"), ( @@ -176,7 +178,7 @@ def test_read_csv_pandas_engines_index_col_sequential_int64_not_supported(engine ), ) def test_read_csv_with_incompatible_write_engine(engine, write_engine): - session = resources.create_bigquery_session() + session = mocks.create_bigquery_session() with pytest.raises( NotImplementedError, @@ -191,16 +193,44 @@ def test_read_csv_with_incompatible_write_engine(engine, write_engine): ) +@pytest.mark.parametrize( + ("names", "error_message"), + ( + pytest.param("abc", "Names should be an ordered collection."), + pytest.param({"a", "b", "c"}, "Names should be an ordered collection."), + pytest.param(["a", "a"], "Duplicated names are not allowed."), + ), +) +def test_read_csv_w_bigquery_engine_raises_error_for_invalid_names( + names, error_message +): + session = mocks.create_bigquery_session() + + with pytest.raises(ValueError, match=error_message): + session.read_csv("path/to/csv.csv", engine="bigquery", names=names) + + +def test_read_csv_w_bigquery_engine_raises_error_for_invalid_dtypes(): + session = mocks.create_bigquery_session() + + with pytest.raises(ValueError, match="dtype should be a dict-like object."): + session.read_csv( + "path/to/csv.csv", + engine="bigquery", + dtype=["a", "b", "c"], # type: ignore[arg-type] + ) + + @pytest.mark.parametrize("missing_parts_table_id", [(""), ("table")]) def test_read_gbq_missing_parts(missing_parts_table_id): - session = resources.create_bigquery_session() + session = mocks.create_bigquery_session() with pytest.raises(ValueError): session.read_gbq(missing_parts_table_id) def test_read_gbq_cached_table(): - session = resources.create_bigquery_session() + session = mocks.create_bigquery_session() table_ref = google.cloud.bigquery.TableReference( google.cloud.bigquery.DatasetReference("my-project", "my_dataset"), "my_table", @@ -210,24 +240,57 @@ def test_read_gbq_cached_table(): ) table._properties["location"] = session._location table._properties["numRows"] = "1000000000" - table._properties["location"] = session._location table._properties["type"] = "TABLE" - session._loader._df_snapshot[table_ref] = ( + session._loader._df_snapshot[str(table_ref)] = ( datetime.datetime(1999, 1, 2, 3, 4, 5, 678901, tzinfo=datetime.timezone.utc), table, ) - session.bqclient.query_and_wait = mock.MagicMock( + session.bqclient._query_and_wait_bigframes = mock.MagicMock( return_value=({"total_count": 3, "distinct_count": 2},) ) session.bqclient.get_table.return_value = table - with pytest.warns(UserWarning, match=re.escape("use_cache=False")): + with pytest.warns( + bigframes.exceptions.TimeTravelCacheWarning, match=re.escape("use_cache=False") + ): df = session.read_gbq("my-project.my_dataset.my_table") assert "1999-01-02T03:04:05.678901" in df.sql +def test_read_gbq_cached_table_doesnt_warn_for_anonymous_tables_and_doesnt_include_time_travel(): + session = mocks.create_bigquery_session() + table_ref = google.cloud.bigquery.TableReference( + google.cloud.bigquery.DatasetReference("my-project", "_anonymous_dataset"), + "my_table", + ) + table = google.cloud.bigquery.Table( + table_ref, (google.cloud.bigquery.SchemaField("col", "INTEGER"),) + ) + table._properties["location"] = session._location + table._properties["numRows"] = "1000000000" + table._properties["location"] = session._location + table._properties["type"] = "TABLE" + session._loader._df_snapshot[str(table_ref)] = ( + datetime.datetime(1999, 1, 2, 3, 4, 5, 678901, tzinfo=datetime.timezone.utc), + table, + ) + + session.bqclient._query_and_wait_bigframes = mock.MagicMock( + return_value=({"total_count": 3, "distinct_count": 2},) + ) + session.bqclient.get_table.return_value = table + + with warnings.catch_warnings(): + warnings.simplefilter( + "error", category=bigframes.exceptions.TimeTravelCacheWarning + ) + df = session.read_gbq("my-project._anonymous_dataset.my_table") + + assert "1999-01-02T03:04:05.678901" not in df.sql + + @pytest.mark.parametrize("table", CLUSTERED_OR_PARTITIONED_TABLES) def test_default_index_warning_raised_by_read_gbq(table): """Because of the windowing operation to create a default index, row @@ -242,8 +305,14 @@ def test_default_index_warning_raised_by_read_gbq(table): bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) bqclient.project = "test-project" bqclient.get_table.return_value = table - bqclient.query_and_wait.return_value = ({"total_count": 3, "distinct_count": 2},) - session = resources.create_bigquery_session(bqclient=bqclient) + bqclient._query_and_wait_bigframes.return_value = ( + {"total_count": 3, "distinct_count": 2}, + ) + session = mocks.create_bigquery_session( + bqclient=bqclient, + # DefaultIndexWarning is only relevant for strict mode. + ordering_mode="strict", + ) table._properties["location"] = session._location with pytest.warns(bigframes.exceptions.DefaultIndexWarning): @@ -265,8 +334,14 @@ def test_default_index_warning_not_raised_by_read_gbq_index_col_sequential_int64 bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) bqclient.project = "test-project" bqclient.get_table.return_value = table - bqclient.query_and_wait.return_value = ({"total_count": 4, "distinct_count": 3},) - session = resources.create_bigquery_session(bqclient=bqclient) + bqclient._query_and_wait_bigframes.return_value = ( + {"total_count": 4, "distinct_count": 3}, + ) + session = mocks.create_bigquery_session( + bqclient=bqclient, + # DefaultIndexWarning is only relevant for strict mode. + ordering_mode="strict", + ) table._properties["location"] = session._location # No warnings raised because we set the option allowing the default indexes. @@ -310,11 +385,14 @@ def test_default_index_warning_not_raised_by_read_gbq_index_col_columns( bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) bqclient.project = "test-project" bqclient.get_table.return_value = table - bqclient.query_and_wait.return_value = ( + bqclient._query_and_wait_bigframes.return_value = ( {"total_count": total_count, "distinct_count": distinct_count}, ) - session = resources.create_bigquery_session( - bqclient=bqclient, table_schema=table.schema + session = mocks.create_bigquery_session( + bqclient=bqclient, + table_schema=table.schema, + # DefaultIndexWarning is only relevant for strict mode. + ordering_mode="strict", ) table._properties["location"] = session._location @@ -355,8 +433,11 @@ def test_default_index_warning_not_raised_by_read_gbq_primary_key(table): bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) bqclient.project = "test-project" bqclient.get_table.return_value = table - session = resources.create_bigquery_session( - bqclient=bqclient, table_schema=table.schema + session = mocks.create_bigquery_session( + bqclient=bqclient, + table_schema=table.schema, + # DefaultIndexWarning is only relevant for strict mode. + ordering_mode="strict", ) table._properties["location"] = session._location @@ -380,7 +461,7 @@ def test_read_gbq_not_found_tables(not_found_table_id): bqclient.get_table.side_effect = google.api_core.exceptions.NotFound( "table not found" ) - session = resources.create_bigquery_session(bqclient=bqclient) + session = mocks.create_bigquery_session(bqclient=bqclient) with pytest.raises(google.api_core.exceptions.NotFound): session.read_gbq(not_found_table_id) @@ -402,7 +483,7 @@ def test_read_gbq_not_found_tables(not_found_table_id): ], ) def test_read_gbq_external_table_no_drive_access(api_name, query_or_table): - session = resources.create_bigquery_session() + session = mocks.create_bigquery_session() session_query_mock = session.bqclient.query def query_mock(query, *args, **kwargs): @@ -414,6 +495,7 @@ def query_mock(query, *args, **kwargs): return session_query_mock(query, *args, **kwargs) session.bqclient.query_and_wait = query_mock + session.bqclient._query_and_wait_bigframes = query_mock def get_table_mock(table_ref): table = google.cloud.bigquery.Table( @@ -430,7 +512,7 @@ def get_table_mock(table_ref): google.api_core.exceptions.Forbidden, match="Check https://cloud.google.com/bigquery/docs/query-drive-data#Google_Drive_permissions.", ): - api(query_or_table) + api(query_or_table).to_pandas() @mock.patch.dict(os.environ, {}, clear=True) @@ -443,3 +525,36 @@ def test_session_init_fails_with_no_project(): credentials=mock.Mock(spec=google.auth.credentials.Credentials) ) ) + + +def test_session_init_warns_if_bf_version_is_too_old(monkeypatch): + release_date = datetime.datetime.strptime(version.__release_date__, "%Y-%m-%d") + current_date = release_date + datetime.timedelta(days=366) + + class FakeDatetime(datetime.datetime): + @classmethod + def today(cls): + return current_date + + monkeypatch.setattr(datetime, "datetime", FakeDatetime) + + with pytest.warns(bigframes.exceptions.ObsoleteVersionWarning): + mocks.create_bigquery_session() + + +@mock.patch("bigframes.constants.MAX_INLINE_BYTES", 1) +def test_read_pandas_inline_exceeds_limit_raises_error(): + session = mocks.create_bigquery_session() + pd_df = pd.DataFrame([[1, 2, 3], [4, 5, 6]]) + with pytest.raises( + ValueError, + match=r"DataFrame size \(.* bytes\) exceeds the maximum allowed for inline data \(1 bytes\)\.", + ): + session.read_pandas(pd_df, write_engine="bigquery_inline") + + +def test_read_pandas_inline_w_interval_type_raises_error(): + session = mocks.create_bigquery_session() + df = pd.DataFrame(pd.arrays.IntervalArray.from_breaks([0, 10, 20, 30, 40, 50])) + with pytest.raises(TypeError): + session.read_pandas(df, write_engine="bigquery_inline") diff --git a/tests/unit/session/test_time.py b/tests/unit/session/test_time.py index 87766e79bb..39a231c3ce 100644 --- a/tests/unit/session/test_time.py +++ b/tests/unit/session/test_time.py @@ -15,7 +15,6 @@ import datetime import unittest.mock as mock -import freezegun import google.cloud.bigquery import pytest @@ -47,6 +46,8 @@ def query_and_wait_mock(query, *args, **kwargs): def test_bqsyncedclock_get_time(bq_client): + freezegun = pytest.importorskip("freezegun") + # this initial local time is actually irrelevant, only the ticks matter initial_local_datetime = datetime.datetime( year=1, month=7, day=12, hour=15, minute=6, second=3 diff --git a/tests/unit/test_clients.py b/tests/unit/test_clients.py index 37450ececb..9daa759838 100644 --- a/tests/unit/test_clients.py +++ b/tests/unit/test_clients.py @@ -12,38 +12,84 @@ # See the License for the specific language governing permissions and # limitations under the License. +from unittest import mock + +from google.cloud import bigquery_connection_v1, resourcemanager_v3 +from google.iam.v1 import policy_pb2 import pytest from bigframes import clients -def test_get_connection_name_full_connection_id(): - connection_name = clients.resolve_full_bq_connection_name( +def test_get_canonical_bq_connection_id_connection_id_only(): + connection_id = clients.get_canonical_bq_connection_id( "connection-id", default_project="default-project", default_location="us" ) - assert connection_name == "default-project.us.connection-id" + assert connection_id == "default-project.us.connection-id" -def test_get_connection_name_full_location_connection_id(): - connection_name = clients.resolve_full_bq_connection_name( +def test_get_canonical_bq_connection_id_location_and_connection_id(): + connection_id = clients.get_canonical_bq_connection_id( "eu.connection-id", default_project="default-project", default_location="us" ) - assert connection_name == "default-project.eu.connection-id" + assert connection_id == "default-project.eu.connection-id" -def test_get_connection_name_full_all(): - connection_name = clients.resolve_full_bq_connection_name( +def test_get_canonical_bq_connection_id_already_canonical(): + connection_id = clients.get_canonical_bq_connection_id( "my-project.eu.connection-id", default_project="default-project", default_location="us", ) - assert connection_name == "my-project.eu.connection-id" + assert connection_id == "my-project.eu.connection-id" -def test_get_connection_name_full_raise_value_error(): - with pytest.raises(ValueError): - clients.resolve_full_bq_connection_name( +def test_get_canonical_bq_connection_id_invalid(): + with pytest.raises(ValueError, match="Invalid connection id format"): + clients.get_canonical_bq_connection_id( "my-project.eu.connection-id.extra_field", default_project="default-project", default_location="us", ) + + +def test_get_canonical_bq_connection_id_valid_path(): + connection_id = clients.get_canonical_bq_connection_id( + "projects/project_id/locations/northamerica-northeast1/connections/connection-id", + default_project="default-project", + default_location="us", + ) + assert connection_id == "project_id.northamerica-northeast1.connection-id" + + +def test_get_canonical_bq_connection_id_invalid_path(): + with pytest.raises(ValueError, match="Invalid connection id format"): + clients.get_canonical_bq_connection_id( + "/projects/project_id/locations/northamerica-northeast1/connections/connection-id", + default_project="default-project", + default_location="us", + ) + + +def test_ensure_iam_binding(): + bq_connection_client = mock.create_autospec( + bigquery_connection_v1.ConnectionServiceClient, instance=True + ) + resource_manager_client = mock.create_autospec( + resourcemanager_v3.ProjectsClient, instance=True + ) + resource_manager_client.get_iam_policy.return_value = policy_pb2.Policy( + bindings=[ + policy_pb2.Binding( + role="roles/test.role1", members=["serviceAccount:serviceAccount1"] + ) + ] + ) + bq_connection_manager = clients.BqConnectionManager( + bq_connection_client, resource_manager_client + ) + bq_connection_manager._IAM_WAIT_SECONDS = 0 # no need to wait in test + bq_connection_manager._ensure_iam_binding( + "test-project", "serviceAccount2", "roles/test.role2" + ) + resource_manager_client.set_iam_policy.assert_called_once() diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py new file mode 100644 index 0000000000..6b3acd7d7d --- /dev/null +++ b/tests/unit/test_daemon.py @@ -0,0 +1,42 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import time +from unittest.mock import MagicMock + +from bigframes.session.bigquery_session import RecurringTaskDaemon + + +def test_recurring_task_daemon_calls(): + mock_task = MagicMock() + daemon = RecurringTaskDaemon( + task=mock_task, frequency=datetime.timedelta(seconds=0.1) + ) + daemon.start() + time.sleep(1.0) + daemon.stop() + time.sleep(0.5) + # be lenient, but number of calls should be in this ballpark regardless of scheduling hiccups + assert mock_task.call_count > 6 + assert mock_task.call_count < 12 + + +def test_recurring_task_daemon_never_started(): + mock_task = MagicMock() + _ = RecurringTaskDaemon( + task=mock_task, frequency=datetime.timedelta(seconds=0.0001) + ) + time.sleep(0.1) + assert mock_task.call_count == 0 diff --git a/tests/unit/test_dataframe.py b/tests/unit/test_dataframe.py index a6ad5e3821..015dbd030e 100644 --- a/tests/unit/test_dataframe.py +++ b/tests/unit/test_dataframe.py @@ -13,17 +13,18 @@ # limitations under the License. import google.cloud.bigquery +import pandas as pd import pytest import bigframes.dataframe - -from . import resources +import bigframes.session +from bigframes.testing import mocks def test_dataframe_dropna_axis_1_subset_not_implememented( monkeypatch: pytest.MonkeyPatch, ): - dataframe = resources.create_dataframe(monkeypatch) + dataframe = mocks.create_dataframe(monkeypatch) with pytest.raises(NotImplementedError, match="subset"): dataframe.dropna(axis=1, subset=["col1", "col2"]) @@ -41,6 +42,68 @@ def test_dataframe_repr_with_uninitialized_object(): assert "DataFrame" in got +@pytest.mark.parametrize( + "rule", + [ + pd.DateOffset(weeks=1), + pd.Timedelta(hours=8), + # According to + # https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.resample.html + # these all default to "right" for closed and label, which isn't yet supported. + "ME", + "YE", + "QE", + "BME", + "BA", + "BQE", + "W", + ], +) +def test_dataframe_rule_not_implememented( + monkeypatch: pytest.MonkeyPatch, + rule, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.raises(NotImplementedError, match="rule"): + dataframe.resample(rule=rule) + + +def test_dataframe_closed_not_implememented( + monkeypatch: pytest.MonkeyPatch, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.raises(NotImplementedError, match="Only closed='left'"): + dataframe.resample(rule="1d", closed="right") + + +def test_dataframe_label_not_implememented( + monkeypatch: pytest.MonkeyPatch, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.raises(NotImplementedError, match="Only label='left'"): + dataframe.resample(rule="1d", label="right") + + +@pytest.mark.parametrize( + "origin", + [ + "end", + "end_day", + ], +) +def test_dataframe_origin_not_implememented( + monkeypatch: pytest.MonkeyPatch, + origin, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.raises(NotImplementedError, match="origin"): + dataframe.resample(rule="1d", origin=origin) + + def test_dataframe_setattr_with_uninitialized_object(): """Ensures DataFrame can be subclassed without trying to set attributes as columns.""" # Avoid calling __init__ since it might be called later in a subclass. @@ -51,14 +114,14 @@ def test_dataframe_setattr_with_uninitialized_object(): def test_dataframe_to_gbq_invalid_destination(monkeypatch: pytest.MonkeyPatch): - dataframe = resources.create_dataframe(monkeypatch) + dataframe = mocks.create_dataframe(monkeypatch) with pytest.raises(ValueError, match="no_dataset_or_project"): dataframe.to_gbq("no_dataset_or_project") def test_dataframe_to_gbq_invalid_if_exists(monkeypatch: pytest.MonkeyPatch): - dataframe = resources.create_dataframe(monkeypatch) + dataframe = mocks.create_dataframe(monkeypatch) with pytest.raises(ValueError, match="notreallyanoption"): # Even though the type is annotated with the literals we accept, users @@ -70,7 +133,7 @@ def test_dataframe_to_gbq_invalid_if_exists(monkeypatch: pytest.MonkeyPatch): def test_dataframe_to_gbq_invalid_if_exists_no_destination( monkeypatch: pytest.MonkeyPatch, ): - dataframe = resources.create_dataframe(monkeypatch) + dataframe = mocks.create_dataframe(monkeypatch) with pytest.raises(ValueError, match="append"): dataframe.to_gbq(if_exists="append") @@ -83,9 +146,100 @@ def test_dataframe_to_gbq_writes_to_anonymous_dataset( anonymous_dataset = google.cloud.bigquery.DatasetReference.from_string( anonymous_dataset_id ) - session = resources.create_bigquery_session(anonymous_dataset=anonymous_dataset) - dataframe = resources.create_dataframe(monkeypatch, session=session) + session = mocks.create_bigquery_session(anonymous_dataset=anonymous_dataset) + dataframe = mocks.create_dataframe(monkeypatch, session=session) destination = dataframe.to_gbq() assert destination.startswith(anonymous_dataset_id) + + +def test_dataframe_rename_columns(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"col1": [], "col2": [], "col3": []} + ) + assert dataframe.columns.to_list() == ["col1", "col2", "col3"] + renamed = dataframe.rename(columns={"col1": "a", "col2": "b", "col3": "c"}) + assert renamed.columns.to_list() == ["a", "b", "c"] + + +def test_dataframe_rename_columns_inplace_returns_none(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"col1": [], "col2": [], "col3": []} + ) + assert dataframe.columns.to_list() == ["col1", "col2", "col3"] + assert ( + dataframe.rename(columns={"col1": "a", "col2": "b", "col3": "c"}, inplace=True) + is None + ) + assert dataframe.columns.to_list() == ["a", "b", "c"] + + +def test_dataframe_rename_axis(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"index1": [], "index2": [], "col1": [], "col2": []} + ).set_index(["index1", "index2"]) + assert list(dataframe.index.names) == ["index1", "index2"] + renamed = dataframe.rename_axis(["a", "b"]) + assert list(renamed.index.names) == ["a", "b"] + + +def test_dataframe_rename_axis_inplace_returns_none(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"index1": [], "index2": [], "col1": [], "col2": []} + ).set_index(["index1", "index2"]) + assert list(dataframe.index.names) == ["index1", "index2"] + assert dataframe.rename_axis(["a", "b"], inplace=True) is None + assert list(dataframe.index.names) == ["a", "b"] + + +def test_dataframe_drop_columns_inplace_returns_none(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"col1": [1], "col2": [2], "col3": [3]} + ) + assert dataframe.columns.to_list() == ["col1", "col2", "col3"] + assert dataframe.drop(columns=["col1", "col3"], inplace=True) is None + assert dataframe.columns.to_list() == ["col2"] + + +def test_dataframe_drop_index_inplace_returns_none( + # Drop index depends on the actual data, not just metadata, so use the + # local engine for more robust testing. + polars_session: bigframes.session.Session, +): + dataframe = polars_session.read_pandas( + pd.DataFrame({"col1": [1, 2, 3], "index_col": [0, 1, 2]}).set_index("index_col") + ) + assert dataframe.index.to_list() == [0, 1, 2] + assert dataframe.drop(index=[0, 2], inplace=True) is None + assert dataframe.index.to_list() == [1] + + +def test_dataframe_drop_columns_returns_new_dataframe(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"col1": [1], "col2": [2], "col3": [3]} + ) + assert dataframe.columns.to_list() == ["col1", "col2", "col3"] + new_dataframe = dataframe.drop(columns=["col1", "col3"]) + assert dataframe.columns.to_list() == ["col1", "col2", "col3"] + assert new_dataframe.columns.to_list() == ["col2"] + + +def test_dataframe_semantics_property_future_warning( + monkeypatch: pytest.MonkeyPatch, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with bigframes.option_context("experiments.semantic_operators", True), pytest.warns( + FutureWarning + ): + dataframe.semantics + + +def test_dataframe_ai_property_future_warning( + monkeypatch: pytest.MonkeyPatch, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.warns(FutureWarning): + dataframe.ai diff --git a/tests/unit/test_dataframe_io.py b/tests/unit/test_dataframe_io.py new file mode 100644 index 0000000000..f2c0241396 --- /dev/null +++ b/tests/unit/test_dataframe_io.py @@ -0,0 +1,56 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import mock + +import pytest + +from bigframes.testing import mocks + + +@pytest.fixture +def mock_df(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe(monkeypatch) + monkeypatch.setattr(dataframe, "to_pandas", mock.Mock()) + return dataframe + + +@pytest.mark.parametrize( + "api_name, kwargs", + [ + ("to_csv", {"allow_large_results": True}), + ("to_json", {"allow_large_results": True}), + ("to_numpy", {"allow_large_results": True}), + ("to_parquet", {"allow_large_results": True}), + ("to_dict", {"allow_large_results": True}), + ("to_excel", {"excel_writer": "abc", "allow_large_results": True}), + ("to_latex", {"allow_large_results": True}), + ("to_records", {"allow_large_results": True}), + ("to_string", {"allow_large_results": True}), + ("to_html", {"allow_large_results": True}), + ("to_markdown", {"allow_large_results": True}), + ("to_pickle", {"path": "abc", "allow_large_results": True}), + ("to_orc", {"allow_large_results": True}), + ], +) +def test_dataframe_to_pandas(mock_df, api_name, kwargs): + getattr(mock_df, api_name)(**kwargs) + mock_df.to_pandas.assert_called_once_with( + allow_large_results=kwargs["allow_large_results"] + ) + + +def test_to_gbq_if_exists_invalid(mock_df): + with pytest.raises(ValueError, match="Got invalid value 'invalid' for if_exists."): + mock_df.to_gbq("a.b.c", if_exists="invalid") diff --git a/tests/unit/test_dataframe_polars.py b/tests/unit/test_dataframe_polars.py new file mode 100644 index 0000000000..1c73d9dc6b --- /dev/null +++ b/tests/unit/test_dataframe_polars.py @@ -0,0 +1,4452 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import io +import operator +import pathlib +import tempfile +import typing +from typing import Generator, List, Tuple + +import numpy as np +import pandas as pd +import pandas.testing +import pytest + +import bigframes +import bigframes._config.display_options as display_options +import bigframes.core.indexes as bf_indexes +import bigframes.dataframe as dataframe +import bigframes.pandas as bpd +import bigframes.series as series +from bigframes.testing.utils import ( + assert_dfs_equivalent, + assert_frame_equal, + assert_series_equal, + assert_series_equivalent, + convert_pandas_dtypes, +) + +pytest.importorskip("polars") +pytest.importorskip("pandas", minversion="2.0.0") + +CURRENT_DIR = pathlib.Path(__file__).parent +DATA_DIR = CURRENT_DIR.parent / "data" + + +@pytest.fixture(scope="module", autouse=True) +def session() -> Generator[bigframes.Session, None, None]: + import bigframes.core.global_session + from bigframes.testing import polars_session + + session = polars_session.TestSession() + with bigframes.core.global_session._GlobalSessionContext(session): + yield session + + +@pytest.fixture(scope="module") +def scalars_pandas_df_index() -> pd.DataFrame: + """pd.DataFrame pointing at test data.""" + + df = pd.read_json( + DATA_DIR / "scalars.jsonl", + lines=True, + ) + convert_pandas_dtypes(df, bytes_col=True) + + df = df.set_index("rowindex", drop=False) + df.index.name = None + return df.set_index("rowindex").sort_index() + + +@pytest.fixture(scope="module") +def scalars_df_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index) + + +@pytest.fixture(scope="module") +def scalars_df_2_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index) + + +@pytest.fixture(scope="module") +def scalars_dfs( + scalars_df_index, + scalars_pandas_df_index, +): + return scalars_df_index, scalars_pandas_df_index + + +def test_df_construct_copy(scalars_dfs): + columns = ["int64_col", "string_col", "float64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + # Make the mapping from label to col_id non-trivial + bf_df = scalars_df.copy() + bf_df["int64_col"] = bf_df["int64_col"] / 2 + pd_df = scalars_pandas_df.copy() + pd_df["int64_col"] = pd_df["int64_col"] / 2 + + bf_result = dataframe.DataFrame(bf_df, columns=columns).to_pandas() + + pd_result = pd.DataFrame(pd_df, columns=columns) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_df_construct_pandas_default(scalars_dfs): + # This should trigger the inlined codepath + columns = [ + "int64_too", + "int64_col", + "float64_col", + "bool_col", + "string_col", + "date_col", + "datetime_col", + "numeric_col", + "float64_col", + "time_col", + "timestamp_col", + ] + _, scalars_pandas_df = scalars_dfs + bf_result = dataframe.DataFrame(scalars_pandas_df, columns=columns).to_pandas() + pd_result = pd.DataFrame(scalars_pandas_df, columns=columns) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_df_construct_structs(session): + pd_frame = pd.Series( + [ + {"version": 1, "project": "pandas"}, + {"version": 2, "project": "pandas"}, + {"version": 1, "project": "numpy"}, + ] + ).to_frame() + bf_series = session.read_pandas(pd_frame) + pd.testing.assert_frame_equal( + bf_series.to_pandas(), pd_frame, check_index_type=False, check_dtype=False + ) + + +def test_df_construct_pandas_set_dtype(scalars_dfs): + columns = [ + "int64_too", + "int64_col", + "float64_col", + "bool_col", + ] + _, scalars_pandas_df = scalars_dfs + bf_result = dataframe.DataFrame( + scalars_pandas_df, columns=columns, dtype="Float64" + ).to_pandas() + pd_result = pd.DataFrame(scalars_pandas_df, columns=columns, dtype="Float64") + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_df_construct_from_series(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = dataframe.DataFrame( + {"a": scalars_df["int64_col"], "b": scalars_df["string_col"]}, + dtype="string[pyarrow]", + ) + pd_result = pd.DataFrame( + {"a": scalars_pandas_df["int64_col"], "b": scalars_pandas_df["string_col"]}, + dtype="string[pyarrow]", + ) + assert_dfs_equivalent(pd_result, bf_result) + + +def test_df_construct_from_dict(): + input_dict = { + "Animal": ["Falcon", "Falcon", "Parrot", "Parrot"], + # With a space in column name. We use standardized SQL schema ids to solve the problem that BQ schema doesn't support column names with spaces. b/296751058 + "Max Speed": [380.0, 370.0, 24.0, 26.0], + } + bf_result = dataframe.DataFrame(input_dict).to_pandas() + pd_result = pd.DataFrame(input_dict) + + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_df_construct_dtype(): + data = { + "int_col": [1, 2, 3], + "string_col": ["1.1", "2.0", "3.5"], + "float_col": [1.0, 2.0, 3.0], + } + dtype = pd.StringDtype(storage="pyarrow") + bf_result = dataframe.DataFrame(data, dtype=dtype) + pd_result = pd.DataFrame(data, dtype=dtype) + pd_result.index = pd_result.index.astype("Int64") + pandas.testing.assert_frame_equal(bf_result.to_pandas(), pd_result) + + +def test_get_column(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + series = scalars_df[col_name] + bf_result = series.to_pandas() + pd_result = scalars_pandas_df[col_name] + assert_series_equal(bf_result, pd_result) + + +def test_get_column_nonstring(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + series = scalars_df.rename(columns={"int64_col": 123.1})[123.1] + bf_result = series.to_pandas() + pd_result = scalars_pandas_df.rename(columns={"int64_col": 123.1})[123.1] + assert_series_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + "row_slice", + [ + (slice(1, 7, 2)), + (slice(1, 7, None)), + (slice(None, -3, None)), + ], +) +def test_get_rows_with_slice(scalars_dfs, row_slice): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[row_slice].to_pandas() + pd_result = scalars_pandas_df[row_slice] + assert_frame_equal(bf_result, pd_result) + + +def test_hasattr(scalars_dfs): + scalars_df, _ = scalars_dfs + assert hasattr(scalars_df, "int64_col") + assert hasattr(scalars_df, "head") + assert not hasattr(scalars_df, "not_exist") + + +@pytest.mark.parametrize( + ("ordered"), + [ + (True), + (False), + ], +) +def test_head_with_custom_column_labels( + scalars_df_index, scalars_pandas_df_index, ordered +): + rename_mapping = { + "int64_col": "Integer Column", + "string_col": "言語列", + } + bf_df = scalars_df_index.rename(columns=rename_mapping).head(3) + bf_result = bf_df.to_pandas(ordered=ordered) + pd_result = scalars_pandas_df_index.rename(columns=rename_mapping).head(3) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) + + +def test_tail_with_custom_column_labels(scalars_df_index, scalars_pandas_df_index): + rename_mapping = { + "int64_col": "Integer Column", + "string_col": "言語列", + } + bf_df = scalars_df_index.rename(columns=rename_mapping).tail(3) + bf_result = bf_df.to_pandas() + pd_result = scalars_pandas_df_index.rename(columns=rename_mapping).tail(3) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_get_column_by_attr(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + series = scalars_df.int64_col + bf_result = series.to_pandas() + pd_result = scalars_pandas_df.int64_col + assert_series_equal(bf_result, pd_result) + + +def test_get_columns(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_names = ["bool_col", "float64_col", "int64_col"] + df_subset = scalars_df.get(col_names) + df_pandas = df_subset.to_pandas() + pd.testing.assert_index_equal( + df_pandas.columns, scalars_pandas_df[col_names].columns + ) + + +def test_get_columns_default(scalars_dfs): + scalars_df, _ = scalars_dfs + col_names = ["not", "column", "names"] + result = scalars_df.get(col_names, "default_val") + assert result == "default_val" + + +@pytest.mark.parametrize( + ("loc", "column", "value", "allow_duplicates"), + [ + (0, 666, 2, False), + (5, "float64_col", 2.2, True), + (13, "rowindex_2", [8, 7, 6, 5, 4, 3, 2, 1, 0], True), + pytest.param( + 14, + "test", + 2, + False, + marks=pytest.mark.xfail( + raises=IndexError, + ), + ), + pytest.param( + 12, + "int64_col", + 2, + False, + marks=pytest.mark.xfail( + raises=ValueError, + ), + ), + ], +) +def test_insert(scalars_dfs, loc, column, value, allow_duplicates): + scalars_df, scalars_pandas_df = scalars_dfs + # insert works inplace, so will influence other tests. + # make a copy to avoid inplace changes. + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + bf_df.insert(loc, column, value, allow_duplicates) + pd_df.insert(loc, column, value, allow_duplicates) + + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df, check_dtype=False) + + +def test_where_series_cond(scalars_df_index, scalars_pandas_df_index): + # Condition is dataframe, other is None (as default). + cond_bf = scalars_df_index["int64_col"] > 0 + cond_pd = scalars_pandas_df_index["int64_col"] > 0 + bf_result = scalars_df_index.where(cond_bf).to_pandas() + pd_result = scalars_pandas_df_index.where(cond_pd) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_mask_series_cond(scalars_df_index, scalars_pandas_df_index): + cond_bf = scalars_df_index["int64_col"] > 0 + cond_pd = scalars_pandas_df_index["int64_col"] > 0 + + bf_df = scalars_df_index[["int64_too", "int64_col", "float64_col"]] + pd_df = scalars_pandas_df_index[["int64_too", "int64_col", "float64_col"]] + bf_result = bf_df.mask(cond_bf, bf_df + 1).to_pandas() + pd_result = pd_df.mask(cond_pd, pd_df + 1) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_where_series_multi_index(scalars_df_index, scalars_pandas_df_index): + # Test when a dataframe has multi-index or multi-columns. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + + dataframe_bf.columns = pd.MultiIndex.from_tuples( + [("str1", 1), ("str2", 2)], names=["STR", "INT"] + ) + cond_bf = dataframe_bf["str1"] > 0 + + with pytest.raises(NotImplementedError) as context: + dataframe_bf.where(cond_bf).to_pandas() + assert ( + str(context.value) + == "The dataframe.where() method does not support multi-column." + ) + + +def test_where_series_cond_const_other(scalars_df_index, scalars_pandas_df_index): + # Condition is a series, other is a constant. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + dataframe_pd = scalars_pandas_df_index[columns] + dataframe_bf.columns.name = "test_name" + dataframe_pd.columns.name = "test_name" + + cond_bf = dataframe_bf["int64_col"] > 0 + cond_pd = dataframe_pd["int64_col"] > 0 + other = 0 + + bf_result = dataframe_bf.where(cond_bf, other).to_pandas() + pd_result = dataframe_pd.where(cond_pd, other) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_where_series_cond_dataframe_other(scalars_df_index, scalars_pandas_df_index): + # Condition is a series, other is a dataframe. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + dataframe_pd = scalars_pandas_df_index[columns] + + cond_bf = dataframe_bf["int64_col"] > 0 + cond_pd = dataframe_pd["int64_col"] > 0 + other_bf = -dataframe_bf + other_pd = -dataframe_pd + + bf_result = dataframe_bf.where(cond_bf, other_bf).to_pandas() + pd_result = dataframe_pd.where(cond_pd, other_pd) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_where_dataframe_cond(scalars_df_index, scalars_pandas_df_index): + # Condition is a dataframe, other is None. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + dataframe_pd = scalars_pandas_df_index[columns] + + cond_bf = dataframe_bf > 0 + cond_pd = dataframe_pd > 0 + + bf_result = dataframe_bf.where(cond_bf, None).to_pandas() + pd_result = dataframe_pd.where(cond_pd, None) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_where_dataframe_cond_const_other(scalars_df_index, scalars_pandas_df_index): + # Condition is a dataframe, other is a constant. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + dataframe_pd = scalars_pandas_df_index[columns] + + cond_bf = dataframe_bf > 0 + cond_pd = dataframe_pd > 0 + other_bf = 10 + other_pd = 10 + + bf_result = dataframe_bf.where(cond_bf, other_bf).to_pandas() + pd_result = dataframe_pd.where(cond_pd, other_pd) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_where_dataframe_cond_dataframe_other( + scalars_df_index, scalars_pandas_df_index +): + # Condition is a dataframe, other is a dataframe. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + dataframe_pd = scalars_pandas_df_index[columns] + + cond_bf = dataframe_bf > 0 + cond_pd = dataframe_pd > 0 + other_bf = dataframe_bf * 2 + other_pd = dataframe_pd * 2 + + bf_result = dataframe_bf.where(cond_bf, other_bf).to_pandas() + pd_result = dataframe_pd.where(cond_pd, other_pd) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_drop_column(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + df_pandas = scalars_df.drop(columns=col_name).to_pandas() + pd.testing.assert_index_equal( + df_pandas.columns, scalars_pandas_df.drop(columns=col_name).columns + ) + + +def test_drop_columns(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_names = ["int64_col", "geography_col", "time_col"] + df_pandas = scalars_df.drop(columns=col_names).to_pandas() + pd.testing.assert_index_equal( + df_pandas.columns, scalars_pandas_df.drop(columns=col_names).columns + ) + + +def test_drop_labels_axis_1(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + labels = ["int64_col", "geography_col", "time_col"] + + pd_result = scalars_pandas_df.drop(labels=labels, axis=1) + bf_result = scalars_df.drop(labels=labels, axis=1).to_pandas() + + pd.testing.assert_frame_equal(pd_result, bf_result) + + +def test_drop_with_custom_column_labels(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + rename_mapping = { + "int64_col": "Integer Column", + "string_col": "言語列", + } + dropped_columns = [ + "言語列", + "timestamp_col", + ] + bf_df = scalars_df.rename(columns=rename_mapping).drop(columns=dropped_columns) + bf_result = bf_df.to_pandas() + pd_result = scalars_pandas_df.rename(columns=rename_mapping).drop( + columns=dropped_columns + ) + assert_frame_equal(bf_result, pd_result) + + +def test_df_memory_usage(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = scalars_pandas_df.memory_usage() + bf_result = scalars_df.memory_usage() + + pd.testing.assert_series_equal(pd_result, bf_result, rtol=1.5) + + +def test_df_info(scalars_dfs): + expected = ( + "\n" + "Index: 9 entries, 0 to 8\n" + "Data columns (total 14 columns):\n" + " # Column Non-Null Count Dtype\n" + "--- ------------- ---------------- ------------------------------\n" + " 0 bool_col 8 non-null boolean\n" + " 1 bytes_col 6 non-null binary[pyarrow]\n" + " 2 date_col 7 non-null date32[day][pyarrow]\n" + " 3 datetime_col 6 non-null timestamp[us][pyarrow]\n" + " 4 geography_col 4 non-null geometry\n" + " 5 int64_col 8 non-null Int64\n" + " 6 int64_too 9 non-null Int64\n" + " 7 numeric_col 6 non-null decimal128(38, 9)[pyarrow]\n" + " 8 float64_col 7 non-null Float64\n" + " 9 rowindex_2 9 non-null Int64\n" + " 10 string_col 8 non-null string\n" + " 11 time_col 6 non-null time64[us][pyarrow]\n" + " 12 timestamp_col 6 non-null timestamp[us, tz=UTC][pyarrow]\n" + " 13 duration_col 7 non-null duration[us][pyarrow]\n" + "dtypes: Float64(1), Int64(3), binary[pyarrow](1), boolean(1), date32[day][pyarrow](1), decimal128(38, 9)[pyarrow](1), duration[us][pyarrow](1), geometry(1), string(1), time64[us][pyarrow](1), timestamp[us, tz=UTC][pyarrow](1), timestamp[us][pyarrow](1)\n" + "memory usage: 1341 bytes\n" + ) + + scalars_df, _ = scalars_dfs + bf_result = io.StringIO() + + scalars_df.info(buf=bf_result) + + assert expected == bf_result.getvalue() + + +@pytest.mark.parametrize( + ("include", "exclude"), + [ + ("Int64", None), + (["int"], None), + ("number", None), + ([pd.Int64Dtype(), pd.BooleanDtype()], None), + (None, [pd.Int64Dtype(), pd.BooleanDtype()]), + ("Int64", ["boolean"]), + ], +) +def test_select_dtypes(scalars_dfs, include, exclude): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = scalars_pandas_df.select_dtypes(include=include, exclude=exclude) + bf_result = scalars_df.select_dtypes(include=include, exclude=exclude).to_pandas() + + pd.testing.assert_frame_equal(pd_result, bf_result) + + +def test_drop_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = scalars_pandas_df.drop(index=[4, 1, 2]) + bf_result = scalars_df.drop(index=[4, 1, 2]).to_pandas() + + pd.testing.assert_frame_equal(pd_result, bf_result) + + +def test_drop_pandas_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + drop_index = scalars_pandas_df.iloc[[4, 1, 2]].index + + pd_result = scalars_pandas_df.drop(index=drop_index) + bf_result = scalars_df.drop(index=drop_index).to_pandas() + + pd.testing.assert_frame_equal(pd_result, bf_result) + + +def test_drop_bigframes_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + drop_index = scalars_df.loc[[4, 1, 2]].index + drop_pandas_index = scalars_pandas_df.loc[[4, 1, 2]].index + + pd_result = scalars_pandas_df.drop(index=drop_pandas_index) + bf_result = scalars_df.drop(index=drop_index).to_pandas() + + pd.testing.assert_frame_equal(pd_result, bf_result) + + +def test_drop_bigframes_index_with_na(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.copy() + scalars_pandas_df = scalars_pandas_df.copy() + scalars_df = scalars_df.set_index("bytes_col") + scalars_pandas_df = scalars_pandas_df.set_index("bytes_col") + drop_index = scalars_df.iloc[[2, 5]].index + drop_pandas_index = scalars_pandas_df.iloc[[2, 5]].index + + pd_result = scalars_pandas_df.drop(index=drop_pandas_index) # drop_pandas_index) + bf_result = scalars_df.drop(index=drop_index).to_pandas() + + pd.testing.assert_frame_equal(pd_result, bf_result) + + +def test_drop_bigframes_multiindex(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.copy() + scalars_pandas_df = scalars_pandas_df.copy() + sub_df = scalars_df.iloc[[4, 1, 2]] + sub_pandas_df = scalars_pandas_df.iloc[[4, 1, 2]] + sub_df = sub_df.set_index(["bytes_col", "numeric_col"]) + sub_pandas_df = sub_pandas_df.set_index(["bytes_col", "numeric_col"]) + drop_index = sub_df.index + drop_pandas_index = sub_pandas_df.index + + scalars_df = scalars_df.set_index(["bytes_col", "numeric_col"]) + scalars_pandas_df = scalars_pandas_df.set_index(["bytes_col", "numeric_col"]) + bf_result = scalars_df.drop(index=drop_index).to_pandas() + pd_result = scalars_pandas_df.drop(index=drop_pandas_index) + + pd.testing.assert_frame_equal(pd_result, bf_result) + + +def test_drop_labels_axis_0(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = scalars_pandas_df.drop(labels=[4, 1, 2], axis=0) + bf_result = scalars_df.drop(labels=[4, 1, 2], axis=0).to_pandas() + + pd.testing.assert_frame_equal(pd_result, bf_result) + + +def test_drop_index_and_columns(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = scalars_pandas_df.drop(index=[4, 1, 2], columns="int64_col") + bf_result = scalars_df.drop(index=[4, 1, 2], columns="int64_col").to_pandas() + + pd.testing.assert_frame_equal(pd_result, bf_result) + + +def test_rename(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name_dict = {"bool_col": 1.2345} + df_pandas = scalars_df.rename(columns=col_name_dict).to_pandas() + pd.testing.assert_index_equal( + df_pandas.columns, scalars_pandas_df.rename(columns=col_name_dict).columns + ) + + +def test_df_peek(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + peek_result = scalars_df.peek(n=3, force=False, allow_large_results=True) + + pd.testing.assert_index_equal(scalars_pandas_df.columns, peek_result.columns) + assert len(peek_result) == 3 + + +def test_df_peek_with_large_results_not_allowed(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + peek_result = scalars_df.peek(n=3, force=False, allow_large_results=False) + + pd.testing.assert_index_equal(scalars_pandas_df.columns, peek_result.columns) + assert len(peek_result) == 3 + + +def test_df_peek_filtered(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + peek_result = scalars_df[scalars_df.int64_col != 0].peek(n=3, force=False) + pd.testing.assert_index_equal(scalars_pandas_df.columns, peek_result.columns) + assert len(peek_result) == 3 + + +def test_df_peek_force_default(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + peek_result = scalars_df[["int64_col", "int64_too"]].cumsum().peek(n=3) + pd.testing.assert_index_equal( + scalars_pandas_df[["int64_col", "int64_too"]].columns, peek_result.columns + ) + assert len(peek_result) == 3 + + +def test_df_peek_reset_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + peek_result = ( + scalars_df[["int64_col", "int64_too"]].reset_index(drop=True).peek(n=3) + ) + pd.testing.assert_index_equal( + scalars_pandas_df[["int64_col", "int64_too"]].columns, peek_result.columns + ) + assert len(peek_result) == 3 + + +def test_repr_w_all_rows(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + # Remove columns with flaky formatting, like NUMERIC columns (which use the + # object dtype). Also makes a copy so that mutating the index name doesn't + # break other tests. + scalars_df = scalars_df.drop(columns=["numeric_col"]) + scalars_pandas_df = scalars_pandas_df.drop(columns=["numeric_col"]) + + # When there are 10 or fewer rows, the outputs should be identical. + actual = repr(scalars_df.head(10)) + + with display_options.pandas_repr(bigframes.options.display): + expected = repr(scalars_pandas_df.head(10)) + + assert actual == expected + + +def test_join_repr(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + scalars_df = ( + scalars_df[["int64_col"]] + .join(scalars_df.set_index("int64_col")[["int64_too"]]) + .sort_index() + ) + scalars_pandas_df = ( + scalars_pandas_df[["int64_col"]] + .join(scalars_pandas_df.set_index("int64_col")[["int64_too"]]) + .sort_index() + ) + # Pandas join result index name seems to depend on the index values in a way that bigframes can't match exactly + scalars_pandas_df.index.name = None + + actual = repr(scalars_df) + + with display_options.pandas_repr(bigframes.options.display): + expected = repr(scalars_pandas_df) + + assert actual == expected + + +def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): + scalars_df, _ = scalars_dfs + # get a pandas df of the expected format + df, _ = scalars_df._block.to_pandas() + pandas_df = df.set_axis(scalars_df._block.column_labels, axis=1) + pandas_df.index.name = scalars_df.index.name + + # When there are 10 or fewer rows, the outputs should be identical except for the extra note. + bundle = scalars_df.head(10)._repr_mimebundle_() + actual = bundle["text/html"] + + with display_options.pandas_repr(bigframes.options.display): + pandas_repr = pandas_df.head(10)._repr_html_() + + expected = ( + pandas_repr + + f"[{len(pandas_df.index)} rows x {len(pandas_df.columns)} columns in total]" + ) + assert actual == expected + + +def test_df_column_name_with_space(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name_dict = {"bool_col": "bool col"} + df_pandas = scalars_df.rename(columns=col_name_dict).to_pandas() + pd.testing.assert_index_equal( + df_pandas.columns, scalars_pandas_df.rename(columns=col_name_dict).columns + ) + + +def test_df_column_name_duplicate(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name_dict = {"int64_too": "int64_col"} + df_pandas = scalars_df.rename(columns=col_name_dict).to_pandas() + pd.testing.assert_index_equal( + df_pandas.columns, scalars_pandas_df.rename(columns=col_name_dict).columns + ) + + +def test_get_df_column_name_duplicate(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name_dict = {"int64_too": "int64_col"} + + bf_result = scalars_df.rename(columns=col_name_dict)["int64_col"].to_pandas() + pd_result = scalars_pandas_df.rename(columns=col_name_dict)["int64_col"] + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) + + +@pytest.mark.parametrize( + ("indices", "axis"), + [ + ([1, 3, 5], 0), + ([2, 4, 6], 1), + ([1, -3, -5, -6], "index"), + ([-2, -4, -6], "columns"), + ], +) +def test_take_df(scalars_dfs, indices, axis): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df.take(indices, axis=axis).to_pandas() + pd_result = scalars_pandas_df.take(indices, axis=axis) + + assert_frame_equal(bf_result, pd_result) + + +def test_filter_df(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_bool_series = scalars_df["bool_col"] + bf_result = scalars_df[bf_bool_series].to_pandas() + + pd_bool_series = scalars_pandas_df["bool_col"] + pd_result = scalars_pandas_df[pd_bool_series] + + assert_frame_equal(bf_result, pd_result) + + +def test_assign_new_column(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + kwargs = {"new_col": 2} + df = scalars_df.assign(**kwargs) + bf_result = df.to_pandas() + pd_result = scalars_pandas_df.assign(**kwargs) + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["new_col"] = pd_result["new_col"].astype("Int64") + + assert_frame_equal(bf_result, pd_result) + + +def test_assign_new_column_w_loc(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + bf_df.loc[:, "new_col"] = 2 + pd_df.loc[:, "new_col"] = 2 + bf_result = bf_df.to_pandas() + pd_result = pd_df + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["new_col"] = pd_result["new_col"].astype("Int64") + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("scalar",), + [ + (2.1,), + (None,), + ], +) +def test_assign_new_column_w_setitem(scalars_dfs, scalar): + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + bf_df["new_col"] = scalar + pd_df["new_col"] = scalar + bf_result = bf_df.to_pandas() + pd_result = pd_df + + # Convert default pandas dtypes `float64` to match BigQuery DataFrames dtypes. + pd_result["new_col"] = pd_result["new_col"].astype("Float64") + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_assign_new_column_w_setitem_dataframe(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + bf_df["int64_col"] = bf_df["int64_too"].to_frame() + pd_df["int64_col"] = pd_df["int64_too"].to_frame() + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_df["int64_col"] = pd_df["int64_col"].astype("Int64") + + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df) + + +def test_assign_new_column_w_setitem_dataframe_error(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + + with pytest.raises(ValueError): + bf_df["impossible_col"] = bf_df[["int64_too", "string_col"]] + with pytest.raises(ValueError): + pd_df["impossible_col"] = pd_df[["int64_too", "string_col"]] + + +def test_assign_new_column_w_setitem_list(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + bf_df["new_col"] = [9, 8, 7, 6, 5, 4, 3, 2, 1] + pd_df["new_col"] = [9, 8, 7, 6, 5, 4, 3, 2, 1] + bf_result = bf_df.to_pandas() + pd_result = pd_df + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["new_col"] = pd_result["new_col"].astype("Int64") + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_assign_new_column_w_setitem_list_repeated(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + bf_df["new_col"] = [9, 8, 7, 6, 5, 4, 3, 2, 1] + pd_df["new_col"] = [9, 8, 7, 6, 5, 4, 3, 2, 1] + bf_df["new_col_2"] = [1, 3, 2, 5, 4, 7, 6, 9, 8] + pd_df["new_col_2"] = [1, 3, 2, 5, 4, 7, 6, 9, 8] + bf_result = bf_df.to_pandas() + pd_result = pd_df + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["new_col"] = pd_result["new_col"].astype("Int64") + pd_result["new_col_2"] = pd_result["new_col_2"].astype("Int64") + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_assign_new_column_w_setitem_list_custom_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + + # set the custom index + pd_df = pd_df.set_index(["string_col", "int64_col"]) + bf_df = bf_df.set_index(["string_col", "int64_col"]) + + bf_df["new_col"] = [9, 8, 7, 6, 5, 4, 3, 2, 1] + pd_df["new_col"] = [9, 8, 7, 6, 5, 4, 3, 2, 1] + bf_result = bf_df.to_pandas() + pd_result = pd_df + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["new_col"] = pd_result["new_col"].astype("Int64") + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_assign_new_column_w_setitem_list_error(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + + with pytest.raises(ValueError): + pd_df["new_col"] = [1, 2, 3] # should be len 9, is 3 + with pytest.raises(ValueError): + bf_df["new_col"] = [1, 2, 3] + + +def test_assign_existing_column(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + kwargs = {"int64_col": 2} + df = scalars_df.assign(**kwargs) + bf_result = df.to_pandas() + pd_result = scalars_pandas_df.assign(**kwargs) + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["int64_col"] = pd_result["int64_col"].astype("Int64") + + assert_frame_equal(bf_result, pd_result) + + +def test_assign_listlike_to_empty_df(session): + empty_df = dataframe.DataFrame(session=session) + empty_pandas_df = pd.DataFrame() + + bf_result = empty_df.assign(new_col=[1, 2, 3]) + pd_result = empty_pandas_df.assign(new_col=[1, 2, 3]) + + pd_result["new_col"] = pd_result["new_col"].astype("Int64") + pd_result.index = pd_result.index.astype("Int64") + assert_frame_equal(bf_result.to_pandas(), pd_result) + + +def test_assign_to_empty_df_multiindex_error(session): + empty_df = dataframe.DataFrame(session=session) + empty_pandas_df = pd.DataFrame() + + empty_df["empty_col_1"] = typing.cast(series.Series, []) + empty_df["empty_col_2"] = typing.cast(series.Series, []) + empty_pandas_df["empty_col_1"] = [] + empty_pandas_df["empty_col_2"] = [] + empty_df = empty_df.set_index(["empty_col_1", "empty_col_2"]) + empty_pandas_df = empty_pandas_df.set_index(["empty_col_1", "empty_col_2"]) + + with pytest.raises(ValueError): + empty_df.assign(new_col=[1, 2, 3, 4, 5, 6, 7, 8, 9]) + with pytest.raises(ValueError): + empty_pandas_df.assign(new_col=[1, 2, 3, 4, 5, 6, 7, 8, 9]) + + +@pytest.mark.parametrize( + ("ordered"), + [ + (True), + (False), + ], +) +def test_assign_series(scalars_dfs, ordered): + scalars_df, scalars_pandas_df = scalars_dfs + column_name = "int64_col" + df = scalars_df.assign(new_col=scalars_df[column_name]) + bf_result = df.to_pandas(ordered=ordered) + pd_result = scalars_pandas_df.assign(new_col=scalars_pandas_df[column_name]) + + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) + + +def test_assign_series_overwrite(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + column_name = "int64_col" + df = scalars_df.assign(**{column_name: scalars_df[column_name] + 3}) + bf_result = df.to_pandas() + pd_result = scalars_pandas_df.assign( + **{column_name: scalars_pandas_df[column_name] + 3} + ) + + assert_frame_equal(bf_result, pd_result) + + +def test_assign_sequential(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + kwargs = {"int64_col": 2, "new_col": 3, "new_col2": 4} + df = scalars_df.assign(**kwargs) + bf_result = df.to_pandas() + pd_result = scalars_pandas_df.assign(**kwargs) + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["int64_col"] = pd_result["int64_col"].astype("Int64") + pd_result["new_col"] = pd_result["new_col"].astype("Int64") + pd_result["new_col2"] = pd_result["new_col2"].astype("Int64") + + assert_frame_equal(bf_result, pd_result) + + +# Require an index so that the self-join is consistent each time. +def test_assign_same_table_different_index_performs_self_join( + scalars_df_index, scalars_pandas_df_index +): + column_name = "int64_col" + bf_df = scalars_df_index.assign( + alternative_index=scalars_df_index["rowindex_2"] + 2 + ) + pd_df = scalars_pandas_df_index.assign( + alternative_index=scalars_pandas_df_index["rowindex_2"] + 2 + ) + bf_df_2 = bf_df.set_index("alternative_index") + pd_df_2 = pd_df.set_index("alternative_index") + bf_result = bf_df.assign(new_col=bf_df_2[column_name] * 10).to_pandas() + pd_result = pd_df.assign(new_col=pd_df_2[column_name] * 10) + + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +# Different table expression must have Index +def test_assign_different_df( + scalars_df_index, scalars_df_2_index, scalars_pandas_df_index +): + column_name = "int64_col" + df = scalars_df_index.assign(new_col=scalars_df_2_index[column_name]) + bf_result = df.to_pandas() + # Doesn't matter to pandas if it comes from the same DF or a different DF. + pd_result = scalars_pandas_df_index.assign( + new_col=scalars_pandas_df_index[column_name] + ) + + assert_frame_equal(bf_result, pd_result) + + +def test_assign_different_df_w_loc( + scalars_df_index, scalars_df_2_index, scalars_pandas_df_index +): + bf_df = scalars_df_index.copy() + bf_df2 = scalars_df_2_index.copy() + pd_df = scalars_pandas_df_index.copy() + assert "int64_col" in bf_df.columns + assert "int64_col" in pd_df.columns + bf_df.loc[:, "int64_col"] = bf_df2.loc[:, "int64_col"] + 1 + pd_df.loc[:, "int64_col"] = pd_df.loc[:, "int64_col"] + 1 + bf_result = bf_df.to_pandas() + pd_result = pd_df + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["int64_col"] = pd_result["int64_col"].astype("Int64") + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_assign_different_df_w_setitem( + scalars_df_index, scalars_df_2_index, scalars_pandas_df_index +): + bf_df = scalars_df_index.copy() + bf_df2 = scalars_df_2_index.copy() + pd_df = scalars_pandas_df_index.copy() + assert "int64_col" in bf_df.columns + assert "int64_col" in pd_df.columns + bf_df["int64_col"] = bf_df2["int64_col"] + 1 + pd_df["int64_col"] = pd_df["int64_col"] + 1 + bf_result = bf_df.to_pandas() + pd_result = pd_df + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["int64_col"] = pd_result["int64_col"].astype("Int64") + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_assign_callable_lambda(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + kwargs = {"new_col": lambda x: x["int64_col"] + x["int64_too"]} + df = scalars_df.assign(**kwargs) + bf_result = df.to_pandas() + pd_result = scalars_pandas_df.assign(**kwargs) + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result["new_col"] = pd_result["new_col"].astype("Int64") + + assert_frame_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("axis", "how", "ignore_index", "subset"), + [ + (0, "any", False, None), + (0, "any", True, None), + (0, "all", False, ["bool_col", "time_col"]), + (0, "any", False, ["bool_col", "time_col"]), + (0, "all", False, "time_col"), + (1, "any", False, None), + (1, "all", False, None), + ], +) +def test_df_dropna(scalars_dfs, axis, how, ignore_index, subset): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + df = scalars_df.dropna(axis=axis, how=how, ignore_index=ignore_index, subset=subset) + bf_result = df.to_pandas() + pd_result = scalars_pandas_df.dropna( + axis=axis, how=how, ignore_index=ignore_index, subset=subset + ) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_df_dropna_range_columns(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.copy() + scalars_pandas_df = scalars_pandas_df.copy() + scalars_df.columns = pandas.RangeIndex(0, len(scalars_df.columns)) + scalars_pandas_df.columns = pandas.RangeIndex(0, len(scalars_pandas_df.columns)) + + df = scalars_df.dropna() + bf_result = df.to_pandas() + pd_result = scalars_pandas_df.dropna() + + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_df_interpolate(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + columns = ["int64_col", "int64_too", "float64_col"] + bf_result = scalars_df[columns].interpolate().to_pandas() + # Pandas can only interpolate on "float64" columns + # https://github.com/pandas-dev/pandas/issues/40252 + pd_result = scalars_pandas_df[columns].astype("float64").interpolate() + + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + check_index_type=False, + check_dtype=False, + ) + + +@pytest.mark.parametrize( + "col, fill_value", + [ + (["int64_col", "float64_col"], 3), + (["string_col"], "A"), + (["datetime_col"], pd.Timestamp("2023-01-01")), + ], +) +def test_df_fillna(scalars_dfs, col, fill_value): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col].fillna(fill_value).to_pandas() + pd_result = scalars_pandas_df[col].fillna(fill_value) + + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +@pytest.mark.skip("b/436316698 unit test failed for python 3.12") +def test_df_ffill(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[["int64_col", "float64_col"]].ffill(limit=1).to_pandas() + pd_result = scalars_pandas_df[["int64_col", "float64_col"]].ffill(limit=1) + + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_df_bfill(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[["int64_col", "float64_col"]].bfill().to_pandas() + pd_result = scalars_pandas_df[["int64_col", "float64_col"]].bfill() + + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_apply_series_series_callable( + scalars_df_index, + scalars_pandas_df_index, +): + columns = ["int64_too", "int64_col"] + + def foo(series, arg1, arg2, *, kwarg1=0, kwarg2=0): + return series**2 + (arg1 * arg2 % 4) + (kwarg1 * kwarg2 % 7) + + bf_result = ( + scalars_df_index[columns] + .apply(foo, args=(33, 61), kwarg1=52, kwarg2=21) + .to_pandas() + ) + + pd_result = scalars_pandas_df_index[columns].apply( + foo, args=(33, 61), kwarg1=52, kwarg2=21 + ) + + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_apply_series_listlike_callable( + scalars_df_index, + scalars_pandas_df_index, +): + columns = ["int64_too", "int64_col"] + bf_result = ( + scalars_df_index[columns].apply(lambda x: [len(x), x.min(), 24]).to_pandas() + ) + + pd_result = scalars_pandas_df_index[columns].apply(lambda x: [len(x), x.min(), 24]) + + # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. + pd_result.index = pd_result.index.astype("Int64") + pd_result = pd_result.astype("Int64") + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_apply_series_scalar_callable( + scalars_df_index, + scalars_pandas_df_index, +): + columns = ["int64_too", "int64_col"] + bf_result = scalars_df_index[columns].apply(lambda x: x.sum()) + + pd_result = scalars_pandas_df_index[columns].apply(lambda x: x.sum()) + + pandas.testing.assert_series_equal(bf_result, pd_result) + + +def test_df_pipe( + scalars_df_index, + scalars_pandas_df_index, +): + columns = ["int64_too", "int64_col"] + + def foo(x: int, y: int, df): + return (df + x) % y + + bf_result = ( + scalars_df_index[columns] + .pipe((foo, "df"), x=7, y=9) + .pipe(lambda x: x**2) + .to_pandas() + ) + + pd_result = ( + scalars_pandas_df_index[columns] + .pipe((foo, "df"), x=7, y=9) + .pipe(lambda x: x**2) + ) + + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_df_keys( + scalars_df_index, + scalars_pandas_df_index, +): + pandas.testing.assert_index_equal( + scalars_df_index.keys(), scalars_pandas_df_index.keys() + ) + + +def test_df_iter( + scalars_df_index, + scalars_pandas_df_index, +): + for bf_i, df_i in zip(scalars_df_index, scalars_pandas_df_index): + assert bf_i == df_i + + +def test_iterrows( + scalars_df_index, + scalars_pandas_df_index, +): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df_index = scalars_df_index.add_suffix("_suffix", axis=1) + scalars_pandas_df_index = scalars_pandas_df_index.add_suffix("_suffix", axis=1) + for (bf_index, bf_series), (pd_index, pd_series) in zip( + scalars_df_index.iterrows(), scalars_pandas_df_index.iterrows() + ): + assert bf_index == pd_index + pandas.testing.assert_series_equal(bf_series, pd_series) + + +@pytest.mark.parametrize( + ( + "index", + "name", + ), + [ + ( + True, + "my_df", + ), + (False, None), + ], +) +def test_itertuples(scalars_df_index, index, name): + # Numeric has slightly different representation as a result of conversions. + bf_tuples = scalars_df_index.itertuples(index, name) + pd_tuples = scalars_df_index.to_pandas().itertuples(index, name) + for bf_tuple, pd_tuple in zip(bf_tuples, pd_tuples): + assert bf_tuple == pd_tuple + + +def test_df_cross_merge(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + left_columns = ["int64_col", "float64_col", "rowindex_2"] + right_columns = ["int64_col", "bool_col", "string_col", "rowindex_2"] + + left = scalars_df[left_columns] + # Offset the rows somewhat so that outer join can have an effect. + right = scalars_df[right_columns].assign(rowindex_2=scalars_df["rowindex_2"] + 2) + + bf_result = left.merge(right, "cross").to_pandas() + + pd_result = scalars_pandas_df[left_columns].merge( + scalars_pandas_df[right_columns].assign( + rowindex_2=scalars_pandas_df["rowindex_2"] + 2 + ), + "cross", + ) + pd.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) + + +@pytest.mark.parametrize( + ("merge_how",), + [ + ("inner",), + ("outer",), + ("left",), + ("right",), + ], +) +def test_df_merge(scalars_dfs, merge_how): + scalars_df, scalars_pandas_df = scalars_dfs + on = "rowindex_2" + left_columns = ["int64_col", "float64_col", "rowindex_2"] + right_columns = ["int64_col", "bool_col", "string_col", "rowindex_2"] + + left = scalars_df[left_columns] + # Offset the rows somewhat so that outer join can have an effect. + right = scalars_df[right_columns].assign(rowindex_2=scalars_df["rowindex_2"] + 2) + + df = left.merge(right, merge_how, on, sort=True) + bf_result = df.to_pandas() + + pd_result = scalars_pandas_df[left_columns].merge( + scalars_pandas_df[right_columns].assign( + rowindex_2=scalars_pandas_df["rowindex_2"] + 2 + ), + merge_how, + on, + sort=True, + ) + + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + + +@pytest.mark.parametrize( + ("left_on", "right_on"), + [ + (["int64_col", "rowindex_2"], ["int64_col", "rowindex_2"]), + (["rowindex_2", "int64_col"], ["int64_col", "rowindex_2"]), + # Polars engine is currently strict on join key types + # (["rowindex_2", "float64_col"], ["int64_col", "rowindex_2"]), + ], +) +def test_df_merge_multi_key(scalars_dfs, left_on, right_on): + scalars_df, scalars_pandas_df = scalars_dfs + left_columns = ["int64_col", "float64_col", "rowindex_2"] + right_columns = ["int64_col", "bool_col", "string_col", "rowindex_2"] + + left = scalars_df[left_columns] + # Offset the rows somewhat so that outer join can have an effect. + right = scalars_df[right_columns].assign(rowindex_2=scalars_df["rowindex_2"] + 2) + + df = left.merge(right, "outer", left_on=left_on, right_on=right_on, sort=True) + bf_result = df.to_pandas() + + pd_result = scalars_pandas_df[left_columns].merge( + scalars_pandas_df[right_columns].assign( + rowindex_2=scalars_pandas_df["rowindex_2"] + 2 + ), + "outer", + left_on=left_on, + right_on=right_on, + sort=True, + ) + + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + + +@pytest.mark.parametrize( + ("merge_how",), + [ + ("inner",), + ("outer",), + ("left",), + ("right",), + ], +) +def test_merge_custom_col_name(scalars_dfs, merge_how): + scalars_df, scalars_pandas_df = scalars_dfs + left_columns = ["int64_col", "float64_col"] + right_columns = ["int64_col", "bool_col", "string_col"] + on = "int64_col" + rename_columns = {"float64_col": "f64_col"} + + left = scalars_df[left_columns] + left = left.rename(columns=rename_columns) + right = scalars_df[right_columns] + df = left.merge(right, merge_how, on, sort=True) + bf_result = df.to_pandas() + + pandas_left_df = scalars_pandas_df[left_columns] + pandas_left_df = pandas_left_df.rename(columns=rename_columns) + pandas_right_df = scalars_pandas_df[right_columns] + pd_result = pandas_left_df.merge(pandas_right_df, merge_how, on, sort=True) + + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + + +@pytest.mark.parametrize( + ("merge_how",), + [ + ("inner",), + ("outer",), + ("left",), + ("right",), + ], +) +def test_merge_left_on_right_on(scalars_dfs, merge_how): + scalars_df, scalars_pandas_df = scalars_dfs + left_columns = ["int64_col", "float64_col", "int64_too"] + right_columns = ["int64_col", "bool_col", "string_col", "rowindex_2"] + + left = scalars_df[left_columns] + right = scalars_df[right_columns] + + df = left.merge( + right, merge_how, left_on="int64_too", right_on="rowindex_2", sort=True + ) + bf_result = df.to_pandas() + + pd_result = scalars_pandas_df[left_columns].merge( + scalars_pandas_df[right_columns], + merge_how, + left_on="int64_too", + right_on="rowindex_2", + sort=True, + ) + + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + + +def test_shape(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df.shape + pd_result = scalars_pandas_df.shape + + assert bf_result == pd_result + + +def test_len(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = len(scalars_df) + pd_result = len(scalars_pandas_df) + + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("n_rows",), + [ + (50,), + (10000,), + ], +) +def test_df_len_local(session, n_rows): + assert ( + len( + session.read_pandas( + pd.DataFrame(np.random.randint(1, 7, n_rows), columns=["one"]), + ) + ) + == n_rows + ) + + +def test_size(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df.size + pd_result = scalars_pandas_df.size + + assert bf_result == pd_result + + +def test_ndim(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df.ndim + pd_result = scalars_pandas_df.ndim + + assert bf_result == pd_result + + +def test_empty_false(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df.empty + pd_result = scalars_pandas_df.empty + + assert bf_result == pd_result + + +def test_empty_true_column_filter(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df[[]].empty + pd_result = scalars_pandas_df[[]].empty + + assert bf_result == pd_result + + +def test_empty_true_row_filter(scalars_dfs: Tuple[dataframe.DataFrame, pd.DataFrame]): + scalars_df, scalars_pandas_df = scalars_dfs + bf_bool: series.Series = typing.cast(series.Series, scalars_df["bool_col"]) + pd_bool: pd.Series = scalars_pandas_df["bool_col"] + bf_false = bf_bool.notna() & (bf_bool != bf_bool) + pd_false = pd_bool.notna() & (pd_bool != pd_bool) + + bf_result = scalars_df[bf_false].empty + pd_result = scalars_pandas_df[pd_false].empty + + assert pd_result + assert bf_result == pd_result + + +def test_empty_true_memtable(session: bigframes.Session): + bf_df = dataframe.DataFrame(session=session) + pd_df = pd.DataFrame() + + bf_result = bf_df.empty + pd_result = pd_df.empty + + assert pd_result + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("drop",), + ((True,), (False,)), +) +def test_reset_index(scalars_df_index, scalars_pandas_df_index, drop): + df = scalars_df_index.reset_index(drop=drop) + assert df.index.name is None + + bf_result = df.to_pandas() + pd_result = scalars_pandas_df_index.reset_index(drop=drop) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_reset_index_then_filter( + scalars_df_index, + scalars_pandas_df_index, +): + bf_filter = scalars_df_index["bool_col"].fillna(True) + bf_df = scalars_df_index.reset_index()[bf_filter] + bf_result = bf_df.to_pandas() + pd_filter = scalars_pandas_df_index["bool_col"].fillna(True) + pd_result = scalars_pandas_df_index.reset_index()[pd_filter] + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering and index keys + # post-filter will have gaps. + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_reset_index_with_unnamed_index( + scalars_df_index, + scalars_pandas_df_index, +): + scalars_df_index = scalars_df_index.copy() + scalars_pandas_df_index = scalars_pandas_df_index.copy() + + scalars_df_index.index.name = None + scalars_pandas_df_index.index.name = None + df = scalars_df_index.reset_index(drop=False) + assert df.index.name is None + + # reset_index(drop=False) creates a new column "index". + assert df.columns[0] == "index" + + bf_result = df.to_pandas() + pd_result = scalars_pandas_df_index.reset_index(drop=False) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_reset_index_with_unnamed_multiindex(session): + bf_df = dataframe.DataFrame( + ([1, 2, 3], [2, 5, 7]), + index=pd.MultiIndex.from_tuples([("a", "aa"), ("a", "aa")]), + session=session, + ) + pd_df = pd.DataFrame( + ([1, 2, 3], [2, 5, 7]), + index=pd.MultiIndex.from_tuples([("a", "aa"), ("a", "aa")]), + ) + + bf_df = bf_df.reset_index() + pd_df = pd_df.reset_index() + + assert pd_df.columns[0] == "level_0" + assert bf_df.columns[0] == "level_0" + assert pd_df.columns[1] == "level_1" + assert bf_df.columns[1] == "level_1" + + +def test_reset_index_with_unnamed_index_and_index_column( + scalars_df_index, + scalars_pandas_df_index, +): + scalars_df_index = scalars_df_index.copy() + scalars_pandas_df_index = scalars_pandas_df_index.copy() + + scalars_df_index.index.name = None + scalars_pandas_df_index.index.name = None + df = scalars_df_index.assign(index=scalars_df_index["int64_col"]).reset_index( + drop=False + ) + assert df.index.name is None + + # reset_index(drop=False) creates a new column "level_0" if the "index" column already exists. + assert df.columns[0] == "level_0" + + bf_result = df.to_pandas() + pd_result = scalars_pandas_df_index.assign( + index=scalars_pandas_df_index["int64_col"] + ).reset_index(drop=False) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("drop",), + ( + (True,), + (False,), + ), +) +@pytest.mark.parametrize( + ("append",), + ( + (True,), + (False,), + ), +) +@pytest.mark.parametrize( + ("index_column",), + (("int64_too",), ("string_col",), ("timestamp_col",)), +) +def test_set_index(scalars_dfs, index_column, drop, append): + scalars_df, scalars_pandas_df = scalars_dfs + df = scalars_df.set_index(index_column, append=append, drop=drop) + bf_result = df.to_pandas() + pd_result = scalars_pandas_df.set_index(index_column, append=append, drop=drop) + + # Sort to disambiguate when there are duplicate index labels. + # Note: Doesn't use assert_pandas_df_equal_ignore_ordering because we get + # "ValueError: 'timestamp_col' is both an index level and a column label, + # which is ambiguous" when trying to sort by a column with the same name as + # the index. + bf_result = bf_result.sort_values("rowindex_2") + pd_result = pd_result.sort_values("rowindex_2") + + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_set_index_key_error(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + with pytest.raises(KeyError): + scalars_pandas_df.set_index(["not_a_col"]) + with pytest.raises(KeyError): + scalars_df.set_index(["not_a_col"]) + + +@pytest.mark.parametrize( + ("ascending",), + ((True,), (False,)), +) +@pytest.mark.parametrize( + ("na_position",), + (("first",), ("last",)), +) +@pytest.mark.parametrize( + ("axis",), + ((0,), ("columns",)), +) +def test_sort_index(scalars_dfs, ascending, na_position, axis): + index_column = "int64_col" + scalars_df, scalars_pandas_df = scalars_dfs + df = scalars_df.set_index(index_column) + bf_result = df.sort_index( + ascending=ascending, na_position=na_position, axis=axis + ).to_pandas() + pd_result = scalars_pandas_df.set_index(index_column).sort_index( + ascending=ascending, na_position=na_position, axis=axis + ) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_dataframe_sort_index_inplace(scalars_dfs): + index_column = "int64_col" + scalars_df, scalars_pandas_df = scalars_dfs + df = scalars_df.copy().set_index(index_column) + df.sort_index(ascending=False, inplace=True) + bf_result = df.to_pandas() + + pd_result = scalars_pandas_df.set_index(index_column).sort_index(ascending=False) + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_df_abs(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + columns = ["int64_col", "int64_too", "float64_col"] + + bf_result = scalars_df[columns].abs() + pd_result = scalars_pandas_df[columns].abs() + + assert_dfs_equivalent(pd_result, bf_result) + + +def test_df_pos(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (+scalars_df[["int64_col", "numeric_col"]]).to_pandas() + pd_result = +scalars_pandas_df[["int64_col", "numeric_col"]] + + assert_frame_equal(pd_result, bf_result) + + +def test_df_neg(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (-scalars_df[["int64_col", "numeric_col"]]).to_pandas() + pd_result = -scalars_pandas_df[["int64_col", "numeric_col"]] + + assert_frame_equal(pd_result, bf_result) + + +def test_df_invert(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + columns = ["int64_col", "bool_col"] + + bf_result = (~scalars_df[columns]).to_pandas() + pd_result = ~scalars_pandas_df[columns] + + assert_frame_equal(bf_result, pd_result) + + +def test_df_isnull(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + columns = ["int64_col", "int64_too", "string_col", "bool_col"] + bf_result = scalars_df[columns].isnull().to_pandas() + pd_result = scalars_pandas_df[columns].isnull() + + # One of dtype mismatches to be documented. Here, the `bf_result.dtype` is + # `BooleanDtype` but the `pd_result.dtype` is `bool`. + pd_result["int64_col"] = pd_result["int64_col"].astype(pd.BooleanDtype()) + pd_result["int64_too"] = pd_result["int64_too"].astype(pd.BooleanDtype()) + pd_result["string_col"] = pd_result["string_col"].astype(pd.BooleanDtype()) + pd_result["bool_col"] = pd_result["bool_col"].astype(pd.BooleanDtype()) + + assert_frame_equal(bf_result, pd_result) + + +def test_df_notnull(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + columns = ["int64_col", "int64_too", "string_col", "bool_col"] + bf_result = scalars_df[columns].notnull().to_pandas() + pd_result = scalars_pandas_df[columns].notnull() + + # One of dtype mismatches to be documented. Here, the `bf_result.dtype` is + # `BooleanDtype` but the `pd_result.dtype` is `bool`. + pd_result["int64_col"] = pd_result["int64_col"].astype(pd.BooleanDtype()) + pd_result["int64_too"] = pd_result["int64_too"].astype(pd.BooleanDtype()) + pd_result["string_col"] = pd_result["string_col"].astype(pd.BooleanDtype()) + pd_result["bool_col"] = pd_result["bool_col"].astype(pd.BooleanDtype()) + + assert_frame_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("left_labels", "right_labels", "overwrite", "fill_value"), + [ + (["a", "b", "c"], ["c", "a", "b"], True, None), + (["a", "b", "c"], ["c", "a", "b"], False, None), + (["a", "b", "c"], ["a", "b", "c"], False, 2), + ], + ids=[ + "one_one_match_overwrite", + "one_one_match_no_overwrite", + "exact_match", + ], +) +def test_combine( + scalars_df_index, + scalars_df_2_index, + scalars_pandas_df_index, + left_labels, + right_labels, + overwrite, + fill_value, +): + if pd.__version__.startswith("1."): + pytest.skip("pd.NA vs NaN not handled well in pandas 1.x.") + columns = ["int64_too", "int64_col", "float64_col"] + + bf_df_a = scalars_df_index[columns] + bf_df_a.columns = left_labels + bf_df_b = scalars_df_2_index[columns] + bf_df_b.columns = right_labels + bf_result = bf_df_a.combine( + bf_df_b, + lambda x, y: x**2 + 2 * x * y + y**2, + overwrite=overwrite, + fill_value=fill_value, + ).to_pandas() + + pd_df_a = scalars_pandas_df_index[columns] + pd_df_a.columns = left_labels + pd_df_b = scalars_pandas_df_index[columns] + pd_df_b.columns = right_labels + pd_result = pd_df_a.combine( + pd_df_b, + lambda x, y: x**2 + 2 * x * y + y**2, + overwrite=overwrite, + fill_value=fill_value, + ) + + # Some dtype inconsistency for all-NULL columns + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("overwrite", "filter_func"), + [ + (True, None), + (False, None), + (True, lambda x: x.isna() | (x % 2 == 0)), + ], + ids=[ + "default", + "overwritefalse", + "customfilter", + ], +) +def test_df_update(overwrite, filter_func): + if pd.__version__.startswith("1."): + pytest.skip("dtype handled differently in pandas 1.x.") + + index1: pandas.Index = pandas.Index([1, 2, 3, 4], dtype="Int64") + + index2: pandas.Index = pandas.Index([1, 2, 4, 5], dtype="Int64") + pd_df1 = pandas.DataFrame( + {"a": [1, None, 3, 4], "b": [5, 6, None, 8]}, dtype="Int64", index=index1 + ) + pd_df2 = pandas.DataFrame( + {"a": [None, 20, 30, 40], "c": [90, None, 110, 120]}, + dtype="Int64", + index=index2, + ) + + bf_df1 = dataframe.DataFrame(pd_df1) + bf_df2 = dataframe.DataFrame(pd_df2) + + bf_df1.update(bf_df2, overwrite=overwrite, filter_func=filter_func) + pd_df1.update(pd_df2, overwrite=overwrite, filter_func=filter_func) + + pd.testing.assert_frame_equal(bf_df1.to_pandas(), pd_df1) + + +def test_df_idxmin(): + pd_df = pd.DataFrame( + {"a": [1, 2, 3], "b": [7, None, 3], "c": [4, 4, 4]}, index=["x", "y", "z"] + ) + bf_df = dataframe.DataFrame(pd_df) + + bf_result = bf_df.idxmin().to_pandas() + pd_result = pd_df.idxmin() + + pd.testing.assert_series_equal( + bf_result, pd_result, check_index_type=False, check_dtype=False + ) + + +def test_df_idxmax(): + pd_df = pd.DataFrame( + {"a": [1, 2, 3], "b": [7, None, 3], "c": [4, 4, 4]}, index=["x", "y", "z"] + ) + bf_df = dataframe.DataFrame(pd_df) + + bf_result = bf_df.idxmax().to_pandas() + pd_result = pd_df.idxmax() + + pd.testing.assert_series_equal( + bf_result, pd_result, check_index_type=False, check_dtype=False + ) + + +@pytest.mark.parametrize( + ("join", "axis"), + [ + ("outer", None), + ("outer", 0), + ("outer", 1), + ("left", 0), + ("right", 1), + ("inner", None), + ("inner", 1), + ], +) +def test_df_align(join, axis): + + index1: pandas.Index = pandas.Index([1, 2, 3, 4], dtype="Int64") + + index2: pandas.Index = pandas.Index([1, 2, 4, 5], dtype="Int64") + pd_df1 = pandas.DataFrame( + {"a": [1, None, 3, 4], "b": [5, 6, None, 8]}, dtype="Int64", index=index1 + ) + pd_df2 = pandas.DataFrame( + {"a": [None, 20, 30, 40], "c": [90, None, 110, 120]}, + dtype="Int64", + index=index2, + ) + + bf_df1 = dataframe.DataFrame(pd_df1) + bf_df2 = dataframe.DataFrame(pd_df2) + + bf_result1, bf_result2 = bf_df1.align(bf_df2, join=join, axis=axis) + pd_result1, pd_result2 = pd_df1.align(pd_df2, join=join, axis=axis) + + # Don't check dtype as pandas does unnecessary float conversion + assert isinstance(bf_result1, dataframe.DataFrame) and isinstance( + bf_result2, dataframe.DataFrame + ) + pd.testing.assert_frame_equal(bf_result1.to_pandas(), pd_result1, check_dtype=False) + pd.testing.assert_frame_equal(bf_result2.to_pandas(), pd_result2, check_dtype=False) + + +def test_combine_first( + scalars_df_index, + scalars_df_2_index, + scalars_pandas_df_index, +): + if pd.__version__.startswith("1."): + pytest.skip("pd.NA vs NaN not handled well in pandas 1.x.") + columns = ["int64_too", "int64_col", "float64_col"] + + bf_df_a = scalars_df_index[columns].iloc[0:6] + bf_df_a.columns = ["a", "b", "c"] + bf_df_b = scalars_df_2_index[columns].iloc[2:8] + bf_df_b.columns = ["b", "a", "d"] + bf_result = bf_df_a.combine_first(bf_df_b).to_pandas() + + pd_df_a = scalars_pandas_df_index[columns].iloc[0:6] + pd_df_a.columns = ["a", "b", "c"] + pd_df_b = scalars_pandas_df_index[columns].iloc[2:8] + pd_df_b.columns = ["b", "a", "d"] + pd_result = pd_df_a.combine_first(pd_df_b) + + # Some dtype inconsistency for all-NULL columns + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +def test_df_corr_w_invalid_parameters(scalars_dfs): + columns = ["int64_too", "int64_col", "float64_col"] + scalars_df, _ = scalars_dfs + + with pytest.raises(NotImplementedError): + scalars_df[columns].corr(method="kendall") + + with pytest.raises(NotImplementedError): + scalars_df[columns].corr(min_periods=1) + + +@pytest.mark.parametrize( + ("columns", "numeric_only"), + [ + (["bool_col", "int64_col", "float64_col"], True), + (["bool_col", "int64_col", "float64_col"], False), + (["bool_col", "int64_col", "float64_col", "string_col"], True), + pytest.param( + ["bool_col", "int64_col", "float64_col", "string_col"], + False, + marks=pytest.mark.xfail( + raises=NotImplementedError, + ), + ), + ], +) +def test_cov_w_numeric_only(scalars_dfs, columns, numeric_only): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[columns].cov(numeric_only=numeric_only).to_pandas() + pd_result = scalars_pandas_df[columns].cov(numeric_only=numeric_only) + # BigFrames and Pandas differ in their data type handling: + # - Column types: BigFrames uses Float64, Pandas uses float64. + # - Index types: BigFrames uses strign, Pandas uses object. + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) + # Only check row order in ordered mode. + pd.testing.assert_frame_equal( + bf_result, + pd_result, + check_dtype=False, + check_index_type=False, + check_like=~scalars_df._block.session._strictly_ordered, + ) + + +def test_df_corrwith_df(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + l_cols = ["int64_col", "float64_col", "int64_too"] + r_cols = ["int64_too", "float64_col"] + + bf_result = scalars_df[l_cols].corrwith(scalars_df[r_cols]).to_pandas() + pd_result = scalars_pandas_df[l_cols].corrwith(scalars_pandas_df[r_cols]) + + # BigFrames and Pandas differ in their data type handling: + # - Column types: BigFrames uses Float64, Pandas uses float64. + # - Index types: BigFrames uses strign, Pandas uses object. + pd.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_df_corrwith_df_numeric_only(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + l_cols = ["int64_col", "float64_col", "int64_too", "string_col"] + r_cols = ["int64_too", "float64_col", "bool_col"] + + bf_result = ( + scalars_df[l_cols].corrwith(scalars_df[r_cols], numeric_only=True).to_pandas() + ) + pd_result = scalars_pandas_df[l_cols].corrwith( + scalars_pandas_df[r_cols], numeric_only=True + ) + + # BigFrames and Pandas differ in their data type handling: + # - Column types: BigFrames uses Float64, Pandas uses float64. + # - Index types: BigFrames uses strign, Pandas uses object. + pd.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_df_corrwith_df_non_numeric_error(scalars_dfs): + scalars_df, _ = scalars_dfs + + l_cols = ["int64_col", "float64_col", "int64_too", "string_col"] + r_cols = ["int64_too", "float64_col", "bool_col"] + + with pytest.raises(NotImplementedError): + scalars_df[l_cols].corrwith(scalars_df[r_cols], numeric_only=False) + + +def test_df_corrwith_series(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + l_cols = ["int64_col", "float64_col", "int64_too"] + r_col = "float64_col" + + bf_result = scalars_df[l_cols].corrwith(scalars_df[r_col]).to_pandas() + pd_result = scalars_pandas_df[l_cols].corrwith(scalars_pandas_df[r_col]) + + # BigFrames and Pandas differ in their data type handling: + # - Column types: BigFrames uses Float64, Pandas uses float64. + # - Index types: BigFrames uses strign, Pandas uses object. + pd.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +@pytest.mark.parametrize( + ("op"), + [ + operator.add, + operator.sub, + operator.mul, + operator.truediv, + operator.floordiv, + operator.eq, + operator.ne, + operator.gt, + operator.ge, + operator.lt, + operator.le, + ], + ids=[ + "add", + "subtract", + "multiply", + "true_divide", + "floor_divide", + "eq", + "ne", + "gt", + "ge", + "lt", + "le", + ], +) +# TODO(garrettwu): deal with NA values +@pytest.mark.parametrize(("other_scalar"), [1, 2.5, 0, 0.0]) +@pytest.mark.parametrize(("reverse_operands"), [True, False]) +def test_scalar_binop(scalars_dfs, op, other_scalar, reverse_operands): + scalars_df, scalars_pandas_df = scalars_dfs + columns = ["int64_col", "float64_col"] + + maybe_reversed_op = (lambda x, y: op(y, x)) if reverse_operands else op + + bf_result = maybe_reversed_op(scalars_df[columns], other_scalar).to_pandas() + pd_result = maybe_reversed_op(scalars_pandas_df[columns], other_scalar) + + assert_frame_equal(bf_result, pd_result) + + +@pytest.mark.parametrize(("other_scalar"), [1, -2]) +def test_mod(scalars_dfs, other_scalar): + # Zero case excluded as pandas produces 0 result for Int64 inputs rather than NA/NaN. + # This is likely a pandas bug as mod 0 is undefined in other dtypes, and most programming languages. + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (scalars_df[["int64_col", "int64_too"]] % other_scalar).to_pandas() + pd_result = scalars_pandas_df[["int64_col", "int64_too"]] % other_scalar + + assert_frame_equal(bf_result, pd_result) + + +def test_scalar_binop_str_exception(scalars_dfs): + scalars_df, _ = scalars_dfs + columns = ["string_col"] + with pytest.raises(TypeError, match="Cannot add dtypes"): + (scalars_df[columns] + 1).to_pandas() + + +@pytest.mark.parametrize( + ("op"), + [ + (lambda x, y: x.add(y, axis="index")), + (lambda x, y: x.radd(y, axis="index")), + (lambda x, y: x.sub(y, axis="index")), + (lambda x, y: x.rsub(y, axis="index")), + (lambda x, y: x.mul(y, axis="index")), + (lambda x, y: x.rmul(y, axis="index")), + (lambda x, y: x.truediv(y, axis="index")), + (lambda x, y: x.rtruediv(y, axis="index")), + (lambda x, y: x.floordiv(y, axis="index")), + (lambda x, y: x.floordiv(y, axis="index")), + (lambda x, y: x.gt(y, axis="index")), + (lambda x, y: x.ge(y, axis="index")), + (lambda x, y: x.lt(y, axis="index")), + (lambda x, y: x.le(y, axis="index")), + ], + ids=[ + "add", + "radd", + "sub", + "rsub", + "mul", + "rmul", + "truediv", + "rtruediv", + "floordiv", + "rfloordiv", + "gt", + "ge", + "lt", + "le", + ], +) +def test_series_binop_axis_index( + scalars_dfs, + op, +): + scalars_df, scalars_pandas_df = scalars_dfs + df_columns = ["int64_col", "float64_col"] + series_column = "int64_too" + + bf_result = op(scalars_df[df_columns], scalars_df[series_column]).to_pandas() + pd_result = op(scalars_pandas_df[df_columns], scalars_pandas_df[series_column]) + + assert_frame_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("input"), + [ + ((1000, 2000, 3000)), + (pd.Index([1000, 2000, 3000])), + (pd.Series((1000, 2000), index=["int64_too", "float64_col"])), + ], + ids=[ + "tuple", + "pd_index", + "pd_series", + ], +) +def test_listlike_binop_axis_1_in_memory_data(scalars_dfs, input): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + df_columns = ["int64_col", "float64_col", "int64_too"] + + bf_result = scalars_df[df_columns].add(input, axis=1).to_pandas() + if hasattr(input, "to_pandas"): + input = input.to_pandas() + pd_result = scalars_pandas_df[df_columns].add(input, axis=1) + + assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +def test_df_reverse_binop_pandas(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + pd_series = pd.Series([100, 200, 300]) + + df_columns = ["int64_col", "float64_col", "int64_too"] + + bf_result = pd_series + scalars_df[df_columns].to_pandas() + pd_result = pd_series + scalars_pandas_df[df_columns] + + assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +def test_listlike_binop_axis_1_bf_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + df_columns = ["int64_col", "float64_col", "int64_too"] + + bf_result = ( + scalars_df[df_columns] + .add(bf_indexes.Index([1000, 2000, 3000]), axis=1) + .to_pandas() + ) + pd_result = scalars_pandas_df[df_columns].add(pd.Index([1000, 2000, 3000]), axis=1) + + assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +def test_binop_with_self_aggregate(session, scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + df_columns = ["int64_col", "float64_col", "int64_too"] + + bf_df = scalars_df[df_columns] + bf_result = (bf_df - bf_df.mean()).to_pandas() + + pd_df = scalars_pandas_df[df_columns] + pd_result = pd_df - pd_df.mean() + + assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("left_labels", "right_labels"), + [ + (["a", "a", "b"], ["c", "c", "d"]), + (["a", "b", "c"], ["c", "a", "b"]), + (["a", "c", "c"], ["c", "a", "c"]), + (["a", "b", "c"], ["a", "b", "c"]), + ], + ids=[ + "no_overlap", + "one_one_match", + "multi_match", + "exact_match", + ], +) +def test_binop_df_df_binary_op( + scalars_df_index, + scalars_df_2_index, + scalars_pandas_df_index, + left_labels, + right_labels, +): + if pd.__version__.startswith("1."): + pytest.skip("pd.NA vs NaN not handled well in pandas 1.x.") + columns = ["int64_too", "int64_col", "float64_col"] + + bf_df_a = scalars_df_index[columns] + bf_df_a.columns = left_labels + bf_df_b = scalars_df_2_index[columns] + bf_df_b.columns = right_labels + bf_result = (bf_df_a - bf_df_b).to_pandas() + + pd_df_a = scalars_pandas_df_index[columns] + pd_df_a.columns = left_labels + pd_df_b = scalars_pandas_df_index[columns] + pd_df_b.columns = right_labels + pd_result = pd_df_a - pd_df_b + + # Some dtype inconsistency for all-NULL columns + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + + +# Differnt table will only work for explicit index, since default index orders are arbitrary. +@pytest.mark.parametrize( + ("ordered"), + [ + (True), + (False), + ], +) +def test_series_binop_add_different_table( + scalars_df_index, scalars_pandas_df_index, scalars_df_2_index, ordered +): + df_columns = ["int64_col", "float64_col"] + series_column = "int64_too" + + bf_result = ( + scalars_df_index[df_columns] + .add(scalars_df_2_index[series_column], axis="index") + .to_pandas(ordered=ordered) + ) + pd_result = scalars_pandas_df_index[df_columns].add( + scalars_pandas_df_index[series_column], axis="index" + ) + + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) + + +# TODO(garrettwu): Test series binop with different index + +all_joins = pytest.mark.parametrize( + ("how",), + (("outer",), ("left",), ("right",), ("inner",), ("cross",)), +) + + +@all_joins +def test_join_same_table(scalars_dfs, how): + bf_df, pd_df = scalars_dfs + if not bf_df._session._strictly_ordered and how == "cross": + pytest.skip("Cross join not supported in partial ordering mode.") + + bf_df_a = bf_df.set_index("int64_too")[["string_col", "int64_col"]] + bf_df_a = bf_df_a.sort_index() + + bf_df_b = bf_df.set_index("int64_too")[["float64_col"]] + bf_df_b = bf_df_b[bf_df_b.float64_col > 0] + bf_df_b = bf_df_b.sort_values("float64_col") + + bf_result = bf_df_a.join(bf_df_b, how=how).to_pandas() + + pd_df_a = pd_df.set_index("int64_too")[["string_col", "int64_col"]].sort_index() + pd_df_a = pd_df_a.sort_index() + + pd_df_b = pd_df.set_index("int64_too")[["float64_col"]] + pd_df_b = pd_df_b[pd_df_b.float64_col > 0] + pd_df_b = pd_df_b.sort_values("float64_col") + + pd_result = pd_df_a.join(pd_df_b, how=how) + + assert_frame_equal(bf_result, pd_result, ignore_order=True) + + +@all_joins +def test_join_different_table( + scalars_df_index, scalars_df_2_index, scalars_pandas_df_index, how +): + bf_df_a = scalars_df_index[["string_col", "int64_col"]] + bf_df_b = scalars_df_2_index.dropna()[["float64_col"]] + bf_result = bf_df_a.join(bf_df_b, how=how).to_pandas() + pd_df_a = scalars_pandas_df_index[["string_col", "int64_col"]] + pd_df_b = scalars_pandas_df_index.dropna()[["float64_col"]] + pd_result = pd_df_a.join(pd_df_b, how=how) + assert_frame_equal(bf_result, pd_result, ignore_order=True) + + +@all_joins +def test_join_raise_when_param_on_duplicate_with_column(scalars_df_index, how): + if how == "cross": + return + bf_df_a = scalars_df_index[["string_col", "int64_col"]].rename( + columns={"int64_col": "string_col"} + ) + bf_df_b = scalars_df_index.dropna()["string_col"] + with pytest.raises( + ValueError, match="The column label 'string_col' is not unique." + ): + bf_df_a.join(bf_df_b, on="string_col", how=how, lsuffix="_l", rsuffix="_r") + + +def test_join_duplicate_columns_raises_value_error(scalars_dfs): + scalars_df, _ = scalars_dfs + df_a = scalars_df[["string_col", "float64_col"]] + df_b = scalars_df[["float64_col"]] + with pytest.raises(ValueError, match="columns overlap but no suffix specified"): + df_a.join(df_b, how="outer") + + +@all_joins +def test_join_param_on_duplicate_with_index_raises_value_error(scalars_df_index, how): + if how == "cross": + return + bf_df_a = scalars_df_index[["string_col"]] + bf_df_a.index.name = "string_col" + bf_df_b = scalars_df_index.dropna()["string_col"] + with pytest.raises( + ValueError, + match="'string_col' is both an index level and a column label, which is ambiguous.", + ): + bf_df_a.join(bf_df_b, on="string_col", how=how, lsuffix="_l", rsuffix="_r") + + +@all_joins +def test_join_param_on(scalars_dfs, how): + bf_df, pd_df = scalars_dfs + + bf_df_a = bf_df[["string_col", "int64_col", "rowindex_2"]] + bf_df_a = bf_df_a.assign(rowindex_2=bf_df_a["rowindex_2"] + 2) + bf_df_b = bf_df[["float64_col"]] + + if how == "cross": + with pytest.raises(ValueError, match="'on' is not supported for cross join."): + bf_df_a.join(bf_df_b, on="rowindex_2", how=how) + else: + bf_result = bf_df_a.join(bf_df_b, on="rowindex_2", how=how).to_pandas() + + pd_df_a = pd_df[["string_col", "int64_col", "rowindex_2"]] + pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) + pd_df_b = pd_df[["float64_col"]] + pd_result = pd_df_a.join(pd_df_b, on="rowindex_2", how=how) + assert_frame_equal(bf_result, pd_result, ignore_order=True) + + +@all_joins +def test_df_join_series(scalars_dfs, how): + bf_df, pd_df = scalars_dfs + + bf_df_a = bf_df[["string_col", "int64_col", "rowindex_2"]] + bf_df_a = bf_df_a.assign(rowindex_2=bf_df_a["rowindex_2"] + 2) + bf_series_b = bf_df["float64_col"] + + if how == "cross": + with pytest.raises(ValueError): + bf_df_a.join(bf_series_b, on="rowindex_2", how=how) + else: + bf_result = bf_df_a.join(bf_series_b, on="rowindex_2", how=how).to_pandas() + + pd_df_a = pd_df[["string_col", "int64_col", "rowindex_2"]] + pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) + pd_series_b = pd_df["float64_col"] + pd_result = pd_df_a.join(pd_series_b, on="rowindex_2", how=how) + assert_frame_equal(bf_result, pd_result, ignore_order=True) + + +@pytest.mark.parametrize( + ("by", "ascending", "na_position"), + [ + ("int64_col", True, "first"), + (["bool_col", "int64_col"], True, "last"), + ("int64_col", False, "first"), + (["bool_col", "int64_col"], [False, True], "last"), + (["bool_col", "int64_col"], [True, False], "first"), + ], +) +def test_dataframe_sort_values( + scalars_df_index, scalars_pandas_df_index, by, ascending, na_position +): + # Test needs values to be unique + bf_result = scalars_df_index.sort_values( + by, ascending=ascending, na_position=na_position + ).to_pandas() + pd_result = scalars_pandas_df_index.sort_values( + by, ascending=ascending, na_position=na_position + ) + + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("by", "ascending", "na_position"), + [ + ("int64_col", True, "first"), + (["bool_col", "int64_col"], True, "last"), + ], +) +def test_dataframe_sort_values_inplace( + scalars_df_index, scalars_pandas_df_index, by, ascending, na_position +): + # Test needs values to be unique + bf_sorted = scalars_df_index.copy() + bf_sorted.sort_values( + by, ascending=ascending, na_position=na_position, inplace=True + ) + bf_result = bf_sorted.to_pandas() + pd_result = scalars_pandas_df_index.sort_values( + by, ascending=ascending, na_position=na_position + ) + + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_dataframe_sort_values_invalid_input(scalars_df_index): + with pytest.raises(KeyError): + scalars_df_index.sort_values(by=scalars_df_index["int64_col"]) + + +def test_dataframe_sort_values_stable(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + scalars_df_index.sort_values("int64_col", kind="stable") + .sort_values("bool_col", kind="stable") + .to_pandas() + ) + pd_result = scalars_pandas_df_index.sort_values( + "int64_col", kind="stable" + ).sort_values("bool_col", kind="stable") + + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("operator", "columns"), + [ + pytest.param(lambda x: x.cumsum(), ["float64_col", "int64_too"]), + # pytest.param(lambda x: x.cumprod(), ["float64_col", "int64_too"]), + pytest.param( + lambda x: x.cumprod(), + ["string_col"], + marks=pytest.mark.xfail( + raises=ValueError, + ), + ), + ], + ids=[ + "cumsum", + # "cumprod", + "non-numeric", + ], +) +def test_dataframe_numeric_analytic_op( + scalars_df_index, scalars_pandas_df_index, operator, columns +): + # TODO: Add nullable ints (pandas 1.x has poor behavior on these) + bf_series = operator(scalars_df_index[columns]) + pd_series = operator(scalars_pandas_df_index[columns]) + bf_result = bf_series.to_pandas() + pd.testing.assert_frame_equal(pd_series, bf_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("operator"), + [ + (lambda x: x.cummin()), + (lambda x: x.cummax()), + (lambda x: x.shift(2)), + (lambda x: x.shift(-2)), + ], + ids=[ + "cummin", + "cummax", + "shiftpostive", + "shiftnegative", + ], +) +def test_dataframe_general_analytic_op( + scalars_df_index, scalars_pandas_df_index, operator +): + col_names = ["int64_too", "float64_col", "int64_col", "bool_col"] + bf_series = operator(scalars_df_index[col_names]) + pd_series = operator(scalars_pandas_df_index[col_names]) + bf_result = bf_series.to_pandas() + pd.testing.assert_frame_equal( + pd_series, + bf_result, + ) + + +@pytest.mark.parametrize( + ("periods",), + [ + (1,), + (2,), + (-1,), + ], +) +def test_dataframe_diff(scalars_df_index, scalars_pandas_df_index, periods): + col_names = ["int64_too", "float64_col", "int64_col"] + bf_result = scalars_df_index[col_names].diff(periods=periods).to_pandas() + pd_result = scalars_pandas_df_index[col_names].diff(periods=periods) + pd.testing.assert_frame_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ("periods",), + [ + (1,), + (2,), + (-1,), + ], +) +def test_dataframe_pct_change(scalars_df_index, scalars_pandas_df_index, periods): + col_names = ["int64_too", "float64_col", "int64_col"] + bf_result = scalars_df_index[col_names].pct_change(periods=periods).to_pandas() + # pandas 3.0 does not automatically ffill anymore + pd_result = scalars_pandas_df_index[col_names].ffill().pct_change(periods=periods) + assert_frame_equal( + pd_result, + bf_result, + nulls_are_nan=True, + ) + + +def test_dataframe_agg_single_string(scalars_dfs): + numeric_cols = ["int64_col", "int64_too", "float64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df[numeric_cols].agg("sum").to_pandas() + pd_result = scalars_pandas_df[numeric_cols].agg("sum") + + assert bf_result.dtype == "Float64" + pd.testing.assert_series_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + +@pytest.mark.parametrize( + ("agg",), + ( + ("sum",), + ("size",), + ), +) +def test_dataframe_agg_int_single_string(scalars_dfs, agg): + numeric_cols = ["int64_col", "int64_too", "bool_col"] + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df[numeric_cols].agg(agg).to_pandas() + pd_result = scalars_pandas_df[numeric_cols].agg(agg) + + assert bf_result.dtype == "Int64" + pd.testing.assert_series_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + +def test_dataframe_agg_multi_string(scalars_dfs): + numeric_cols = ["int64_col", "int64_too", "float64_col"] + aggregations = [ + "sum", + "mean", + "median", + "std", + "var", + "min", + "max", + "nunique", + "count", + ] + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[numeric_cols].agg(aggregations) + pd_result = scalars_pandas_df[numeric_cols].agg(aggregations) + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + pd_result = pd_result.astype("Float64") + + # Drop median, as it's an approximation. + bf_median = bf_result.loc["median", :] + bf_result = bf_result.drop(labels=["median"]) + pd_result = pd_result.drop(labels=["median"]) + + assert_dfs_equivalent(pd_result, bf_result, check_index_type=False) + + # Double-check that median is at least plausible. + assert ( + (bf_result.loc["min", :] <= bf_median) & (bf_median <= bf_result.loc["max", :]) + ).all() + + +def test_dataframe_agg_int_multi_string(scalars_dfs): + numeric_cols = ["int64_col", "int64_too", "bool_col"] + aggregations = [ + "sum", + "nunique", + "count", + "size", + ] + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[numeric_cols].agg(aggregations).to_pandas() + pd_result = scalars_pandas_df[numeric_cols].agg(aggregations) + + for dtype in bf_result.dtypes: + assert dtype == "Int64" + + # Pandas may produce narrower numeric types + # Pandas has object index type + pd.testing.assert_frame_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + +def test_df_transpose(): + # Include some floats to ensure type coercion + values = [[0, 3.5, True], [1, 4.5, False], [2, 6.5, None]] + # Test complex case of both axes being multi-indices with non-unique elements + + columns: pandas.Index = pd.Index( + ["A", "B", "A"], dtype=pd.StringDtype(storage="pyarrow") + ) + columns_multi = pd.MultiIndex.from_arrays([columns, columns], names=["c1", "c2"]) + + index: pandas.Index = pd.Index( + ["b", "a", "a"], dtype=pd.StringDtype(storage="pyarrow") + ) + rows_multi = pd.MultiIndex.from_arrays([index, index], names=["r1", "r2"]) + + pd_df = pandas.DataFrame(values, index=rows_multi, columns=columns_multi) + bf_df = dataframe.DataFrame(values, index=rows_multi, columns=columns_multi) + + pd_result = pd_df.T + bf_result = bf_df.T.to_pandas() + + assert_frame_equal(pd_result, bf_result, check_dtype=False, nulls_are_nan=True) + + +def test_df_transpose_error(): + with pytest.raises(TypeError, match="Cannot coerce.*to a common type."): + dataframe.DataFrame([[1, "hello"], [2, "world"]]).transpose() + + +def test_df_transpose_repeated_uses_cache(): + bf_df = dataframe.DataFrame([[1, 2.5], [2, 3.5]]) + pd_df = pandas.DataFrame([[1, 2.5], [2, 3.5]]) + # Transposing many times so that operation will fail from complexity if not using cache + for i in range(10): + # Cache still works even with simple scalar binop + bf_df = bf_df.transpose() + i + pd_df = pd_df.transpose() + i + + pd.testing.assert_frame_equal( + pd_df, bf_df.to_pandas(), check_dtype=False, check_index_type=False + ) + + +def test_df_stack(scalars_dfs): + if pandas.__version__.startswith("1.") or pandas.__version__.startswith("2.0"): + pytest.skip("pandas <2.1 uses different stack implementation") + scalars_df, scalars_pandas_df = scalars_dfs + # To match bigquery dataframes + scalars_pandas_df = scalars_pandas_df.copy() + scalars_pandas_df.columns = scalars_pandas_df.columns.astype("string[pyarrow]") + # Can only stack identically-typed columns + columns = ["int64_col", "int64_too", "rowindex_2"] + + bf_result = scalars_df[columns].stack().to_pandas() + pd_result = scalars_pandas_df[columns].stack(future_stack=True) + + # Pandas produces NaN, where bq dataframes produces pd.NA + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +def test_df_melt_default(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + # To match bigquery dataframes + scalars_pandas_df = scalars_pandas_df.copy() + scalars_pandas_df.columns = scalars_pandas_df.columns.astype("string[pyarrow]") + # Can only stack identically-typed columns + columns = ["int64_col", "int64_too", "rowindex_2"] + + bf_result = scalars_df[columns].melt().to_pandas() + pd_result = scalars_pandas_df[columns].melt() + + # Pandas produces int64 index, Bigframes produces Int64 (nullable) + pd.testing.assert_frame_equal( + bf_result, + pd_result, + check_index_type=False, + check_dtype=False, + ) + + +def test_df_melt_parameterized(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + # To match bigquery dataframes + scalars_pandas_df = scalars_pandas_df.copy() + scalars_pandas_df.columns = scalars_pandas_df.columns.astype("string[pyarrow]") + # Can only stack identically-typed columns + + bf_result = scalars_df.melt( + var_name="alice", + value_name="bob", + id_vars=["string_col"], + value_vars=["int64_col", "int64_too"], + ).to_pandas() + pd_result = scalars_pandas_df.melt( + var_name="alice", + value_name="bob", + id_vars=["string_col"], + value_vars=["int64_col", "int64_too"], + ) + + # Pandas produces int64 index, Bigframes produces Int64 (nullable) + pd.testing.assert_frame_equal( + bf_result, pd_result, check_index_type=False, check_dtype=False + ) + + +@pytest.mark.parametrize( + ("ordered"), + [ + (True), + (False), + ], +) +def test_df_unstack(scalars_dfs, ordered): + scalars_df, scalars_pandas_df = scalars_dfs + # To match bigquery dataframes + scalars_pandas_df = scalars_pandas_df.copy() + scalars_pandas_df.columns = scalars_pandas_df.columns.astype("string[pyarrow]") + # Can only stack identically-typed columns + columns = [ + "rowindex_2", + "int64_col", + "int64_too", + ] + + # unstack on mono-index produces series + bf_result = scalars_df[columns].unstack().to_pandas(ordered=ordered) + pd_result = scalars_pandas_df[columns].unstack() + + # Pandas produces NaN, where bq dataframes produces pd.NA + assert_series_equal( + bf_result, pd_result, check_dtype=False, ignore_order=not ordered + ) + + +def test_ipython_key_completions_with_drop(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_names = "string_col" + bf_dataframe = scalars_df.drop(columns=col_names) + pd_dataframe = scalars_pandas_df.drop(columns=col_names) + expected = pd_dataframe.columns.tolist() + + results = bf_dataframe._ipython_key_completions_() + + assert col_names not in results + assert results == expected + # _ipython_key_completions_ is called with square brackets + # so only column names are relevant with tab completion + assert "to_gbq" not in results + assert "merge" not in results + assert "drop" not in results + + +def test_ipython_key_completions_with_rename(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name_dict = {"string_col": "a_renamed_column"} + bf_dataframe = scalars_df.rename(columns=col_name_dict) + pd_dataframe = scalars_pandas_df.rename(columns=col_name_dict) + expected = pd_dataframe.columns.tolist() + + results = bf_dataframe._ipython_key_completions_() + + assert "string_col" not in results + assert "a_renamed_column" in results + assert results == expected + # _ipython_key_completions_ is called with square brackets + # so only column names are relevant with tab completion + assert "to_gbq" not in results + assert "merge" not in results + assert "drop" not in results + + +def test__dir__with_drop(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_names = "string_col" + bf_dataframe = scalars_df.drop(columns=col_names) + pd_dataframe = scalars_pandas_df.drop(columns=col_names) + expected = pd_dataframe.columns.tolist() + + results = dir(bf_dataframe) + + assert col_names not in results + assert frozenset(expected) <= frozenset(results) + # __dir__ is called with a '.' and displays all methods, columns names, etc. + assert "to_gbq" in results + assert "merge" in results + assert "drop" in results + + +def test__dir__with_rename(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name_dict = {"string_col": "a_renamed_column"} + bf_dataframe = scalars_df.rename(columns=col_name_dict) + pd_dataframe = scalars_pandas_df.rename(columns=col_name_dict) + expected = pd_dataframe.columns.tolist() + + results = dir(bf_dataframe) + + assert "string_col" not in results + assert "a_renamed_column" in results + assert frozenset(expected) <= frozenset(results) + # __dir__ is called with a '.' and displays all methods, columns names, etc. + assert "to_gbq" in results + assert "merge" in results + assert "drop" in results + + +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (0, 0, None), + (None, None, None), + (1, None, None), + (None, 4, None), + (None, None, 2), + (None, 50000000000, 1), + (5, 4, None), + (3, None, 2), + (1, 7, 2), + (1, 7, 50000000000), + ], +) +def test_iloc_slice(scalars_df_index, scalars_pandas_df_index, start, stop, step): + bf_result = scalars_df_index.iloc[start:stop:step].to_pandas() + pd_result = scalars_pandas_df_index.iloc[start:stop:step] + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_iloc_slice_zero_step(scalars_df_index): + with pytest.raises(ValueError): + scalars_df_index.iloc[0:0:0] + + +@pytest.mark.parametrize( + ("ordered"), + [ + (True), + (False), + ], +) +def test_iloc_slice_nested(scalars_df_index, scalars_pandas_df_index, ordered): + bf_result = scalars_df_index.iloc[1:].iloc[1:].to_pandas(ordered=ordered) + pd_result = scalars_pandas_df_index.iloc[1:].iloc[1:] + + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) + + +@pytest.mark.parametrize( + "index", + [0, 5, -2, (2,)], +) +def test_iloc_single_integer(scalars_df_index, scalars_pandas_df_index, index): + bf_result = scalars_df_index.iloc[index] + pd_result = scalars_pandas_df_index.iloc[index] + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + "index", + [(2, 5), (5, 0), (0, 0)], +) +def test_iloc_tuple(scalars_df_index, scalars_pandas_df_index, index): + bf_result = scalars_df_index.iloc[index] + pd_result = scalars_pandas_df_index.iloc[index] + + assert bf_result == pd_result + + +@pytest.mark.parametrize( + "index", + [(slice(None), [1, 2, 3]), (slice(1, 7, 2), [2, 5, 3])], +) +def test_iloc_tuple_multi_columns(scalars_df_index, scalars_pandas_df_index, index): + bf_result = scalars_df_index.iloc[index].to_pandas() + pd_result = scalars_pandas_df_index.iloc[index] + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_iloc_tuple_multi_columns_single_row(scalars_df_index, scalars_pandas_df_index): + index = (2, [2, 1, 3, -4]) + bf_result = scalars_df_index.iloc[index] + pd_result = scalars_pandas_df_index.iloc[index] + pd.testing.assert_series_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("index", "error"), + [ + ((1, 1, 1), pd.errors.IndexingError), + (("asd", "asd", "asd"), pd.errors.IndexingError), + (("asd"), TypeError), + ], +) +def test_iloc_tuple_errors(scalars_df_index, scalars_pandas_df_index, index, error): + with pytest.raises(error): + scalars_df_index.iloc[index] + with pytest.raises(error): + scalars_pandas_df_index.iloc[index] + + +@pytest.mark.parametrize( + "index", + [(2, 5), (5, 0), (0, 0)], +) +def test_iat(scalars_df_index, scalars_pandas_df_index, index): + bf_result = scalars_df_index.iat[index] + pd_result = scalars_pandas_df_index.iat[index] + + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("index", "error"), + [ + (0, TypeError), + ("asd", ValueError), + ((1, 2, 3), TypeError), + (("asd", "asd"), ValueError), + ], +) +def test_iat_errors(scalars_df_index, scalars_pandas_df_index, index, error): + with pytest.raises(error): + scalars_pandas_df_index.iat[index] + with pytest.raises(error): + scalars_df_index.iat[index] + + +def test_iloc_single_integer_out_of_bound_error( + scalars_df_index, scalars_pandas_df_index +): + with pytest.raises(IndexError, match="single positional indexer is out-of-bounds"): + scalars_df_index.iloc[99] + + +def test_loc_bool_series(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.loc[scalars_df_index.bool_col].to_pandas() + pd_result = scalars_pandas_df_index.loc[scalars_pandas_df_index.bool_col] + + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_loc_select_column(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.loc[:, "int64_col"].to_pandas() + pd_result = scalars_pandas_df_index.loc[:, "int64_col"] + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_loc_select_with_column_condition(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.loc[:, scalars_df_index.dtypes == "Int64"].to_pandas() + pd_result = scalars_pandas_df_index.loc[ + :, scalars_pandas_df_index.dtypes == "Int64" + ] + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_loc_select_with_column_condition_bf_series( + scalars_df_index, scalars_pandas_df_index +): + # (b/347072677) GEOGRAPH type doesn't support DISTINCT op + columns = [ + item for item in scalars_pandas_df_index.columns if item != "geography_col" + ] + scalars_df_index = scalars_df_index[columns] + scalars_pandas_df_index = scalars_pandas_df_index[columns] + + size_half = len(scalars_pandas_df_index) / 2 + bf_result = scalars_df_index.loc[ + :, scalars_df_index.nunique() > size_half + ].to_pandas() + pd_result = scalars_pandas_df_index.loc[ + :, scalars_pandas_df_index.nunique() > size_half + ] + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_loc_single_index_with_duplicate(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("string_col", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index( + "string_col", drop=False + ) + index = "Hello, World!" + bf_result = scalars_df_index.loc[index] + pd_result = scalars_pandas_df_index.loc[index] + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_single_index_no_duplicate(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("int64_too", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index("int64_too", drop=False) + index = -2345 + bf_result = scalars_df_index.loc[index] + pd_result = scalars_pandas_df_index.loc[index] + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_at_with_duplicate(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("string_col", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index( + "string_col", drop=False + ) + index = "Hello, World!" + bf_result = scalars_df_index.at[index, "int64_too"] + pd_result = scalars_pandas_df_index.at[index, "int64_too"] + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_at_no_duplicate(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("int64_too", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index("int64_too", drop=False) + index = -2345 + bf_result = scalars_df_index.at[index, "string_col"] + pd_result = scalars_pandas_df_index.at[index, "string_col"] + assert bf_result == pd_result + + +def test_loc_setitem_bool_series_scalar_new_col(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + bf_df.loc[bf_df["int64_too"] == 0, "new_col"] = 99 + pd_df.loc[pd_df["int64_too"] == 0, "new_col"] = 99 + + # pandas uses float64 instead + pd_df["new_col"] = pd_df["new_col"].astype("Float64") + + pd.testing.assert_frame_equal( + bf_df.to_pandas(), + pd_df, + ) + + +@pytest.mark.parametrize( + ("col", "value"), + [ + ("string_col", "hello"), + ("int64_col", 3), + ("float64_col", 3.5), + ], +) +def test_loc_setitem_bool_series_scalar_existing_col(scalars_dfs, col, value): + if pd.__version__.startswith("1."): + pytest.skip("this loc overload not supported in pandas 1.x.") + + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + bf_df.loc[bf_df["int64_too"] == 1, col] = value + pd_df.loc[pd_df["int64_too"] == 1, col] = value + + pd.testing.assert_frame_equal( + bf_df.to_pandas(), + pd_df, + ) + + +def test_loc_setitem_bool_series_scalar_error(scalars_dfs): + if pd.__version__.startswith("1."): + pytest.skip("this loc overload not supported in pandas 1.x.") + + scalars_df, scalars_pandas_df = scalars_dfs + bf_df = scalars_df.copy() + pd_df = scalars_pandas_df.copy() + + with pytest.raises(Exception): + bf_df.loc[bf_df["int64_too"] == 1, "string_col"] = 99 + with pytest.raises(Exception): + pd_df.loc[pd_df["int64_too"] == 1, "string_col"] = 99 + + +@pytest.mark.parametrize( + ("col", "op"), + [ + # Int aggregates + pytest.param("int64_col", lambda x: x.sum(), id="int-sum"), + pytest.param("int64_col", lambda x: x.min(), id="int-min"), + pytest.param("int64_col", lambda x: x.max(), id="int-max"), + pytest.param("int64_col", lambda x: x.count(), id="int-count"), + pytest.param("int64_col", lambda x: x.nunique(), id="int-nunique"), + # Float aggregates + pytest.param("float64_col", lambda x: x.count(), id="float-count"), + pytest.param("float64_col", lambda x: x.nunique(), id="float-nunique"), + # Bool aggregates + pytest.param("bool_col", lambda x: x.sum(), id="bool-sum"), + pytest.param("bool_col", lambda x: x.count(), id="bool-count"), + pytest.param("bool_col", lambda x: x.nunique(), id="bool-nunique"), + # String aggregates + pytest.param("string_col", lambda x: x.count(), id="string-count"), + pytest.param("string_col", lambda x: x.nunique(), id="string-nunique"), + ], +) +def test_dataframe_aggregate_int(scalars_df_index, scalars_pandas_df_index, col, op): + bf_result = op(scalars_df_index[[col]]).to_pandas() + pd_result = op(scalars_pandas_df_index[[col]]) + + # Check dtype separately + assert bf_result.dtype == "Int64" + # Is otherwise "object" dtype + pd_result.index = pd_result.index.astype("string[pyarrow]") + # Pandas may produce narrower numeric types + assert_series_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + + +@pytest.mark.parametrize( + ("col", "op"), + [ + pytest.param("bool_col", lambda x: x.min(), id="bool-min"), + pytest.param("bool_col", lambda x: x.max(), id="bool-max"), + ], +) +def test_dataframe_aggregate_bool(scalars_df_index, scalars_pandas_df_index, col, op): + bf_result = op(scalars_df_index[[col]]).to_pandas() + pd_result = op(scalars_pandas_df_index[[col]]) + + # Check dtype separately + assert bf_result.dtype == "boolean" + + # Pandas may produce narrower numeric types + # Pandas has object index type + pd_result.index = pd_result.index.astype("string[pyarrow]") + assert_series_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + + +@pytest.mark.parametrize( + ("op", "bf_dtype"), + [ + (lambda x: x.sum(numeric_only=True), "Float64"), + (lambda x: x.mean(numeric_only=True), "Float64"), + (lambda x: x.min(numeric_only=True), "Float64"), + (lambda x: x.max(numeric_only=True), "Float64"), + (lambda x: x.std(numeric_only=True), "Float64"), + (lambda x: x.var(numeric_only=True), "Float64"), + (lambda x: x.count(numeric_only=False), "Int64"), + (lambda x: x.nunique(), "Int64"), + ], + ids=["sum", "mean", "min", "max", "std", "var", "count", "nunique"], +) +def test_dataframe_aggregates(scalars_dfs, op, bf_dtype): + scalars_df_index, scalars_pandas_df_index = scalars_dfs + col_names = ["int64_too", "float64_col", "string_col", "int64_col", "bool_col"] + bf_series = op(scalars_df_index[col_names]) + bf_result = bf_series + pd_result = op(scalars_pandas_df_index[col_names]) + + # Check dtype separately + assert bf_result.dtype == bf_dtype + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + # Pandas has object index type + pd_result.index = pd_result.index.astype("string[pyarrow]") + assert_series_equivalent( + pd_result, + bf_result, + check_dtype=False, + check_index_type=False, + ) + + +@pytest.mark.parametrize( + ("op"), + [ + (lambda x: x.sum(axis=1, numeric_only=True)), + (lambda x: x.mean(axis=1, numeric_only=True)), + (lambda x: x.min(axis=1, numeric_only=True)), + (lambda x: x.max(axis=1, numeric_only=True)), + (lambda x: x.std(axis=1, numeric_only=True)), + (lambda x: x.var(axis=1, numeric_only=True)), + ], + ids=["sum", "mean", "min", "max", "std", "var"], +) +def test_dataframe_aggregates_axis_1(scalars_df_index, scalars_pandas_df_index, op): + col_names = ["int64_too", "int64_col", "float64_col", "bool_col", "string_col"] + bf_result = op(scalars_df_index[col_names]).to_pandas() + pd_result = op(scalars_pandas_df_index[col_names]) + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + # Pandas has object index type + assert_series_equal(pd_result, bf_result, check_index_type=False, check_dtype=False) + + +@pytest.mark.parametrize( + ("op"), + [ + (lambda x: x.all(bool_only=True)), + (lambda x: x.any(bool_only=True)), + (lambda x: x.all(axis=1, bool_only=True)), + (lambda x: x.any(axis=1, bool_only=True)), + ], + ids=["all_axis0", "any_axis0", "all_axis1", "any_axis1"], +) +def test_dataframe_bool_aggregates(scalars_df_index, scalars_pandas_df_index, op): + # Pandas will drop nullable 'boolean' dtype so we convert first to bool, then cast back later + scalars_df_index = scalars_df_index.assign( + bool_col=scalars_df_index.bool_col.fillna(False) + ) + scalars_pandas_df_index = scalars_pandas_df_index.assign( + bool_col=scalars_pandas_df_index.bool_col.fillna(False).astype("bool") + ) + bf_series = op(scalars_df_index) + pd_series = op(scalars_pandas_df_index).astype("boolean") + bf_result = bf_series.to_pandas() + + pd_series.index = pd_series.index.astype(bf_result.index.dtype) + pd.testing.assert_series_equal(pd_series, bf_result, check_index_type=False) + + +def test_dataframe_prod(scalars_df_index, scalars_pandas_df_index): + col_names = ["int64_too", "float64_col"] + bf_series = scalars_df_index[col_names].prod() + pd_series = scalars_pandas_df_index[col_names].prod() + bf_result = bf_series.to_pandas() + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + pd_series = pd_series.astype("Float64") + # Pandas has object index type + pd.testing.assert_series_equal(pd_series, bf_result, check_index_type=False) + + +def test_df_skew_too_few_values(scalars_dfs): + columns = ["float64_col", "int64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[columns].head(2).skew().to_pandas() + pd_result = scalars_pandas_df[columns].head(2).skew() + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + pd_result = pd_result.astype("Float64") + + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + + +@pytest.mark.parametrize( + ("ordered"), + [ + (True), + (False), + ], +) +def test_df_skew(scalars_dfs, ordered): + columns = ["float64_col", "int64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[columns].skew().to_pandas(ordered=ordered) + pd_result = scalars_pandas_df[columns].skew() + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + pd_result = pd_result.astype("Float64") + + assert_series_equal( + pd_result, bf_result, check_index_type=False, ignore_order=not ordered + ) + + +def test_df_kurt_too_few_values(scalars_dfs): + columns = ["float64_col", "int64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[columns].head(2).kurt().to_pandas() + pd_result = scalars_pandas_df[columns].head(2).kurt() + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + pd_result = pd_result.astype("Float64") + + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + + +def test_df_kurt(scalars_dfs): + columns = ["float64_col", "int64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[columns].kurt().to_pandas() + pd_result = scalars_pandas_df[columns].kurt() + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + pd_result = pd_result.astype("Float64") + + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + + +def test_sample_raises_value_error(scalars_dfs): + scalars_df, _ = scalars_dfs + with pytest.raises( + ValueError, match="Only one of 'n' or 'frac' parameter can be specified." + ): + scalars_df.sample(frac=0.5, n=4) + + +@pytest.mark.parametrize( + ("axis",), + [ + (None,), + (0,), + (1,), + ], +) +def test_df_add_prefix(scalars_df_index, scalars_pandas_df_index, axis): + if pd.__version__.startswith("1."): + pytest.skip("add_prefix axis parameter not supported in pandas 1.x.") + bf_result = scalars_df_index.add_prefix("prefix_", axis).to_pandas() + + pd_result = scalars_pandas_df_index.add_prefix("prefix_", axis) + + pd.testing.assert_frame_equal( + bf_result, + pd_result, + check_index_type=False, + ) + + +@pytest.mark.parametrize( + ("axis",), + [ + (0,), + (1,), + ], +) +def test_df_add_suffix(scalars_df_index, scalars_pandas_df_index, axis): + if pd.__version__.startswith("1."): + pytest.skip("add_prefix axis parameter not supported in pandas 1.x.") + bf_result = scalars_df_index.add_suffix("_suffix", axis).to_pandas() + + pd_result = scalars_pandas_df_index.add_suffix("_suffix", axis) + + pd.testing.assert_frame_equal( + bf_result, + pd_result, + check_index_type=False, + ) + + +def test_df_astype_error_error(session): + input = pd.DataFrame(["hello", "world", "3.11", "4000"]) + with pytest.raises(ValueError): + session.read_pandas(input).astype("Float64", errors="bad_value") + + +def test_df_columns_filter_items(scalars_df_index, scalars_pandas_df_index): + if pd.__version__.startswith("2.0") or pd.__version__.startswith("1."): + pytest.skip("pandas filter items behavior different pre-2.1") + bf_result = scalars_df_index.filter(items=["string_col", "int64_col"]).to_pandas() + + pd_result = scalars_pandas_df_index.filter(items=["string_col", "int64_col"]) + # Ignore column ordering as pandas order differently depending on version + pd.testing.assert_frame_equal( + bf_result.sort_index(axis=1), + pd_result.sort_index(axis=1), + ) + + +def test_df_columns_filter_like(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.filter(like="64_col").to_pandas() + + pd_result = scalars_pandas_df_index.filter(like="64_col") + + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_df_columns_filter_regex(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.filter(regex="^[^_]+$").to_pandas() + + pd_result = scalars_pandas_df_index.filter(regex="^[^_]+$") + + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_df_reindex_rows_list(scalars_dfs): + scalars_df_index, scalars_pandas_df_index = scalars_dfs + bf_result = scalars_df_index.reindex(index=[5, 1, 3, 99, 1]) + + pd_result = scalars_pandas_df_index.reindex(index=[5, 1, 3, 99, 1]) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + assert_dfs_equivalent( + pd_result, + bf_result, + ) + + +def test_df_reindex_rows_index(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.reindex( + index=pd.Index([5, 1, 3, 99, 1], name="newname") + ).to_pandas() + + pd_result = scalars_pandas_df_index.reindex( + index=pd.Index([5, 1, 3, 99, 1], name="newname") + ) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_df_reindex_nonunique(scalars_df_index): + with pytest.raises(ValueError): + # int64_too is non-unique + scalars_df_index.set_index("int64_too").reindex( + index=[5, 1, 3, 99, 1], validate=True + ) + + +def test_df_reindex_columns(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.reindex( + columns=["not_a_col", "int64_col", "int64_too"] + ).to_pandas() + + pd_result = scalars_pandas_df_index.reindex( + columns=["not_a_col", "int64_col", "int64_too"] + ) + + # Pandas uses float64 as default for newly created empty column, bf uses Float64 + pd_result.not_a_col = pd_result.not_a_col.astype(pandas.Float64Dtype()) + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_df_reindex_columns_with_same_order(scalars_df_index, scalars_pandas_df_index): + # First, make sure the two dataframes have the same columns in order. + columns = ["int64_col", "int64_too"] + bf = scalars_df_index[columns] + pd_df = scalars_pandas_df_index[columns] + + bf_result = bf.reindex(columns=columns).to_pandas() + pd_result = pd_df.reindex(columns=columns) + + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_df_equals_identical(scalars_df_index, scalars_pandas_df_index): + unsupported = [ + "geography_col", + ] + scalars_df_index = scalars_df_index.drop(columns=unsupported) + scalars_pandas_df_index = scalars_pandas_df_index.drop(columns=unsupported) + + bf_result = scalars_df_index.equals(scalars_df_index) + pd_result = scalars_pandas_df_index.equals(scalars_pandas_df_index) + + assert pd_result == bf_result + + +def test_df_equals_series(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index[["int64_col"]].equals(scalars_df_index["int64_col"]) + pd_result = scalars_pandas_df_index[["int64_col"]].equals( + scalars_pandas_df_index["int64_col"] + ) + + assert pd_result == bf_result + + +def test_df_equals_different_dtype(scalars_df_index, scalars_pandas_df_index): + columns = ["int64_col", "int64_too"] + scalars_df_index = scalars_df_index[columns] + scalars_pandas_df_index = scalars_pandas_df_index[columns] + + bf_modified = scalars_df_index.copy() + bf_modified = bf_modified.astype("Float64") + + pd_modified = scalars_pandas_df_index.copy() + pd_modified = pd_modified.astype("Float64") + + bf_result = scalars_df_index.equals(bf_modified) + pd_result = scalars_pandas_df_index.equals(pd_modified) + + assert pd_result == bf_result + + +def test_df_equals_different_values(scalars_df_index, scalars_pandas_df_index): + columns = ["int64_col", "int64_too"] + scalars_df_index = scalars_df_index[columns] + scalars_pandas_df_index = scalars_pandas_df_index[columns] + + bf_modified = scalars_df_index.copy() + bf_modified["int64_col"] = bf_modified.int64_col + 1 + + pd_modified = scalars_pandas_df_index.copy() + pd_modified["int64_col"] = pd_modified.int64_col + 1 + + bf_result = scalars_df_index.equals(bf_modified) + pd_result = scalars_pandas_df_index.equals(pd_modified) + + assert pd_result == bf_result + + +def test_df_equals_extra_column(scalars_df_index, scalars_pandas_df_index): + columns = ["int64_col", "int64_too"] + more_columns = ["int64_col", "int64_too", "float64_col"] + + bf_result = scalars_df_index[columns].equals(scalars_df_index[more_columns]) + pd_result = scalars_pandas_df_index[columns].equals( + scalars_pandas_df_index[more_columns] + ) + + assert pd_result == bf_result + + +def test_df_reindex_like(scalars_df_index, scalars_pandas_df_index): + reindex_target_bf = scalars_df_index.reindex( + columns=["not_a_col", "int64_col", "int64_too"], index=[5, 1, 3, 99, 1] + ) + bf_result = scalars_df_index.reindex_like(reindex_target_bf).to_pandas() + + reindex_target_pd = scalars_pandas_df_index.reindex( + columns=["not_a_col", "int64_col", "int64_too"], index=[5, 1, 3, 99, 1] + ) + pd_result = scalars_pandas_df_index.reindex_like(reindex_target_pd) + + # Pandas uses float64 as default for newly created empty column, bf uses Float64 + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + # Pandas uses float64 as default for newly created empty column, bf uses Float64 + pd_result.not_a_col = pd_result.not_a_col.astype(pandas.Float64Dtype()) + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_df_values(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.values + + pd_result = scalars_pandas_df_index.values + # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe + pd.testing.assert_frame_equal( + pd.DataFrame(bf_result), pd.DataFrame(pd_result), check_dtype=False + ) + + +def test_df_to_numpy(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.to_numpy() + + pd_result = scalars_pandas_df_index.to_numpy() + # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe + pd.testing.assert_frame_equal( + pd.DataFrame(bf_result), pd.DataFrame(pd_result), check_dtype=False + ) + + +def test_df___array__(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.__array__() + + pd_result = scalars_pandas_df_index.__array__() + # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe + pd.testing.assert_frame_equal( + pd.DataFrame(bf_result), pd.DataFrame(pd_result), check_dtype=False + ) + + +def test_df_getattr_attribute_error_when_pandas_has(scalars_df_index): + # swapaxes is implemented in pandas but not in bigframes + with pytest.raises(AttributeError): + scalars_df_index.swapaxes() + + +def test_df_getattr_attribute_error(scalars_df_index): + with pytest.raises(AttributeError): + scalars_df_index.not_a_method() + + +def test_df_getattr_axes(): + df = dataframe.DataFrame( + [[1, 1, 1], [1, 1, 1]], columns=["index", "columns", "my_column"] + ) + assert isinstance(df.index, bigframes.core.indexes.Index) + assert isinstance(df.columns, pandas.Index) + assert isinstance(df.my_column, series.Series) + + +def test_df_setattr_index(): + pd_df = pandas.DataFrame( + [[1, 1, 1], [1, 1, 1]], columns=["index", "columns", "my_column"] + ) + bf_df = dataframe.DataFrame(pd_df) + + pd_df.index = pandas.Index([4, 5]) + bf_df.index = [4, 5] + + assert_frame_equal( + pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False + ) + + +def test_df_setattr_columns(): + pd_df = pandas.DataFrame( + [[1, 1, 1], [1, 1, 1]], columns=["index", "columns", "my_column"] + ) + bf_df = dataframe.DataFrame(pd_df) + + pd_df.columns = typing.cast(pandas.Index, pandas.Index([4, 5, 6])) + + bf_df.columns = pandas.Index([4, 5, 6]) + + assert_frame_equal( + pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False + ) + + +def test_df_setattr_modify_column(): + pd_df = pandas.DataFrame( + [[1, 1, 1], [1, 1, 1]], columns=["index", "columns", "my_column"] + ) + bf_df = dataframe.DataFrame(pd_df) + pd_df.my_column = [4, 5] + bf_df.my_column = [4, 5] + + assert_frame_equal( + pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False + ) + + +def test_loc_list_string_index(scalars_df_index, scalars_pandas_df_index): + index_list = scalars_pandas_df_index.string_col.iloc[[0, 1, 1, 5]].values + + scalars_df_index = scalars_df_index.set_index("string_col") + scalars_pandas_df_index = scalars_pandas_df_index.set_index("string_col") + + bf_result = scalars_df_index.loc[index_list].to_pandas() + pd_result = scalars_pandas_df_index.loc[index_list] + + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_loc_list_integer_index(scalars_df_index, scalars_pandas_df_index): + index_list = [3, 2, 1, 3, 2, 1] + + bf_result = scalars_df_index.loc[index_list] + pd_result = scalars_pandas_df_index.loc[index_list] + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_list_multiindex(scalars_dfs): + scalars_df_index, scalars_pandas_df_index = scalars_dfs + scalars_df_multiindex = scalars_df_index.set_index(["string_col", "int64_col"]) + scalars_pandas_df_multiindex = scalars_pandas_df_index.set_index( + ["string_col", "int64_col"] + ) + index_list = [("Hello, World!", -234892), ("Hello, World!", 123456789)] + + bf_result = scalars_df_multiindex.loc[index_list] + pd_result = scalars_pandas_df_multiindex.loc[index_list] + + assert_dfs_equivalent( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + "index_list", + [ + [0, 1, 2, 3, 4, 4], + [0, 0, 0, 5, 4, 7, -2, -5, 3], + [-1, -2, -3, -4, -5, -5], + ], +) +def test_iloc_list(scalars_df_index, scalars_pandas_df_index, index_list): + bf_result = scalars_df_index.iloc[index_list] + pd_result = scalars_pandas_df_index.iloc[index_list] + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_iloc_list_multiindex(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.copy() + scalars_pandas_df = scalars_pandas_df.copy() + scalars_df = scalars_df.set_index(["bytes_col", "numeric_col"]) + scalars_pandas_df = scalars_pandas_df.set_index(["bytes_col", "numeric_col"]) + + index_list = [0, 0, 0, 5, 4, 7] + + bf_result = scalars_df.iloc[index_list] + pd_result = scalars_pandas_df.iloc[index_list] + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_iloc_empty_list(scalars_df_index, scalars_pandas_df_index): + + index_list: List[int] = [] + + bf_result = scalars_df_index.iloc[index_list] + pd_result = scalars_pandas_df_index.iloc[index_list] + + bf_result = bf_result.to_pandas() + assert bf_result.shape == pd_result.shape # types are known to be different + + +def test_rename_axis(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.rename_axis("newindexname") + pd_result = scalars_pandas_df_index.rename_axis("newindexname") + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_rename_axis_nonstring(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.rename_axis((4,)) + pd_result = scalars_pandas_df_index.rename_axis((4,)) + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_series_string_index(scalars_df_index, scalars_pandas_df_index): + pd_string_series = scalars_pandas_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + bf_string_series = scalars_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + + scalars_df_index = scalars_df_index.set_index("string_col") + scalars_pandas_df_index = scalars_pandas_df_index.set_index("string_col") + + bf_result = scalars_df_index.loc[bf_string_series] + pd_result = scalars_pandas_df_index.loc[pd_string_series] + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_series_multiindex(scalars_df_index, scalars_pandas_df_index): + pd_string_series = scalars_pandas_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + bf_string_series = scalars_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + + scalars_df_multiindex = scalars_df_index.set_index(["string_col", "int64_col"]) + scalars_pandas_df_multiindex = scalars_pandas_df_index.set_index( + ["string_col", "int64_col"] + ) + + bf_result = scalars_df_multiindex.loc[bf_string_series] + pd_result = scalars_pandas_df_multiindex.loc[pd_string_series] + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_index_integer_index(scalars_df_index, scalars_pandas_df_index): + pd_index = scalars_pandas_df_index.iloc[[0, 5, 1, 1, 5]].index + bf_index = scalars_df_index.iloc[[0, 5, 1, 1, 5]].index + + bf_result = scalars_df_index.loc[bf_index] + pd_result = scalars_pandas_df_index.loc[pd_index] + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_index_integer_index_renamed_col( + scalars_df_index, scalars_pandas_df_index +): + scalars_df_index = scalars_df_index.rename(columns={"int64_col": "rename"}) + scalars_pandas_df_index = scalars_pandas_df_index.rename( + columns={"int64_col": "rename"} + ) + + pd_index = scalars_pandas_df_index.iloc[[0, 5, 1, 1, 5]].index + bf_index = scalars_df_index.iloc[[0, 5, 1, 1, 5]].index + + bf_result = scalars_df_index.loc[bf_index] + pd_result = scalars_pandas_df_index.loc[pd_index] + + pd.testing.assert_frame_equal( + bf_result.to_pandas(), + pd_result, + ) + + +@pytest.mark.parametrize( + ("subset"), + [ + None, + "bool_col", + ["bool_col", "int64_too"], + ], +) +@pytest.mark.parametrize( + ("keep",), + [ + (False,), + ], +) +def test_df_drop_duplicates(scalars_df_index, scalars_pandas_df_index, keep, subset): + columns = ["bool_col", "int64_too", "int64_col"] + bf_df = scalars_df_index[columns].drop_duplicates(subset, keep=keep).to_pandas() + pd_df = scalars_pandas_df_index[columns].drop_duplicates(subset, keep=keep) + pd.testing.assert_frame_equal( + pd_df, + bf_df, + ) + + +@pytest.mark.parametrize( + ("subset"), + [ + None, + ["bool_col"], + ], +) +@pytest.mark.parametrize( + ("keep",), + [ + (False,), + ], +) +def test_df_duplicated(scalars_df_index, scalars_pandas_df_index, keep, subset): + columns = ["bool_col", "int64_too", "int64_col"] + bf_series = scalars_df_index[columns].duplicated(subset, keep=keep).to_pandas() + pd_series = scalars_pandas_df_index[columns].duplicated(subset, keep=keep) + pd.testing.assert_series_equal(pd_series, bf_series, check_dtype=False) + + +def test_df_from_dict_columns_orient(): + data = {"a": [1, 2], "b": [3.3, 2.4]} + bf_result = dataframe.DataFrame.from_dict(data, orient="columns").to_pandas() + pd_result = pd.DataFrame.from_dict(data, orient="columns") + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + + +def test_df_from_dict_index_orient(): + data = {"a": [1, 2], "b": [3.3, 2.4]} + bf_result = dataframe.DataFrame.from_dict( + data, orient="index", columns=["col1", "col2"] + ).to_pandas() + pd_result = pd.DataFrame.from_dict(data, orient="index", columns=["col1", "col2"]) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + + +def test_df_from_dict_tight_orient(): + data = { + "index": [("i1", "i2"), ("i3", "i4")], + "columns": ["col1", "col2"], + "data": [[1, 2.6], [3, 4.5]], + "index_names": ["in1", "in2"], + "column_names": ["column_axis"], + } + + bf_result = dataframe.DataFrame.from_dict(data, orient="tight").to_pandas() + pd_result = pd.DataFrame.from_dict(data, orient="tight") + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + + +def test_df_from_records(): + records = ((1, "a"), (2.5, "b"), (3.3, "c"), (4.9, "d")) + + bf_result = dataframe.DataFrame.from_records( + records, columns=["c1", "c2"] + ).to_pandas() + pd_result = pd.DataFrame.from_records(records, columns=["c1", "c2"]) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + + +def test_df_to_dict(scalars_df_index, scalars_pandas_df_index): + unsupported = ["numeric_col"] # formatted differently + bf_result = scalars_df_index.drop(columns=unsupported).to_dict() + pd_result = scalars_pandas_df_index.drop(columns=unsupported).to_dict() + + assert bf_result == pd_result + + +def test_df_to_json_local_str(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.to_json() + # default_handler for arrow types that have no default conversion + pd_result = scalars_pandas_df_index.to_json(default_handler=str) + + assert bf_result == pd_result + + +def test_df_to_json_local_file(scalars_df_index, scalars_pandas_df_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + # duration not fully supported at pandas level + scalars_df_index = scalars_df_index.drop(columns="duration_col") + scalars_pandas_df_index = scalars_pandas_df_index.drop(columns="duration_col") + with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: + scalars_df_index.to_json(bf_result_file, orient="table") + # default_handler for arrow types that have no default conversion + scalars_pandas_df_index.to_json( + pd_result_file, orient="table", default_handler=str + ) + + bf_result = bf_result_file.read() + pd_result = pd_result_file.read() + + assert bf_result == pd_result + + +def test_df_to_csv_local_str(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.to_csv() + # default_handler for arrow types that have no default conversion + pd_result = scalars_pandas_df_index.to_csv() + + assert bf_result == pd_result + + +def test_df_to_csv_local_file(scalars_df_index, scalars_pandas_df_index): + with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: + scalars_df_index.to_csv(bf_result_file) + scalars_pandas_df_index.to_csv(pd_result_file) + + bf_result = bf_result_file.read() + pd_result = pd_result_file.read() + + assert bf_result == pd_result + + +def test_df_to_parquet_local_bytes(scalars_df_index, scalars_pandas_df_index): + # GEOGRAPHY not supported in parquet export. + unsupported = ["geography_col"] + + bf_result = scalars_df_index.drop(columns=unsupported).to_parquet() + # default_handler for arrow types that have no default conversion + pd_result = scalars_pandas_df_index.drop(columns=unsupported).to_parquet() + + assert bf_result == pd_result + + +def test_df_to_parquet_local_file(scalars_df_index, scalars_pandas_df_index): + # GEOGRAPHY not supported in parquet export. + unsupported = ["geography_col"] + with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: + scalars_df_index.drop(columns=unsupported).to_parquet(bf_result_file) + scalars_pandas_df_index.drop(columns=unsupported).to_parquet(pd_result_file) + + bf_result = bf_result_file.read() + pd_result = pd_result_file.read() + + assert bf_result == pd_result + + +def test_df_to_records(scalars_df_index, scalars_pandas_df_index): + unsupported = ["numeric_col"] + bf_result = scalars_df_index.drop(columns=unsupported).to_records() + pd_result = scalars_pandas_df_index.drop(columns=unsupported).to_records() + + for bfi, pdi in zip(bf_result, pd_result): + for bfj, pdj in zip(bfi, pdi): + assert pd.isna(bfj) and pd.isna(pdj) or bfj == pdj + + +def test_df_to_string(scalars_df_index, scalars_pandas_df_index): + unsupported = ["numeric_col"] # formatted differently + + bf_result = scalars_df_index.drop(columns=unsupported).to_string() + pd_result = scalars_pandas_df_index.drop(columns=unsupported).to_string() + + assert bf_result == pd_result + + +def test_df_to_html(scalars_df_index, scalars_pandas_df_index): + unsupported = ["numeric_col"] # formatted differently + + bf_result = scalars_df_index.drop(columns=unsupported).to_html() + pd_result = scalars_pandas_df_index.drop(columns=unsupported).to_html() + + assert bf_result == pd_result + + +def test_df_to_markdown(scalars_df_index, scalars_pandas_df_index): + # Nulls have bug from tabulate https://github.com/astanin/python-tabulate/issues/231 + bf_result = scalars_df_index.dropna().to_markdown() + pd_result = scalars_pandas_df_index.dropna().to_markdown() + + assert bf_result == pd_result + + +def test_df_to_pickle(scalars_df_index, scalars_pandas_df_index): + with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: + scalars_df_index.to_pickle(bf_result_file) + scalars_pandas_df_index.to_pickle(pd_result_file) + bf_result = bf_result_file.read() + pd_result = pd_result_file.read() + + assert bf_result == pd_result + + +def test_df_to_orc(scalars_df_index, scalars_pandas_df_index): + pytest.importorskip("pyarrow.orc") + unsupported = [ + "numeric_col", + "bytes_col", + "date_col", + "datetime_col", + "time_col", + "timestamp_col", + "geography_col", + "duration_col", + ] + + bf_result_file = tempfile.TemporaryFile() + pd_result_file = tempfile.TemporaryFile() + scalars_df_index.drop(columns=unsupported).to_orc(bf_result_file) + scalars_pandas_df_index.drop(columns=unsupported).reset_index().to_orc( + pd_result_file + ) + bf_result = bf_result_file.read() + pd_result = bf_result_file.read() + + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("expr",), + [ + ("new_col = int64_col + int64_too",), + ("new_col = (rowindex > 3) | bool_col",), + ("int64_too = bool_col\nnew_col2 = rowindex",), + ], +) +def test_df_eval(scalars_dfs, expr): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df.eval(expr).to_pandas() + pd_result = scalars_pandas_df.eval(expr) + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("expr",), + [ + ("int64_col > int64_too",), + ("bool_col",), + ("((int64_col - int64_too) % @local_var) == 0",), + ], +) +def test_df_query(scalars_dfs, expr): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + # local_var is referenced in expressions + local_var = 3 # NOQA + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df.query(expr).to_pandas() + pd_result = scalars_pandas_df.query(expr) + + pd.testing.assert_frame_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("subset", "normalize", "ascending", "dropna"), + [ + (None, False, False, False), + (None, True, True, True), + ("bool_col", True, False, True), + ], +) +def test_df_value_counts(scalars_dfs, subset, normalize, ascending, dropna): + if pd.__version__.startswith("1."): + pytest.skip("pandas 1.x produces different column labels.") + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = ( + scalars_df[["string_col", "bool_col"]] + .value_counts(subset, normalize=normalize, ascending=ascending, dropna=dropna) + .to_pandas() + ) + pd_result = scalars_pandas_df[["string_col", "bool_col"]].value_counts( + subset, normalize=normalize, ascending=ascending, dropna=dropna + ) + + assert_series_equal( + bf_result, + pd_result, + check_dtype=False, + check_index_type=False, + # different pandas versions inconsistent for tie-handling + ignore_order=True, + ) + + +def test_df_bool_interpretation_error(scalars_df_index): + with pytest.raises(ValueError): + True if scalars_df_index else False + + +def test_assign_after_binop_row_joins(): + pd_df = pd.DataFrame( + { + "idx1": [1, 1, 1, 1, 2, 2, 2, 2], + "idx2": [10, 10, 20, 20, 10, 10, 20, 20], + "metric1": [10, 14, 2, 13, 6, 2, 9, 5], + "metric2": [25, -3, 8, 2, -1, 0, 0, -4], + }, + dtype=pd.Int64Dtype(), + ).set_index(["idx1", "idx2"]) + bf_df = dataframe.DataFrame(pd_df) + + # Expect implicit joiner to be used, preserving input cardinality rather than getting relational join + bf_df["metric_diff"] = bf_df.metric1 - bf_df.metric2 + pd_df["metric_diff"] = pd_df.metric1 - pd_df.metric2 + + assert_frame_equal(bf_df.to_pandas(), pd_df) + + +def test_df_dot_inline(session): + df1 = pd.DataFrame([[1, 2, 3], [2, 5, 7]]) + df2 = pd.DataFrame([[2, 4, 8], [1, 5, 10], [3, 6, 9]]) + + bf1 = session.read_pandas(df1) + bf2 = session.read_pandas(df2) + bf_result = bf1.dot(bf2).to_pandas() + pd_result = df1.dot(df2) + + # Patch pandas dtypes for testing parity + # Pandas uses int64 instead of Int64 (nullable) dtype. + for name in pd_result.columns: + pd_result[name] = pd_result[name].astype(pd.Int64Dtype()) + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + pd.testing.assert_frame_equal( + bf_result, + pd_result, + ) + + +def test_df_dot_series_inline(): + left = [[1, 2, 3], [2, 5, 7]] + right = [2, 1, 3] + + bf1 = dataframe.DataFrame(left) + bf2 = series.Series(right) + bf_result = bf1.dot(bf2).to_pandas() + + df1 = pd.DataFrame(left) + df2 = pd.Series(right) + pd_result = df1.dot(df2) + + # Patch pandas dtypes for testing parity + # Pandas result is int64 instead of Int64 (nullable) dtype. + pd_result = pd_result.astype(pd.Int64Dtype()) + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("col_names", "ignore_index"), + [ + pytest.param(["A"], False, id="one_array_false"), + pytest.param(["A"], True, id="one_array_true"), + pytest.param(["B"], False, id="one_float_false"), + pytest.param(["B"], True, id="one_float_true"), + pytest.param(["A", "C"], False, id="two_arrays_false"), + pytest.param(["A", "C"], True, id="two_arrays_true"), + ], +) +def test_dataframe_explode(col_names, ignore_index, session): + data = { + "A": [[0, 1, 2], [], [3, 4]], + "B": 3, + "C": [["a", "b", "c"], np.nan, ["d", "e"]], + } + + df = bpd.DataFrame(data, session=session) + pd_df = df.to_pandas() + pd_result = pd_df.explode(col_names, ignore_index=ignore_index) + bf_result = df.explode(col_names, ignore_index=ignore_index) + + # Check that to_pandas() results in at most a single query execution + bf_materialized = bf_result.to_pandas() + + pd.testing.assert_frame_equal( + bf_materialized, + pd_result, + check_index_type=False, + check_dtype=False, + ) + + +@pytest.mark.parametrize( + ("ignore_index", "ordered"), + [ + pytest.param(True, True, id="include_index_ordered"), + pytest.param(True, False, id="include_index_unordered"), + pytest.param(False, True, id="ignore_index_ordered"), + ], +) +def test_dataframe_explode_reserve_order(session, ignore_index, ordered): + data = { + "a": [np.random.randint(0, 10, 10) for _ in range(10)], + "b": [np.random.randint(0, 10, 10) for _ in range(10)], + } + df = bpd.DataFrame(data) + pd_df = pd.DataFrame(data) + + res = df.explode(["a", "b"], ignore_index=ignore_index).to_pandas(ordered=ordered) + pd_res = pd_df.explode(["a", "b"], ignore_index=ignore_index).astype( + pd.Int64Dtype() + ) + pd.testing.assert_frame_equal( + res if ordered else res.sort_index(), + pd_res, + check_index_type=False, + ) + + +@pytest.mark.parametrize( + ("col_names"), + [ + pytest.param([], id="empty", marks=pytest.mark.xfail(raises=ValueError)), + pytest.param( + ["A", "A"], id="duplicate", marks=pytest.mark.xfail(raises=ValueError) + ), + pytest.param("unknown", id="unknown", marks=pytest.mark.xfail(raises=KeyError)), + ], +) +def test_dataframe_explode_xfail(col_names): + df = bpd.DataFrame({"A": [[0, 1, 2], [], [3, 4]]}) + df.explode(col_names) diff --git a/tests/unit/test_dtypes.py b/tests/unit/test_dtypes.py new file mode 100644 index 0000000000..0e600de964 --- /dev/null +++ b/tests/unit/test_dtypes.py @@ -0,0 +1,73 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import db_dtypes # type: ignore +import pyarrow as pa # type: ignore +import pytest +import shapely.geometry # type: ignore + +import bigframes.dtypes + + +@pytest.mark.parametrize( + ["python_type", "expected_dtype"], + [ + (bool, bigframes.dtypes.BOOL_DTYPE), + (int, bigframes.dtypes.INT_DTYPE), + (str, bigframes.dtypes.STRING_DTYPE), + (shapely.geometry.Point, bigframes.dtypes.GEO_DTYPE), + (shapely.geometry.Polygon, bigframes.dtypes.GEO_DTYPE), + (shapely.geometry.base.BaseGeometry, bigframes.dtypes.GEO_DTYPE), + ], +) +def test_bigframes_type_supports_python_types(python_type, expected_dtype): + got_dtype = bigframes.dtypes.bigframes_type(python_type) + assert got_dtype == expected_dtype + + +@pytest.mark.parametrize( + ["scalar", "expected_dtype"], + [ + (pa.scalar(1_000_000_000, type=pa.int64()), bigframes.dtypes.INT_DTYPE), + (pa.scalar(True, type=pa.bool_()), bigframes.dtypes.BOOL_DTYPE), + (pa.scalar("hello", type=pa.string()), bigframes.dtypes.STRING_DTYPE), + # Support NULL scalars. + (pa.scalar(None, type=pa.int64()), bigframes.dtypes.INT_DTYPE), + (pa.scalar(None, type=pa.bool_()), bigframes.dtypes.BOOL_DTYPE), + (pa.scalar(None, type=pa.string()), bigframes.dtypes.STRING_DTYPE), + ], +) +def test_infer_literal_type_arrow_scalar(scalar, expected_dtype): + assert bigframes.dtypes.infer_literal_type(scalar) == expected_dtype + + +@pytest.mark.parametrize( + ["type_", "expected"], + [ + (pa.int64(), False), + (db_dtypes.JSONArrowType(), True), + (pa.struct([("int", pa.int64()), ("str", pa.string())]), False), + (pa.struct([("int", pa.int64()), ("json", db_dtypes.JSONArrowType())]), True), + (pa.list_(pa.int64()), False), + (pa.list_(db_dtypes.JSONArrowType()), True), + ( + pa.list_( + pa.struct([("int", pa.int64()), ("json", db_dtypes.JSONArrowType())]) + ), + True, + ), + ], +) +def test_contains_db_dtypes_json_arrow_type(type_, expected): + assert bigframes.dtypes.contains_db_dtypes_json_arrow_type(type_) == expected diff --git a/tests/unit/test_formatting_helpers.py b/tests/unit/test_formatting_helpers.py index 588ef6e824..7a1cf1ab13 100644 --- a/tests/unit/test_formatting_helpers.py +++ b/tests/unit/test_formatting_helpers.py @@ -19,6 +19,7 @@ import google.cloud.bigquery as bigquery import pytest +import bigframes.core.events as bfevents import bigframes.formatting_helpers as formatting_helpers import bigframes.version @@ -30,7 +31,7 @@ def test_wait_for_query_job_error_includes_feedback_link(): ) with pytest.raises(api_core_exceptions.BadRequest) as cap_exc: - formatting_helpers.wait_for_query_job(mock_query_job) + formatting_helpers.wait_for_job(mock_query_job) cap_exc.match("Test message 123.") cap_exc.match(constants.FEEDBACK_LINK) @@ -66,7 +67,133 @@ def test_get_formatted_bytes(test_input, expected): @pytest.mark.parametrize( - "test_input, expected", [(None, None), ("string", "string"), (100000, "a minute")] + "test_input, expected", [(None, None), ("string", "string"), (66000, "a minute")] ) def test_get_formatted_time(test_input, expected): assert formatting_helpers.get_formatted_time(test_input) == expected + + +def test_render_bqquery_sent_event_html(): + event = bfevents.BigQuerySentEvent( + query="SELECT * FROM my_table", + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + ) + html = formatting_helpers.render_bqquery_sent_event_html(event) + assert "SELECT * FROM my_table" in html + assert "my-job-id" in html + assert "us-central1" in html + assert "my-project" in html + assert "
" in html + + +def test_render_bqquery_sent_event_plaintext(): + event = bfevents.BigQuerySentEvent( + query="SELECT * FROM my_table", + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + ) + text = formatting_helpers.render_bqquery_sent_event_plaintext(event) + assert "my-job-id" in text + assert "us-central1" in text + assert "my-project" in text + assert "SELECT * FROM my_table" not in text + + +def test_render_bqquery_retry_event_html(): + event = bfevents.BigQueryRetryEvent( + query="SELECT * FROM my_table", + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + ) + html = formatting_helpers.render_bqquery_retry_event_html(event) + assert "Retrying query" in html + assert "SELECT * FROM my_table" in html + assert "my-job-id" in html + assert "us-central1" in html + assert "my-project" in html + assert "
" in html + + +def test_render_bqquery_retry_event_plaintext(): + event = bfevents.BigQueryRetryEvent( + query="SELECT * FROM my_table", + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + ) + text = formatting_helpers.render_bqquery_retry_event_plaintext(event) + assert "Retrying query" in text + assert "my-job-id" in text + assert "us-central1" in text + assert "my-project" in text + assert "SELECT * FROM my_table" not in text + + +def test_render_bqquery_received_event_html(): + mock_plan_entry = mock.create_autospec( + bigquery.job.query.QueryPlanEntry, instance=True + ) + mock_plan_entry.__str__.return_value = "mocked plan" + event = bfevents.BigQueryReceivedEvent( + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + state="RUNNING", + query_plan=[mock_plan_entry], + ) + html = formatting_helpers.render_bqquery_received_event_html(event) + assert "Query" in html + assert "my-job-id" in html + assert "is RUNNING" in html + assert "
" in html + assert "mocked plan" in html + + +def test_render_bqquery_received_event_plaintext(): + event = bfevents.BigQueryReceivedEvent( + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + state="RUNNING", + query_plan=[], + ) + text = formatting_helpers.render_bqquery_received_event_plaintext(event) + assert "Query" in text + assert "my-job-id" in text + assert "is RUNNING" in text + assert "Query Plan" not in text + + +def test_render_bqquery_finished_event_html(): + event = bfevents.BigQueryFinishedEvent( + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + total_bytes_processed=1000, + slot_millis=2000, + ) + html = formatting_helpers.render_bqquery_finished_event_html(event) + assert "Query" in html + assert "my-job-id" in html + assert "processed 1.0 kB" in html + assert "2 seconds of slot time" in html + + +def test_render_bqquery_finished_event_plaintext(): + event = bfevents.BigQueryFinishedEvent( + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + total_bytes_processed=1000, + slot_millis=2000, + ) + text = formatting_helpers.render_bqquery_finished_event_plaintext(event) + assert "Query" in text + assert "my-job-id" in text + assert "finished" in text + assert "1.0 kB processed" in text + assert "Slot time: 2 seconds" in text diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py new file mode 100644 index 0000000000..b875d56e7a --- /dev/null +++ b/tests/unit/test_index.py @@ -0,0 +1,51 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pytest + +from bigframes.testing import mocks + + +def test_index_rename(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"idx": [], "col": []} + ).set_index("idx") + index = dataframe.index + assert index.name == "idx" + renamed = index.rename("my_index_name") + assert renamed.name == "my_index_name" + + +def test_index_rename_inplace_returns_none(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"idx": [], "col": []} + ).set_index("idx") + index = dataframe.index + assert index.name == "idx" + assert index.rename("my_index_name", inplace=True) is None + + # Make sure the linked DataFrame is updated, too. + assert dataframe.index.name == "my_index_name" + assert index.name == "my_index_name" + + +def test_index_to_list(monkeypatch: pytest.MonkeyPatch): + pd_index = pd.Index([1, 2, 3], name="my_index") + df = mocks.create_dataframe( + monkeypatch, + data={"my_index": [1, 2, 3]}, + ).set_index("my_index") + bf_index = df.index + assert bf_index.to_list() == pd_index.to_list() diff --git a/tests/unit/test_interchange.py b/tests/unit/test_interchange.py new file mode 100644 index 0000000000..87f6c91e23 --- /dev/null +++ b/tests/unit/test_interchange.py @@ -0,0 +1,108 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pathlib +from typing import Generator + +import pandas as pd +import pandas.api.interchange as pd_interchange +import pandas.testing +import pytest + +import bigframes +import bigframes.pandas as bpd +from bigframes.testing.utils import convert_pandas_dtypes + +pytest.importorskip("polars") +pytest.importorskip("pandas", minversion="2.0.0") + +CURRENT_DIR = pathlib.Path(__file__).parent +DATA_DIR = CURRENT_DIR.parent / "data" + + +@pytest.fixture(scope="module", autouse=True) +def session() -> Generator[bigframes.Session, None, None]: + import bigframes.core.global_session + from bigframes.testing import polars_session + + session = polars_session.TestSession() + with bigframes.core.global_session._GlobalSessionContext(session): + yield session + + +@pytest.fixture(scope="module") +def scalars_pandas_df_index() -> pd.DataFrame: + """pd.DataFrame pointing at test data.""" + + df = pd.read_json( + DATA_DIR / "scalars.jsonl", + lines=True, + ) + convert_pandas_dtypes(df, bytes_col=True) + + df = df.set_index("rowindex", drop=False) + df.index.name = None + return df.set_index("rowindex").sort_index() + + +def test_interchange_df_logical_properties(session): + df = bpd.DataFrame({"a": [1, 2, 3], 2: [4, 5, 6]}, session=session) + interchange_df = df.__dataframe__() + assert interchange_df.num_columns() == 2 + assert interchange_df.num_rows() == 3 + assert interchange_df.column_names() == ["a", "2"] + + +def test_interchange_column_logical_properties(session): + df = bpd.DataFrame( + { + "nums": [1, 2, 3, None, None], + "animals": ["cat", "dog", "mouse", "horse", "turtle"], + }, + session=session, + ) + interchange_df = df.__dataframe__() + + assert interchange_df.get_column_by_name("nums").size() == 5 + assert interchange_df.get_column(0).null_count == 2 + + assert interchange_df.get_column_by_name("animals").size() == 5 + assert interchange_df.get_column(1).null_count == 0 + + +def test_interchange_to_pandas(session, scalars_pandas_df_index): + # A few limitations: + # 1) Limited datatype support + # 2) Pandas converts null to NaN/False, rather than use nullable or pyarrow types + # 3) Indices aren't preserved by interchange format + unsupported_cols = [ + "bytes_col", + "date_col", + "numeric_col", + "time_col", + "duration_col", + "geography_col", + ] + scalars_pandas_df_index = scalars_pandas_df_index.drop(columns=unsupported_cols) + scalars_pandas_df_index = scalars_pandas_df_index.bfill().ffill() + bf_df = session.read_pandas(scalars_pandas_df_index) + + from_ix = pd_interchange.from_dataframe(bf_df) + + # interchange format does not include index, so just reset both indices before comparison + pandas.testing.assert_frame_equal( + scalars_pandas_df_index.reset_index(drop=True), + from_ix.reset_index(drop=True), + check_dtype=False, + ) diff --git a/tests/unit/test_local_data.py b/tests/unit/test_local_data.py new file mode 100644 index 0000000000..1537c896fb --- /dev/null +++ b/tests/unit/test_local_data.py @@ -0,0 +1,186 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pandas as pd +import pandas.testing +import pyarrow as pa + +from bigframes import dtypes +from bigframes.core import local_data + +pd_data = pd.DataFrame( + { + "ints": [10, 20, 30, 40, 50], + "nested_ints": [[1, 2], [], [3, 4, 5], [], [20, 30]], + "structs": [{"a": 100}, None, {}, {"b": 200}, {"b": 300}], + } +) + +pd_data_normalized = pd.DataFrame( + { + "ints": pd.Series([10, 20, 30, 40, 50], dtype=dtypes.INT_DTYPE), + "nested_ints": pd.Series( + [[1, 2], [], [3, 4, 5], [], [20, 30]], + dtype=pd.ArrowDtype(pa.list_(pa.int64())), + ), + "structs": pd.Series( + [{"a": 100}, None, {}, {"b": 200}, {"b": 300}], + dtype=pd.ArrowDtype(pa.struct({"a": pa.int64(), "b": pa.int64()})), + ), + } +) + + +def test_local_data_well_formed_round_trip(): + local_entry = local_data.ManagedArrowTable.from_pandas(pd_data) + result = pd.DataFrame(local_entry.itertuples(), columns=pd_data.columns) + result = result.assign( + **{ + col: result[col].astype(pd_data_normalized[col].dtype) + for col in pd_data_normalized.columns + } + ) + pandas.testing.assert_frame_equal(pd_data_normalized, result, check_dtype=False) + + +def test_local_data_small_sizes_round_trip(): + pyarrow_version = int(pa.__version__.split(".")[0]) + + int8s = [126, 127, -127, -128, 0, 1, -1] + uint8s = [254, 255, 1, 0, 128, 129, 127] + int16s = [32766, 32767, -32766, -32767, 0, 1, -1] + uint16s = [65534, 65535, 1, 0, 32768, 32769, 32767] + int32s = [2**31 - 2, 2**31 - 1, -(2**31) + 1, -(2**31), 0, 1, -1] + uint32s = [2**32 - 2, 2**32 - 1, 1, 0, 2**31, 2**31 + 1, 2**31 - 1] + float16s = [ + # Test some edge cases from: + # https://en.wikipedia.org/wiki/Half-precision_floating-point_format#Precision_limitations + float.fromhex("0x1.0p-24"), # (2 ** -24).hex() + float.fromhex("-0x1.0p-24"), + float.fromhex("0x1.ffcp-13"), # ((2 ** -12) - (2 ** -23)).hex() + float.fromhex("-0x1.ffcp-13"), + 0, + float.fromhex("0x1.ffcp+14"), # (32768.0 - 16).hex() + float.fromhex("-0x1.ffcp+14"), + ] + float32s = [ + # Test some edge cases from: + # https://en.wikipedia.org/wiki/Single-precision_floating-point_format#Notable_single-precision_cases + # and + # https://en.wikipedia.org/wiki/Single-precision_floating-point_format#Precision_limitations_on_decimal_values_(between_1_and_16777216) + float.fromhex("0x1.0p-149"), # (2 ** -149).hex() + float.fromhex("-0x1.0p-149"), # (2 ** -149).hex() + float.fromhex("0x1.fffffep-1"), # (1.0 - (2 ** -24)).hex() + float.fromhex("-0x1.fffffep-1"), + 0, + float.fromhex("0x1.fffffcp-127"), # ((2 ** -126) * (1 - 2 ** -23)).hex() + float.fromhex("-0x1.fffffcp-127"), # ((2 ** -126) * (1 - 2 ** -23)).hex() + ] + small_data = { + "int8": pd.Series(int8s, dtype=pd.Int8Dtype()), + "int16": pd.Series(int16s, dtype=pd.Int16Dtype()), + "int32": pd.Series(int32s, dtype=pd.Int32Dtype()), + "uint8": pd.Series(uint8s, dtype=pd.UInt8Dtype()), + "uint16": pd.Series(uint16s, dtype=pd.UInt16Dtype()), + "uint32": pd.Series(uint32s, dtype=pd.UInt32Dtype()), + "float32": pd.Series(float32s, dtype="float32"), + } + expected_data = { + "int8": pd.Series(int8s, dtype=pd.Int64Dtype()), + "int16": pd.Series(int16s, dtype=pd.Int64Dtype()), + "int32": pd.Series(int32s, dtype=pd.Int64Dtype()), + "uint8": pd.Series(uint8s, dtype=pd.Int64Dtype()), + "uint16": pd.Series(uint16s, dtype=pd.Int64Dtype()), + "uint32": pd.Series(uint32s, dtype=pd.Int64Dtype()), + "float32": pd.Series(float32s, dtype=pd.Float64Dtype()), + } + + # Casting from float16 added in version 16. + # https://arrow.apache.org/blog/2024/04/20/16.0.0-release/#:~:text=Enhancements,New%20Features + if pyarrow_version >= 16: + small_data["float16"] = pd.Series(float16s, dtype="float16") + expected_data["float16"] = pd.Series(float16s, dtype=pd.Float64Dtype()) + + small_pd = pd.DataFrame(small_data) + local_entry = local_data.ManagedArrowTable.from_pandas(small_pd) + result = pd.DataFrame(local_entry.itertuples(), columns=small_pd.columns) + + expected = pd.DataFrame(expected_data) + pandas.testing.assert_frame_equal(expected, result, check_dtype=False) + + +def test_local_data_well_formed_round_trip_chunked(): + pa_table = pa.Table.from_pandas(pd_data, preserve_index=False) + as_rechunked_pyarrow = pa.Table.from_batches(pa_table.to_batches(max_chunksize=2)) + local_entry = local_data.ManagedArrowTable.from_pyarrow(as_rechunked_pyarrow) + result = pd.DataFrame(local_entry.itertuples(), columns=pd_data.columns) + result = result.assign( + **{ + col: result[col].astype(pd_data_normalized[col].dtype) + for col in pd_data_normalized.columns + } + ) + pandas.testing.assert_frame_equal(pd_data_normalized, result, check_dtype=False) + + +def test_local_data_well_formed_round_trip_sliced(): + pa_table = pa.Table.from_pandas(pd_data, preserve_index=False) + as_rechunked_pyarrow = pa.Table.from_batches(pa_table.slice(0, 4).to_batches()) + local_entry = local_data.ManagedArrowTable.from_pyarrow(as_rechunked_pyarrow) + result = pd.DataFrame(local_entry.itertuples(), columns=pd_data.columns) + result = result.assign( + **{ + col: result[col].astype(pd_data_normalized[col].dtype) + for col in pd_data_normalized.columns + } + ) + pandas.testing.assert_frame_equal( + pd_data_normalized[0:4].reset_index(drop=True), + result.reset_index(drop=True), + check_dtype=False, + ) + + +def test_local_data_equal_self(): + local_entry = local_data.ManagedArrowTable.from_pandas(pd_data) + assert local_entry == local_entry + assert hash(local_entry) == hash(local_entry) + + +def test_local_data_not_equal_other(): + local_entry = local_data.ManagedArrowTable.from_pandas(pd_data) + local_entry2 = local_data.ManagedArrowTable.from_pandas(pd_data[::2]) + assert local_entry != local_entry2 + assert hash(local_entry) != hash(local_entry2) + + +def test_local_data_itertuples_struct_none(): + pd_data = pd.DataFrame( + { + "structs": [{"a": 100}, None, {"b": 200}, {"b": 300}], + } + ) + local_entry = local_data.ManagedArrowTable.from_pandas(pd_data) + result = list(local_entry.itertuples()) + assert result[1][0] is None + + +def test_local_data_itertuples_list_none(): + pd_data = pd.DataFrame( + { + "lists": [[1, 2], None, [3, 4]], + } + ) + local_entry = local_data.ManagedArrowTable.from_pandas(pd_data) + result = list(local_entry.itertuples()) + assert result[1][0] == [] diff --git a/tests/unit/test_local_engine.py b/tests/unit/test_local_engine.py index 4697c84960..5f80e4928c 100644 --- a/tests/unit/test_local_engine.py +++ b/tests/unit/test_local_engine.py @@ -19,17 +19,10 @@ import bigframes import bigframes.pandas as bpd -from tests.system.utils import skip_legacy_pandas +from bigframes.testing.utils import assert_frame_equal, assert_series_equal pytest.importorskip("polars") - - -# All tests in this file require polars to be installed to pass. -@pytest.fixture(scope="module") -def polars_session(): - from . import polars_session - - return polars_session.TestSession() +pytest.importorskip("pandas", minversion="2.0.0") @pytest.fixture(scope="module") @@ -41,7 +34,7 @@ def small_inline_frame() -> pd.DataFrame: "bools": pd.Series([True, None, False], dtype="boolean"), "strings": pd.Series(["b", "aa", "ccc"], dtype="string[pyarrow]"), "intLists": pd.Series( - [[1, 2, 3], [4, 5, 6, 7], None], + [[1, 2, 3], [4, 5, 6, 7], []], dtype=pd.ArrowDtype(pa.list_(pa.int64())), ), }, @@ -50,8 +43,14 @@ def small_inline_frame() -> pd.DataFrame: return df -# These tests should be unit tests, but Session object is tightly coupled to BigQuery client. -@skip_legacy_pandas +def test_polars_local_engine_series(polars_session: bigframes.Session): + bf_series = bpd.Series([1, 2, 3], session=polars_session) + pd_series = pd.Series([1, 2, 3], dtype=bf_series.dtype) + bf_result = bf_series.to_pandas() + pd_result = pd_series + assert_series_equal(bf_result, pd_result, check_index_type=False) + + def test_polars_local_engine_add( small_inline_frame: pd.DataFrame, polars_session: bigframes.Session ): @@ -63,7 +62,6 @@ def test_polars_local_engine_add( pandas.testing.assert_series_equal(bf_result, pd_result) -@skip_legacy_pandas def test_polars_local_engine_order_by(small_inline_frame: pd.DataFrame, polars_session): pd_df = small_inline_frame bf_df = bpd.DataFrame(pd_df, session=polars_session) @@ -73,17 +71,42 @@ def test_polars_local_engine_order_by(small_inline_frame: pd.DataFrame, polars_s pandas.testing.assert_frame_equal(bf_result, pd_result) -@skip_legacy_pandas def test_polars_local_engine_filter(small_inline_frame: pd.DataFrame, polars_session): pd_df = small_inline_frame bf_df = bpd.DataFrame(pd_df, session=polars_session) - bf_result = bf_df.filter(bf_df["int2"] >= 1).to_pandas() - pd_result = pd_df.filter(pd_df["int2"] >= 1) # type: ignore - pandas.testing.assert_frame_equal(bf_result, pd_result) + bf_result = bf_df[bf_df["int2"] >= 1].to_pandas() + pd_result = pd_df[pd_df["int2"] >= 1] # type: ignore + assert_frame_equal(bf_result, pd_result) + + +def test_polars_local_engine_series_rename_with_mapping(polars_session): + pd_series = pd.Series( + ["a", "b", "c"], index=[1, 2, 3], dtype="string[pyarrow]", name="test_name" + ) + bf_series = bpd.Series(pd_series, session=polars_session) + + bf_result = bf_series.rename({1: 100, 2: 200, 3: 300}).to_pandas() + pd_result = pd_series.rename({1: 100, 2: 200, 3: 300}) + # pd default index is int64, bf is Int64 + assert_series_equal(bf_result, pd_result, check_index_type=False) + + +def test_polars_local_engine_series_rename_with_mapping_inplace(polars_session): + pd_series = pd.Series( + ["a", "b", "c"], index=[1, 2, 3], dtype="string[pyarrow]", name="test_name" + ) + bf_series = bpd.Series(pd_series, session=polars_session) + + pd_series.rename({1: 100, 2: 200, 3: 300}, inplace=True) + assert bf_series.rename({1: 100, 2: 200, 3: 300}, inplace=True) is None + + bf_result = bf_series.to_pandas() + pd_result = pd_series + # pd default index is int64, bf is Int64 + assert_series_equal(bf_result, pd_result, check_index_type=False) -@skip_legacy_pandas def test_polars_local_engine_reset_index( small_inline_frame: pd.DataFrame, polars_session ): @@ -96,7 +119,6 @@ def test_polars_local_engine_reset_index( pandas.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) -@skip_legacy_pandas def test_polars_local_engine_join_binop(polars_session): pd_df_1 = pd.DataFrame({"colA": [1, None, 3], "colB": [3, 1, 2]}, index=[1, 2, 3]) pd_df_2 = pd.DataFrame( @@ -108,15 +130,15 @@ def test_polars_local_engine_join_binop(polars_session): bf_result = (bf_df_1 + bf_df_2).to_pandas() pd_result = pd_df_1 + pd_df_2 # Sort since different join ordering - pandas.testing.assert_frame_equal( + assert_frame_equal( bf_result.sort_index(), pd_result.sort_index(), check_dtype=False, check_index_type=False, + nulls_are_nan=True, ) -@skip_legacy_pandas @pytest.mark.parametrize( "join_type", ["inner", "left", "right", "outer"], @@ -139,7 +161,6 @@ def test_polars_local_engine_joins(join_type, polars_session): ) -@skip_legacy_pandas def test_polars_local_engine_agg(polars_session): pd_df = pd.DataFrame( {"colA": [True, False, True, False, True], "colB": [1, 2, 3, 4, 5]} @@ -152,7 +173,6 @@ def test_polars_local_engine_agg(polars_session): pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False, check_index_type=False) # type: ignore -@skip_legacy_pandas def test_polars_local_engine_groupby_sum(polars_session): pd_df = pd.DataFrame( {"colA": [True, False, True, False, True], "colB": [1, 2, 3, 4, 5]} @@ -166,7 +186,6 @@ def test_polars_local_engine_groupby_sum(polars_session): ) -@skip_legacy_pandas def test_polars_local_engine_cumsum(small_inline_frame, polars_session): pd_df = small_inline_frame[["int1", "int2"]] bf_df = bpd.DataFrame(pd_df, session=polars_session) @@ -176,7 +195,6 @@ def test_polars_local_engine_cumsum(small_inline_frame, polars_session): pandas.testing.assert_frame_equal(bf_result, pd_result) -@skip_legacy_pandas def test_polars_local_engine_explode(small_inline_frame, polars_session): pd_df = small_inline_frame bf_df = bpd.DataFrame(pd_df, session=polars_session) @@ -206,7 +224,6 @@ def test_polars_local_engine_explode(small_inline_frame, polars_session): (7, -7, -2), ], ) -@skip_legacy_pandas def test_polars_local_engine_slice( small_inline_frame, polars_session, start, stop, step ): diff --git a/tests/unit/test_notebook.py b/tests/unit/test_notebook.py index a41854fb29..3feacd52b2 100644 --- a/tests/unit/test_notebook.py +++ b/tests/unit/test_notebook.py @@ -12,12 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pathlib -import os.path +REPO_ROOT = pathlib.Path(__file__).parent.parent.parent def test_template_notebook_exists(): # This notebook is meant for being used as a BigFrames usage template and # could be dynamically linked in places such as BQ Studio and IDE extensions. # Let's make sure it exists in the well known path. - assert os.path.exists("notebooks/getting_started/bq_dataframes_template.ipynb") + assert ( + REPO_ROOT / "notebooks" / "getting_started" / "bq_dataframes_template.ipynb" + ).exists() diff --git a/tests/unit/test_pandas.py b/tests/unit/test_pandas.py index 1ee52c08a1..e1e713697d 100644 --- a/tests/unit/test_pandas.py +++ b/tests/unit/test_pandas.py @@ -64,8 +64,12 @@ def test_method_matches_session(method_name: str): pandas_method = getattr(bigframes.pandas, method_name) pandas_doc = inspect.getdoc(pandas_method) assert pandas_doc is not None, "docstrings are required" - assert re.sub(leading_whitespace, "", pandas_doc) == re.sub( - leading_whitespace, "", session_doc + + pandas_doc_stripped = re.sub(leading_whitespace, "", pandas_doc) + session_doc_stripped = re.sub(leading_whitespace, "", session_doc) + assert ( + pandas_doc_stripped == session_doc_stripped + or ":`bigframes.pandas" in session_doc_stripped ) # Add `eval_str = True` so that deferred annotations are turned into their @@ -75,46 +79,102 @@ def test_method_matches_session(method_name: str): eval_str=True, globals={**vars(bigframes.session), **{"dataframe": bigframes.dataframe}}, ) - pandas_signature = inspect.signature(pandas_method, eval_str=True) - assert [ - # Kind includes position, which will be an offset. - parameter.replace(kind=inspect.Parameter.POSITIONAL_ONLY) - for parameter in pandas_signature.parameters.values() - ] == [ + session_args = [ # Kind includes position, which will be an offset. parameter.replace(kind=inspect.Parameter.POSITIONAL_ONLY) for parameter in session_signature.parameters.values() # Don't include the first parameter, which is `self: Session` - ][ - 1: + ][1:] + pandas_signature = inspect.signature(pandas_method, eval_str=True) + pandas_args = [ + # Kind includes position, which will be an offset. + parameter.replace(kind=inspect.Parameter.POSITIONAL_ONLY) + for parameter in pandas_signature.parameters.values() + ] + assert session_args == pandas_args or ["args", "kwargs"] == [ + parameter.name for parameter in session_args ] assert pandas_signature.return_annotation == session_signature.return_annotation -def test_cut_raises_with_labels(): +@pytest.mark.parametrize( + ("bins", "labels", "error_message"), + [ + pytest.param( + 5, + True, + "Bin labels must either be False, None or passed in as a list-like argument", + id="true", + ), + pytest.param( + 5, + 1.5, + "Bin labels must either be False, None or passed in as a list-like argument", + id="invalid_types", + ), + pytest.param( + 2, + ["A"], + "must be same as the value of bins", + id="int_bins_mismatch", + ), + pytest.param( + [1, 2, 3], + ["A"], + "must be same as the number of bin edges", + id="iterator_bins_mismatch", + ), + ], +) +def test_cut_raises_with_invalid_labels(bins: int, labels, error_message: str): + mock_series = mock.create_autospec(bigframes.pandas.Series, instance=True) + mock_series.__len__.return_value = 5 + with pytest.raises(ValueError, match=error_message): + bigframes.pandas.cut(mock_series, bins, labels=labels) + + +def test_cut_raises_with_unsupported_labels(): + mock_series = mock.create_autospec(bigframes.pandas.Series, instance=True) + labels = [1, 2] with pytest.raises( - NotImplementedError, - match="The 'labels' parameter must be either False or None.", + NotImplementedError, match=r".*only iterables of strings are supported.*" ): - mock_series = mock.create_autospec(bigframes.pandas.Series, instance=True) - bigframes.pandas.cut(mock_series, 4, labels=["a", "b", "c", "d"]) + bigframes.pandas.cut(mock_series, 2, labels=labels) # type: ignore @pytest.mark.parametrize( - ("bins",), - ( - (0,), - (-1,), - ), + ("bins", "error_message"), + [ + pytest.param(1.5, "`bins` must be an integer or interable.", id="float"), + pytest.param(0, "`bins` should be a positive integer.", id="zero_int"), + pytest.param(-1, "`bins` should be a positive integer.", id="neg_int"), + pytest.param( + ["notabreak"], + "`bins` iterable should contain tuples or numerics", + id="iterable_w_wrong_type", + ), + pytest.param( + [10, 3], + "left side of interval must be <= right side", + id="decreased_breaks", + ), + pytest.param( + [(1, 10), (2, 25)], + "Overlapping IntervalIndex is not accepted.", + id="overlapping_intervals", + ), + ], ) -def test_cut_raises_with_invalid_bins(bins: int): - with pytest.raises(ValueError, match="`bins` should be a positive integer."): - mock_series = mock.create_autospec(bigframes.pandas.Series, instance=True) +def test_cut_raises_with_invalid_bins(bins: int, error_message: str): + mock_series = mock.create_autospec(bigframes.pandas.Series, instance=True) + mock_series.__len__.return_value = 5 + + with pytest.raises(ValueError, match=error_message): bigframes.pandas.cut(mock_series, bins, labels=False) def test_pandas_attribute(): - assert bpd.NA is pd.NA + assert pd.NA is pd.NA assert bpd.BooleanDtype is pd.BooleanDtype assert bpd.Float64Dtype is pd.Float64Dtype assert bpd.Int64Dtype is pd.Int64Dtype diff --git a/tests/unit/test_planner.py b/tests/unit/test_planner.py index c64b50395b..66d83f362d 100644 --- a/tests/unit/test_planner.py +++ b/tests/unit/test_planner.py @@ -39,7 +39,6 @@ LEAF: core.ArrayValue = core.ArrayValue.from_table( session=FAKE_SESSION, table=TABLE, - schema=bigframes.core.schema.ArraySchema.from_bq_table(TABLE), ) diff --git a/tests/unit/test_sequences.py b/tests/unit/test_sequences.py new file mode 100644 index 0000000000..d901670b9b --- /dev/null +++ b/tests/unit/test_sequences.py @@ -0,0 +1,55 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import itertools +from typing import Sequence + +import pytest + +from bigframes.core import sequences + +LARGE_LIST = list(range(100, 500)) +SMALL_LIST = list(range(1, 5)) +CHAINED_LIST = sequences.ChainedSequence([SMALL_LIST for i in range(100)]) + + +def _build_reference(*parts): + return tuple(itertools.chain(*parts)) + + +def _check_equivalence(expected: Sequence, actual: Sequence): + assert len(expected) == len(actual) + assert tuple(expected) == tuple(actual) + assert expected[10:1:-2] == actual[10:1:-2] + if len(expected) > 0: + assert expected[len(expected) - 1] == expected[len(actual) - 1] + + +@pytest.mark.parametrize( + ("parts",), + [ + ([],), + ([[]],), + ([[0, 1, 2]],), + ([LARGE_LIST, SMALL_LIST, LARGE_LIST],), + ([SMALL_LIST * 100],), + ([CHAINED_LIST, LARGE_LIST, CHAINED_LIST, SMALL_LIST],), + ], +) +def test_init_chained_sequence_single_slist(parts): + value = sequences.ChainedSequence(*parts) + expected = _build_reference(*parts) + _check_equivalence(expected, value) diff --git a/tests/unit/test_series.py b/tests/unit/test_series.py index 1409209c6c..8a083d7e4a 100644 --- a/tests/unit/test_series.py +++ b/tests/unit/test_series.py @@ -12,7 +12,44 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import cast + +import pytest + import bigframes.series +from bigframes.testing import mocks + + +def test_series_rename(monkeypatch: pytest.MonkeyPatch): + series = cast(bigframes.series.Series, mocks.create_dataframe(monkeypatch)["col"]) + assert series.name == "col" + renamed = series.rename("renamed_col") + assert renamed.name == "renamed_col" + + +def test_series_rename_inplace_returns_none(monkeypatch: pytest.MonkeyPatch): + series = cast(bigframes.series.Series, mocks.create_dataframe(monkeypatch)["col"]) + assert series.name == "col" + assert series.rename("renamed_col", inplace=True) is None + assert series.name == "renamed_col" + + +def test_series_rename_axis(monkeypatch: pytest.MonkeyPatch): + series = mocks.create_dataframe( + monkeypatch, data={"index1": [], "index2": [], "col1": [], "col2": []} + ).set_index(["index1", "index2"])["col1"] + assert list(series.index.names) == ["index1", "index2"] + renamed = series.rename_axis(["a", "b"]) + assert list(renamed.index.names) == ["a", "b"] + + +def test_series_rename_axis_inplace_returns_none(monkeypatch: pytest.MonkeyPatch): + series = mocks.create_dataframe( + monkeypatch, data={"index1": [], "index2": [], "col1": [], "col2": []} + ).set_index(["index1", "index2"])["col1"] + assert list(series.index.names) == ["index1", "index2"] + assert series.rename_axis(["a", "b"], inplace=True) is None + assert list(series.index.names) == ["a", "b"] def test_series_repr_with_uninitialized_object(): diff --git a/tests/unit/test_series_io.py b/tests/unit/test_series_io.py new file mode 100644 index 0000000000..bb0ea15053 --- /dev/null +++ b/tests/unit/test_series_io.py @@ -0,0 +1,50 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import mock + +import pytest + +from bigframes.testing import mocks + + +@pytest.fixture +def mock_series(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe(monkeypatch) + series = dataframe["col"] + monkeypatch.setattr(series, "to_pandas", mock.Mock()) + return series + + +@pytest.mark.parametrize( + "api_name, kwargs", + [ + ("to_csv", {"allow_large_results": True}), + ("to_dict", {"allow_large_results": True}), + ("to_excel", {"excel_writer": "abc", "allow_large_results": True}), + ("to_json", {"allow_large_results": True}), + ("to_latex", {"allow_large_results": True}), + ("to_list", {"allow_large_results": True}), + ("to_markdown", {"allow_large_results": True}), + ("to_numpy", {"allow_large_results": True}), + ("to_pickle", {"path": "abc", "allow_large_results": True}), + ("to_string", {"allow_large_results": True}), + ("to_xarray", {"allow_large_results": True}), + ], +) +def test_series_allow_large_results_param_passing(mock_series, api_name, kwargs): + getattr(mock_series, api_name)(**kwargs) + mock_series.to_pandas.assert_called_once_with( + allow_large_results=kwargs["allow_large_results"] + ) diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py new file mode 100644 index 0000000000..516a46d4dd --- /dev/null +++ b/tests/unit/test_series_polars.py @@ -0,0 +1,5141 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime as dt +import json +import math +import operator +import pathlib +import re +import tempfile +from typing import Generator + +import db_dtypes # type: ignore +import geopandas as gpd # type: ignore +import google.api_core.exceptions +import numpy +from packaging.version import Version +import pandas as pd +import pyarrow as pa # type: ignore +import pytest +import shapely.geometry # type: ignore + +import bigframes +import bigframes.dtypes as dtypes +import bigframes.features +import bigframes.pandas +import bigframes.pandas as bpd +import bigframes.series as series +from bigframes.testing.utils import ( + assert_frame_equal, + assert_series_equal, + convert_pandas_dtypes, + get_first_file_from_wildcard, + pandas_major_version, +) + +pytest.importorskip("polars") +pytest.importorskip("pandas", minversion="2.0.0") + +CURRENT_DIR = pathlib.Path(__file__).parent +DATA_DIR = CURRENT_DIR.parent / "data" + + +@pytest.fixture(scope="module", autouse=True) +def session() -> Generator[bigframes.Session, None, None]: + import bigframes.core.global_session + from bigframes.testing import polars_session + + session = polars_session.TestSession() + with bigframes.core.global_session._GlobalSessionContext(session): + yield session + + +@pytest.fixture(scope="module") +def scalars_pandas_df_index() -> pd.DataFrame: + """pd.DataFrame pointing at test data.""" + + df = pd.read_json( + DATA_DIR / "scalars.jsonl", + lines=True, + ) + convert_pandas_dtypes(df, bytes_col=True) + + df = df.set_index("rowindex", drop=False) + df.index.name = None + return df.set_index("rowindex").sort_index() + + +@pytest.fixture(scope="module") +def scalars_df_default_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index).reset_index(drop=False) + + +@pytest.fixture(scope="module") +def scalars_df_2_default_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index).reset_index(drop=False) + + +@pytest.fixture(scope="module") +def scalars_df_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index) + + +@pytest.fixture(scope="module") +def scalars_df_2_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index) + + +@pytest.fixture(scope="module") +def scalars_dfs( + scalars_df_index, + scalars_pandas_df_index, +): + return scalars_df_index, scalars_pandas_df_index + + +def test_series_construct_copy(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = series.Series( + scalars_df["int64_col"], name="test_series", dtype="Float64" + ).to_pandas() + pd_result = pd.Series( + scalars_pandas_df["int64_col"], name="test_series", dtype="Float64" + ) + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_nullable_ints(): + bf_result = series.Series( + [1, 3, bigframes.pandas.NA], index=[0, 4, bigframes.pandas.NA] + ).to_pandas() + + # TODO(b/340885567): fix type error + expected_index = pd.Index( # type: ignore + [0, 4, None], + dtype=pd.Int64Dtype(), + ) + expected = pd.Series([1, 3, pd.NA], dtype=pd.Int64Dtype(), index=expected_index) + + pd.testing.assert_series_equal(bf_result, expected) + + +def test_series_construct_timestamps(): + datetimes = [ + dt.datetime(2020, 1, 20, 20, 20, 20, 20), + dt.datetime(2019, 1, 20, 20, 20, 20, 20), + None, + ] + bf_result = series.Series(datetimes).to_pandas() + pd_result = pd.Series(datetimes, dtype=pd.ArrowDtype(pa.timestamp("us"))) + + assert_series_equal(bf_result, pd_result, check_index_type=False) + + +def test_series_construct_copy_with_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = series.Series( + scalars_df["int64_col"], + name="test_series", + dtype="Float64", + index=scalars_df["int64_too"], + ).to_pandas() + pd_result = pd.Series( + scalars_pandas_df["int64_col"], + name="test_series", + dtype="Float64", + index=scalars_pandas_df["int64_too"], + ) + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_copy_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = series.Series( + scalars_df.index, + name="test_series", + dtype="Float64", + index=scalars_df["int64_too"], + ).to_pandas() + pd_result = pd.Series( + scalars_pandas_df.index, + name="test_series", + dtype="Float64", + index=scalars_pandas_df["int64_too"], + ) + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_pandas(scalars_dfs): + _, scalars_pandas_df = scalars_dfs + bf_result = series.Series( + scalars_pandas_df["int64_col"], name="test_series", dtype="Float64" + ) + pd_result = pd.Series( + scalars_pandas_df["int64_col"], name="test_series", dtype="Float64" + ) + assert bf_result.shape == pd_result.shape + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +def test_series_construct_from_list(): + bf_result = series.Series([1, 1, 2, 3, 5, 8, 13], dtype="Int64").to_pandas() + pd_result = pd.Series([1, 1, 2, 3, 5, 8, 13], dtype="Int64") + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_reindex(): + bf_result = series.Series( + series.Series({1: 10, 2: 30, 3: 30}), index=[3, 2], dtype="Int64" + ).to_pandas() + pd_result = pd.Series(pd.Series({1: 10, 2: 30, 3: 30}), index=[3, 2], dtype="Int64") + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_from_list_w_index(): + bf_result = series.Series( + [1, 1, 2, 3, 5, 8, 13], index=[10, 20, 30, 40, 50, 60, 70], dtype="Int64" + ).to_pandas() + pd_result = pd.Series( + [1, 1, 2, 3, 5, 8, 13], index=[10, 20, 30, 40, 50, 60, 70], dtype="Int64" + ) + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_empty(session: bigframes.Session): + bf_series: series.Series = series.Series(session=session) + pd_series: pd.Series = pd.Series() + + bf_result = bf_series.empty + pd_result = pd_series.empty + + assert pd_result + assert bf_result == pd_result + + +def test_series_construct_scalar_no_index(): + bf_result = series.Series("hello world", dtype="string[pyarrow]").to_pandas() + pd_result = pd.Series("hello world", dtype="string[pyarrow]") + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_scalar_w_index(): + bf_result = series.Series( + "hello world", dtype="string[pyarrow]", index=[0, 2, 1] + ).to_pandas() + pd_result = pd.Series("hello world", dtype="string[pyarrow]", index=[0, 2, 1]) + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_nan(): + bf_result = series.Series(numpy.nan).to_pandas() + pd_result = pd.Series(numpy.nan) + + pd_result.index = pd_result.index.astype("Int64") + pd_result = pd_result.astype("Float64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_scalar_w_bf_index(): + bf_result = series.Series( + "hello", index=bigframes.pandas.Index([1, 2, 3]) + ).to_pandas() + pd_result = pd.Series("hello", index=pd.Index([1, 2, 3], dtype="Int64")) + + pd_result = pd_result.astype("string[pyarrow]") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_from_list_escaped_strings(): + """Check that special characters are supported.""" + strings = [ + "string\nwith\nnewline", + "string\twith\ttabs", + "string\\with\\backslashes", + ] + bf_result = series.Series(strings, name="test_series", dtype="string[pyarrow]") + pd_result = pd.Series(strings, name="test_series", dtype="string[pyarrow]") + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +def test_series_construct_geodata(): + pd_series = pd.Series( + [ + shapely.geometry.Point(1, 1), + shapely.geometry.Point(2, 2), + shapely.geometry.Point(3, 3), + ], + dtype=gpd.array.GeometryDtype(), + ) + + series = bigframes.pandas.Series(pd_series) + + assert_series_equal(pd_series, series.to_pandas(), check_index_type=False) + + +@pytest.mark.parametrize( + ("dtype"), + [ + pytest.param(pd.Int64Dtype(), id="int"), + pytest.param(pd.Float64Dtype(), id="float"), + pytest.param(pd.StringDtype(storage="pyarrow"), id="string"), + ], +) +def test_series_construct_w_dtype(dtype): + data = [1, 2, 3] + expected = pd.Series(data, dtype=dtype) + expected.index = expected.index.astype("Int64") + series = bigframes.pandas.Series(data, dtype=dtype) + pd.testing.assert_series_equal(series.to_pandas(), expected) + + +def test_series_construct_w_dtype_for_struct(): + # The data shows the struct fields are disordered and correctly handled during + # construction. + data = [ + {"a": 1, "c": "pandas", "b": dt.datetime(2020, 1, 20, 20, 20, 20, 20)}, + {"a": 2, "c": "pandas", "b": dt.datetime(2019, 1, 20, 20, 20, 20, 20)}, + {"a": 1, "c": "numpy", "b": None}, + ] + dtype = pd.ArrowDtype( + pa.struct([("a", pa.int64()), ("c", pa.string()), ("b", pa.timestamp("us"))]) + ) + series = bigframes.pandas.Series(data, dtype=dtype) + expected = pd.Series(data, dtype=dtype) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(series.to_pandas(), expected) + + +def test_series_construct_w_dtype_for_array_string(): + data = [["1", "2", "3"], [], ["4", "5"]] + dtype = pd.ArrowDtype(pa.list_(pa.string())) + series = bigframes.pandas.Series(data, dtype=dtype) + expected = pd.Series(data, dtype=dtype) + expected.index = expected.index.astype("Int64") + + # Skip dtype check due to internal issue b/321013333. This issue causes array types + # to be converted to the `object` dtype when calling `to_pandas()`, resulting in + # a mismatch with the expected Pandas type. + if bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable: + check_dtype = True + else: + check_dtype = False + + pd.testing.assert_series_equal( + series.to_pandas(), expected, check_dtype=check_dtype + ) + + +def test_series_construct_w_dtype_for_array_struct(): + data = [[{"a": 1, "c": "aa"}, {"a": 2, "c": "bb"}], [], [{"a": 3, "c": "cc"}]] + dtype = pd.ArrowDtype(pa.list_(pa.struct([("a", pa.int64()), ("c", pa.string())]))) + series = bigframes.pandas.Series(data, dtype=dtype) + expected = pd.Series(data, dtype=dtype) + expected.index = expected.index.astype("Int64") + + # Skip dtype check due to internal issue b/321013333. This issue causes array types + # to be converted to the `object` dtype when calling `to_pandas()`, resulting in + # a mismatch with the expected Pandas type. + if bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable: + check_dtype = True + else: + check_dtype = False + + pd.testing.assert_series_equal( + series.to_pandas(), expected, check_dtype=check_dtype + ) + + +def test_series_construct_local_unordered_has_sequential_index(session): + series = bigframes.pandas.Series( + ["Sun", "Mon", "Tues", "Wed", "Thurs", "Fri", "Sat"], session=session + ) + expected: pd.Index = pd.Index([0, 1, 2, 3, 4, 5, 6], dtype=pd.Int64Dtype()) + pd.testing.assert_index_equal(series.index.to_pandas(), expected) + + +@pytest.mark.parametrize( + ("json_type"), + [ + pytest.param(dtypes.JSON_DTYPE), + pytest.param("json"), + ], +) +def test_series_construct_w_json_dtype(json_type): + data = [ + "1", + '"str"', + "false", + '["a", {"b": 1}, null]', + None, + '{"a": {"b": [1, 2, 3], "c": true}}', + ] + s = bigframes.pandas.Series(data, dtype=json_type) + + assert s.dtype == dtypes.JSON_DTYPE + assert s[0] == "1" + assert s[1] == '"str"' + assert s[2] == "false" + assert s[3] == '["a",{"b":1},null]' + assert pd.isna(s[4]) + assert s[5] == '{"a":{"b":[1,2,3],"c":true}}' + + +def test_series_keys(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].keys().to_pandas() + pd_result = scalars_pandas_df["int64_col"].keys() + pd.testing.assert_index_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ["data", "index"], + [ + (["a", "b", "c"], None), + ([1, 2, 3], ["a", "b", "c"]), + ([1, 2, None], ["a", "b", "c"]), + ([1, 2, 3], [pd.NA, "b", "c"]), + ([numpy.nan, 2, 3], ["a", "b", "c"]), + ], +) +def test_series_items(data, index): + bf_series = series.Series(data, index=index) + pd_series = pd.Series(data, index=index) + + for (bf_index, bf_value), (pd_index, pd_value) in zip( + bf_series.items(), pd_series.items() + ): + # TODO(jialuo): Remove the if conditions after b/373699458 is addressed. + if not pd.isna(bf_index) or not pd.isna(pd_index): + assert bf_index == pd_index + if not pd.isna(bf_value) or not pd.isna(pd_value): + assert bf_value == pd_value + + +@pytest.mark.parametrize( + ["col_name", "expected_dtype"], + [ + ("bool_col", pd.BooleanDtype()), + # TODO(swast): Use a more efficient type. + ("bytes_col", pd.ArrowDtype(pa.binary())), + ("date_col", pd.ArrowDtype(pa.date32())), + ("datetime_col", pd.ArrowDtype(pa.timestamp("us"))), + ("float64_col", pd.Float64Dtype()), + ("geography_col", gpd.array.GeometryDtype()), + ("int64_col", pd.Int64Dtype()), + # TODO(swast): Use a more efficient type. + ("numeric_col", pd.ArrowDtype(pa.decimal128(38, 9))), + ("int64_too", pd.Int64Dtype()), + ("string_col", pd.StringDtype(storage="pyarrow")), + ("time_col", pd.ArrowDtype(pa.time64("us"))), + ("timestamp_col", pd.ArrowDtype(pa.timestamp("us", tz="UTC"))), + ], +) +def test_get_column(scalars_dfs, col_name, expected_dtype): + scalars_df, scalars_pandas_df = scalars_dfs + series = scalars_df[col_name] + series_pandas = series.to_pandas() + assert series_pandas.dtype == expected_dtype + assert series_pandas.shape[0] == scalars_pandas_df.shape[0] + + +def test_series_get_column_default(scalars_dfs): + scalars_df, _ = scalars_dfs + result = scalars_df.get(123123123123123, "default_val") + assert result == "default_val" + + +@pytest.mark.parametrize( + ("key",), + [ + ("hello",), + (2,), + ("int64_col",), + (None,), + ], +) +def test_series_contains(scalars_df_index, scalars_pandas_df_index, key): + bf_result = key in scalars_df_index["int64_col"] + pd_result = key in scalars_pandas_df_index["int64_col"] + + assert bf_result == pd_result + + +def test_series_equals_identical(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_col.equals(scalars_df_index.int64_col) + pd_result = scalars_pandas_df_index.int64_col.equals( + scalars_pandas_df_index.int64_col + ) + + assert pd_result == bf_result + + +def test_series_equals_df(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_col"].equals(scalars_df_index[["int64_col"]]) + pd_result = scalars_pandas_df_index["int64_col"].equals( + scalars_pandas_df_index[["int64_col"]] + ) + + assert pd_result == bf_result + + +def test_series_equals_different_dtype(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_col"] + pd_series = scalars_pandas_df_index["int64_col"] + + bf_result = bf_series.equals(bf_series.astype("Float64")) + pd_result = pd_series.equals(pd_series.astype("Float64")) + + assert pd_result == bf_result + + +def test_series_equals_different_values(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_col"] + pd_series = scalars_pandas_df_index["int64_col"] + + bf_result = bf_series.equals(bf_series + 1) + pd_result = pd_series.equals(pd_series + 1) + + assert pd_result == bf_result + + +def test_series_get_with_default_index(scalars_dfs): + col_name = "float64_col" + key = 2 + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].get(key) + pd_result = scalars_pandas_df[col_name].get(key) + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("index_col", "key"), + ( + ("int64_too", 2), + ("string_col", "Hello, World!"), + ("int64_too", slice(2, 6)), + ), +) +def test_series___getitem__(scalars_dfs, index_col, key): + col_name = "float64_col" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + bf_result = scalars_df[col_name][key] + pd_result = scalars_pandas_df[col_name][key] + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +@pytest.mark.parametrize( + ("key",), + ( + (-2,), + (-1,), + (0,), + (1,), + ), +) +def test_series___getitem___with_int_key(scalars_dfs, key): + if pd.__version__.startswith("3."): + pytest.skip("pandas 3.0 dropped getitem with int key") + col_name = "int64_too" + index_col = "string_col" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + bf_result = scalars_df[col_name][key] + pd_result = scalars_pandas_df[col_name][key] + assert bf_result == pd_result + + +def test_series___getitem___with_default_index(scalars_dfs): + col_name = "float64_col" + key = 2 + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name][key] + pd_result = scalars_pandas_df[col_name][key] + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("index_col", "key", "value"), + ( + ("int64_too", 2, "new_string_value"), + ("string_col", "Hello, World!", "updated_value"), + ("int64_too", 0, None), + ), +) +def test_series___setitem__(scalars_dfs, index_col, key, value): + col_name = "string_col" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name].copy() + + bf_series[key] = value + pd_series[key] = value + + pd.testing.assert_series_equal(bf_series.to_pandas(), pd_series) + + +@pytest.mark.parametrize( + ("key", "value"), + ( + (0, 999), + (1, 888), + (0, None), + (-2345, 777), + ), +) +def test_series___setitem___with_int_key_numeric(scalars_dfs, key, value): + col_name = "int64_col" + index_col = "int64_too" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name].copy() + + bf_series[key] = value + pd_series[key] = value + + pd.testing.assert_series_equal(bf_series.to_pandas(), pd_series) + + +def test_series___setitem___with_default_index(scalars_dfs): + col_name = "float64_col" + key = 2 + value = 123.456 + scalars_df, scalars_pandas_df = scalars_dfs + + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name].copy() + + bf_series[key] = value + pd_series[key] = value + + assert bf_series.to_pandas().iloc[key] == pd_series.iloc[key] + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_too",), + ), +) +def test_abs(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].abs().to_pandas() + pd_result = scalars_pandas_df[col_name].abs() + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_too",), + ), +) +def test_series_pos(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (+scalars_df[col_name]).to_pandas() + pd_result = +scalars_pandas_df[col_name] + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_too",), + ), +) +def test_series_neg(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (-scalars_df[col_name]).to_pandas() + pd_result = -scalars_pandas_df[col_name] + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("bool_col",), + ("int64_col",), + ), +) +def test_series_invert(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (~scalars_df[col_name]).to_pandas() + pd_result = ~scalars_pandas_df[col_name] + + assert_series_equal(pd_result, bf_result) + + +def test_fillna(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name].fillna("Missing").to_pandas() + pd_result = scalars_pandas_df[col_name].fillna("Missing") + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_series_replace_scalar_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = ( + scalars_df[col_name].replace("Hello, World!", "Howdy, Planet!").to_pandas() + ) + pd_result = scalars_pandas_df[col_name].replace("Hello, World!", "Howdy, Planet!") + + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +def test_series_replace_list_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = ( + scalars_df[col_name] + .replace(["Hello, World!", "T"], "Howdy, Planet!") + .to_pandas() + ) + pd_result = scalars_pandas_df[col_name].replace( + ["Hello, World!", "T"], "Howdy, Planet!" + ) + + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ("replacement_dict",), + (({},),), + ids=[ + "empty", + ], +) +def test_series_replace_dict(scalars_dfs, replacement_dict): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name].replace(replacement_dict).to_pandas() + pd_result = scalars_pandas_df[col_name].replace(replacement_dict) + + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ("method",), + ( + ("linear",), + ("values",), + ("slinear",), + ("nearest",), + ("zero",), + ("pad",), + ), +) +def test_series_interpolate(method): + pytest.importorskip("scipy") + if method == "pad" and pd.__version__.startswith("3."): + pytest.skip("pandas 3.0 dropped method='pad'") + + values = [None, 1, 2, None, None, 16, None] + index = [-3.2, 11.4, 3.56, 4, 4.32, 5.55, 76.8] + pd_series = pd.Series(values, index) + bf_series = series.Series(pd_series) + + # Pandas can only interpolate on "float64" columns + # https://github.com/pandas-dev/pandas/issues/40252 + pd_result = pd_series.astype("float64").interpolate(method=method) + bf_result = bf_series.interpolate(method=method).to_pandas() + + # pd uses non-null types, while bf uses nullable types + assert_series_equal( + pd_result, + bf_result, + check_index_type=False, + check_dtype=False, + nulls_are_nan=True, + ) + + +@pytest.mark.parametrize( + ("ignore_index",), + ( + (True,), + (False,), + ), +) +def test_series_dropna(scalars_dfs, ignore_index): + if pd.__version__.startswith("1."): + pytest.skip("ignore_index parameter not supported in pandas 1.x.") + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name].dropna(ignore_index=ignore_index).to_pandas() + pd_result = scalars_pandas_df[col_name].dropna(ignore_index=ignore_index) + assert_series_equal(pd_result, bf_result, check_index_type=False) + + +@pytest.mark.parametrize( + ("agg",), + ( + ("sum",), + ("size",), + ), +) +def test_series_agg_single_string(scalars_dfs, agg): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].agg(agg) + pd_result = scalars_pandas_df["int64_col"].agg(agg) + assert math.isclose(pd_result, bf_result) + + +def test_series_agg_multi_string(scalars_dfs): + aggregations = [ + "sum", + "mean", + "std", + "var", + "min", + "max", + "nunique", + "count", + "size", + ] + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].agg(aggregations).to_pandas() + pd_result = scalars_pandas_df["int64_col"].agg(aggregations) + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + pd_result = pd_result.astype("Float64") + + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("string_col",), + ("int64_col",), + ), +) +def test_max(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].max() + pd_result = scalars_pandas_df[col_name].max() + assert pd_result == bf_result + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("string_col",), + ("int64_col",), + ), +) +def test_min(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].min() + pd_result = scalars_pandas_df[col_name].min() + assert pd_result == bf_result + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_col",), + ), +) +def test_std(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].std() + pd_result = scalars_pandas_df[col_name].std() + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_col",), + ), +) +def test_kurt(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].kurt() + pd_result = scalars_pandas_df[col_name].kurt() + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_col",), + ), +) +def test_skew(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].skew() + pd_result = scalars_pandas_df[col_name].skew() + assert math.isclose(pd_result, bf_result) + + +def test_skew_undefined(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].iloc[:2].skew() + pd_result = scalars_pandas_df["int64_col"].iloc[:2].skew() + # both should be pd.NA + assert pd_result is bf_result + + +def test_kurt_undefined(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].iloc[:3].kurt() + pd_result = scalars_pandas_df["int64_col"].iloc[:3].kurt() + # both should be pd.NA + assert pd_result is bf_result + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_col",), + ), +) +def test_var(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].var() + pd_result = scalars_pandas_df[col_name].var() + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("bool_col",), + ("int64_col",), + ), +) +def test_mode_stat(scalars_df_index, scalars_pandas_df_index, col_name): + bf_result = scalars_df_index[col_name].mode().to_pandas() + pd_result = scalars_pandas_df_index[col_name].mode() + + ## Mode implicitly resets index, and bigframes default indices use nullable Int64 + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("operator"), + [ + (lambda x, y: x + y), + (lambda x, y: x - y), + (lambda x, y: x * y), + (lambda x, y: x / y), + (lambda x, y: x // y), + (lambda x, y: x < y), + (lambda x, y: x > y), + (lambda x, y: x <= y), + (lambda x, y: x >= y), + ], + ids=[ + "add", + "subtract", + "multiply", + "divide", + "floordivide", + "less_than", + "greater_than", + "less_than_equal", + "greater_than_equal", + ], +) +@pytest.mark.parametrize( + ("other_scalar"), + [ + -1, + 0, + 14, + # TODO(tswast): Support pd.NA, + ], +) +@pytest.mark.parametrize(("reverse_operands"), [True, False]) +def test_series_int_int_operators_scalar( + scalars_dfs, operator, other_scalar, reverse_operands +): + scalars_df, scalars_pandas_df = scalars_dfs + + maybe_reversed_op = (lambda x, y: operator(y, x)) if reverse_operands else operator + + bf_result = maybe_reversed_op(scalars_df["int64_col"], other_scalar).to_pandas() + pd_result = maybe_reversed_op(scalars_pandas_df["int64_col"], other_scalar) + + # don't check dtype, as pandas is a bit unstable here across versions, esp floordiv + assert_series_equal(pd_result, bf_result, check_dtype=False) + + +def test_series_pow_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (scalars_df["int64_col"] ** 2).to_pandas() + pd_result = scalars_pandas_df["int64_col"] ** 2 + + assert_series_equal(pd_result, bf_result) + + +def test_series_pow_scalar_reverse(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (0.8 ** scalars_df["int64_col"]).to_pandas() + pd_result = 0.8 ** scalars_pandas_df["int64_col"] + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("operator"), + [ + (lambda x, y: x & y), + (lambda x, y: x | y), + (lambda x, y: x ^ y), + ], + ids=[ + "and", + "or", + "xor", + ], +) +@pytest.mark.parametrize( + ("other_scalar"), + [ + True, + False, + pytest.param( + pd.NA, + marks=[ + pytest.mark.skip( + reason="https://github.com/pola-rs/polars/issues/24809" + ) + ], + id="NULL", + ), + ], +) +@pytest.mark.parametrize(("reverse_operands"), [True, False]) +def test_series_bool_bool_operators_scalar( + scalars_dfs, operator, other_scalar, reverse_operands +): + scalars_df, scalars_pandas_df = scalars_dfs + + maybe_reversed_op = (lambda x, y: operator(y, x)) if reverse_operands else operator + + bf_result = maybe_reversed_op(scalars_df["bool_col"], other_scalar).to_pandas() + pd_result = maybe_reversed_op(scalars_pandas_df["bool_col"], other_scalar) + + assert_series_equal(pd_result.astype(pd.BooleanDtype()), bf_result) + + +@pytest.mark.parametrize( + ("operator"), + [ + (lambda x, y: x + y), + (lambda x, y: x - y), + (lambda x, y: x * y), + (lambda x, y: x / y), + (lambda x, y: x < y), + (lambda x, y: x > y), + (lambda x, y: x <= y), + (lambda x, y: x >= y), + (lambda x, y: x % y), + (lambda x, y: x // y), + (lambda x, y: x & y), + (lambda x, y: x | y), + (lambda x, y: x ^ y), + ], + ids=[ + "add", + "subtract", + "multiply", + "divide", + "less_than", + "greater_than", + "less_than_equal", + "greater_than_equal", + "modulo", + "floordivide", + "bitwise_and", + "bitwise_or", + "bitwise_xor", + ], +) +def test_series_int_int_operators_series(scalars_dfs, operator): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = operator(scalars_df["int64_col"], scalars_df["int64_too"]).to_pandas() + pd_result = operator(scalars_pandas_df["int64_col"], scalars_pandas_df["int64_too"]) + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_x",), + [ + ("int64_col",), + ("int64_too",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("col_y",), + [ + ("int64_col",), + ("int64_too",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("method",), + [ + ("mod",), + ("rmod",), + ], +) +def test_mods(scalars_dfs, col_x, col_y, method): + scalars_df, scalars_pandas_df = scalars_dfs + x_bf = scalars_df[col_x] + y_bf = scalars_df[col_y] + bf_series = getattr(x_bf, method)(y_bf) + # BigQuery's mod functions return [BIG]NUMERIC values unless both arguments are integers. + # https://cloud.google.com/bigquery/docs/reference/standard-sql/mathematical_functions#mod + if x_bf.dtype == pd.Int64Dtype() and y_bf.dtype == pd.Int64Dtype(): + bf_result = bf_series.to_pandas() + else: + bf_result = bf_series.astype("Float64").to_pandas() + pd_result = getattr(scalars_pandas_df[col_x], method)(scalars_pandas_df[col_y]) + assert_series_equal(pd_result, bf_result, nulls_are_nan=True) + + +# We work around a pandas bug that doesn't handle correlating nullable dtypes by doing this +# manually with dumb self-correlation instead of parameterized as test_mods is above. +def test_series_corr(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_too"].corr(scalars_df["int64_too"]) + pd_result = ( + scalars_pandas_df["int64_too"] + .astype("int64") + .corr(scalars_pandas_df["int64_too"].astype("int64")) + ) + assert math.isclose(pd_result, bf_result) + + +def test_series_autocorr(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["float64_col"].autocorr(2) + pd_result = scalars_pandas_df["float64_col"].autocorr(2) + assert math.isclose(pd_result, bf_result) + + +def test_series_cov(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_too"].cov(scalars_df["int64_too"]) + pd_result = ( + scalars_pandas_df["int64_too"] + .astype("int64") + .cov(scalars_pandas_df["int64_too"].astype("int64")) + ) + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_x",), + [ + ("int64_col",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("col_y",), + [ + ("int64_col",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("method",), + [ + ("divmod",), + ("rdivmod",), + ], +) +def test_divmods_series(scalars_dfs, col_x, col_y, method): + scalars_df, scalars_pandas_df = scalars_dfs + bf_div_result, bf_mod_result = getattr(scalars_df[col_x], method)(scalars_df[col_y]) + pd_div_result, pd_mod_result = getattr(scalars_pandas_df[col_x], method)( + scalars_pandas_df[col_y] + ) + # BigQuery's mod functions return NUMERIC values for non-INT64 inputs. + if bf_div_result.dtype == pd.Int64Dtype(): + pd.testing.assert_series_equal(pd_div_result, bf_div_result.to_pandas()) + else: + pd.testing.assert_series_equal( + pd_div_result, bf_div_result.astype("Float64").to_pandas() + ) + + if bf_mod_result.dtype == pd.Int64Dtype(): + pd.testing.assert_series_equal(pd_mod_result, bf_mod_result.to_pandas()) + else: + pd.testing.assert_series_equal( + pd_mod_result, bf_mod_result.astype("Float64").to_pandas() + ) + + +@pytest.mark.parametrize( + ("col_x",), + [ + ("int64_col",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("other",), + [ + (-1000,), + (678,), + ], +) +@pytest.mark.parametrize( + ("method",), + [ + ("divmod",), + ("rdivmod",), + ], +) +def test_divmods_scalars(scalars_dfs, col_x, other, method): + scalars_df, scalars_pandas_df = scalars_dfs + bf_div_result, bf_mod_result = getattr(scalars_df[col_x], method)(other) + pd_div_result, pd_mod_result = getattr(scalars_pandas_df[col_x], method)(other) + # BigQuery's mod functions return NUMERIC values for non-INT64 inputs. + if bf_div_result.dtype == pd.Int64Dtype(): + pd.testing.assert_series_equal(pd_div_result, bf_div_result.to_pandas()) + else: + pd.testing.assert_series_equal( + pd_div_result, bf_div_result.astype("Float64").to_pandas() + ) + + if bf_mod_result.dtype == pd.Int64Dtype(): + pd.testing.assert_series_equal(pd_mod_result, bf_mod_result.to_pandas()) + else: + pd.testing.assert_series_equal( + pd_mod_result, bf_mod_result.astype("Float64").to_pandas() + ) + + +@pytest.mark.parametrize( + ("other",), + [ + (3,), + (-6.2,), + ], +) +def test_series_add_scalar(scalars_dfs, other): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (scalars_df["float64_col"] + other).to_pandas() + pd_result = scalars_pandas_df["float64_col"] + other + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("left_col", "right_col"), + [ + ("float64_col", "float64_col"), + ("int64_col", "float64_col"), + ("int64_col", "int64_too"), + ], +) +def test_series_add_bigframes_series(scalars_dfs, left_col, right_col): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (scalars_df[left_col] + scalars_df[right_col]).to_pandas() + pd_result = scalars_pandas_df[left_col] + scalars_pandas_df[right_col] + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("left_col", "right_col", "righter_col"), + [ + ("float64_col", "float64_col", "float64_col"), + ("int64_col", "int64_col", "int64_col"), + ], +) +def test_series_add_bigframes_series_nested( + scalars_dfs, left_col, right_col, righter_col +): + """Test that we can correctly add multiple times.""" + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = ( + (scalars_df[left_col] + scalars_df[right_col]) + scalars_df[righter_col] + ).to_pandas() + pd_result = ( + scalars_pandas_df[left_col] + scalars_pandas_df[right_col] + ) + scalars_pandas_df[righter_col] + + assert_series_equal(pd_result, bf_result) + + +def test_series_add_different_table_default_index( + scalars_df_default_index, + scalars_df_2_default_index, +): + bf_result = ( + scalars_df_default_index["float64_col"] + + scalars_df_2_default_index["float64_col"] + ).to_pandas() + pd_result = ( + # Default index may not have a well defined order, but it should at + # least be consistent across to_pandas() calls. + scalars_df_default_index["float64_col"].to_pandas() + + scalars_df_2_default_index["float64_col"].to_pandas() + ) + # TODO(swast): Can remove sort_index() when there's default ordering. + pd.testing.assert_series_equal(bf_result.sort_index(), pd_result.sort_index()) + + +def test_series_add_different_table_with_index( + scalars_df_index, scalars_df_2_index, scalars_pandas_df_index +): + scalars_pandas_df = scalars_pandas_df_index + bf_result = scalars_df_index["float64_col"] + scalars_df_2_index["int64_col"] + # When index values are unique, we can emulate with values from the same + # DataFrame. + pd_result = scalars_pandas_df["float64_col"] + scalars_pandas_df["int64_col"] + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +def test_reset_index_drop(scalars_df_index, scalars_pandas_df_index): + scalars_pandas_df = scalars_pandas_df_index + bf_result = ( + scalars_df_index["float64_col"] + .sort_index(ascending=False) + .reset_index(drop=True) + ).iloc[::2] + pd_result = ( + scalars_pandas_df["float64_col"] + .sort_index(ascending=False) + .reset_index(drop=True) + ).iloc[::2] + + # BigQuery DataFrames default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +def test_series_reset_index_allow_duplicates(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_col"].copy() + bf_series.index.name = "int64_col" + df = bf_series.reset_index(allow_duplicates=True, drop=False) + assert df.index.name is None + + bf_result = df.to_pandas() + + pd_series = scalars_pandas_df_index["int64_col"].copy() + pd_series.index.name = "int64_col" + pd_result = pd_series.reset_index(allow_duplicates=True, drop=False) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_series_reset_index_duplicates_error(scalars_df_index): + scalars_df_index = scalars_df_index["int64_col"].copy() + scalars_df_index.index.name = "int64_col" + with pytest.raises(ValueError): + scalars_df_index.reset_index(allow_duplicates=False, drop=False) + + +def test_series_reset_index_inplace(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.sort_index(ascending=False)["float64_col"] + bf_result.reset_index(drop=True, inplace=True) + pd_result = scalars_pandas_df_index.sort_index(ascending=False)["float64_col"] + pd_result.reset_index(drop=True, inplace=True) + + # BigQuery DataFrames default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +@pytest.mark.parametrize( + ("name",), + [ + ("some_name",), + (None,), + ], +) +def test_reset_index_no_drop(scalars_df_index, scalars_pandas_df_index, name): + scalars_pandas_df = scalars_pandas_df_index + kw_args = {"name": name} if name else {} + bf_result = ( + scalars_df_index["float64_col"] + .sort_index(ascending=False) + .reset_index(drop=False, **kw_args) + ) + pd_result = ( + scalars_pandas_df["float64_col"] + .sort_index(ascending=False) + .reset_index(drop=False, **kw_args) + ) + + # BigQuery DataFrames default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_frame_equal(bf_result.to_pandas(), pd_result) + + +def test_copy(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + # Expect mutation on original not to effect_copy + bf_series = scalars_df_index[col_name].copy() + bf_copy = bf_series.copy() + bf_copy.loc[0] = 5.6 + bf_series.loc[0] = 3.4 + + pd_series = scalars_pandas_df_index[col_name].copy() + pd_copy = pd_series.copy() + pd_copy.loc[0] = 5.6 + pd_series.loc[0] = 3.4 + + assert bf_copy.to_pandas().loc[0] != bf_series.to_pandas().loc[0] + pd.testing.assert_series_equal(bf_copy.to_pandas(), pd_copy) + + +def test_isin_raise_error(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_too" + with pytest.raises(TypeError): + scalars_df_index[col_name].isin("whatever").to_pandas() + + +@pytest.mark.parametrize( + ( + "col_name", + "test_set", + ), + [ + ( + "int64_col", + [314159, 2.0, 3, pd.NA], + ), + ( + "int64_col", + [2, 55555, 4], + ), + ( + "float64_col", + [-123.456, 1.25, pd.NA], + ), + ( + "int64_too", + [1, 2, pd.NA], + ), + ( + "string_col", + ["Hello, World!", "Hi", "こんにちは"], + ), + ], +) +def test_isin(scalars_dfs, col_name, test_set): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].isin(test_set).to_pandas() + pd_result = scalars_pandas_df[col_name].isin(test_set).astype("boolean") + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ( + "col_name", + "test_set", + ), + [ + ( + "int64_col", + [314159, 2.0, 3, pd.NA], + ), + ( + "int64_col", + [2, 55555, 4], + ), + ( + "float64_col", + [-123.456, 1.25, pd.NA], + ), + ( + "int64_too", + [1, 2, pd.NA], + ), + ( + "string_col", + ["Hello, World!", "Hi", "こんにちは"], + ), + ], +) +def test_isin_bigframes_values(scalars_dfs, col_name, test_set, session): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = ( + scalars_df[col_name].isin(series.Series(test_set, session=session)).to_pandas() + ) + pd_result = scalars_pandas_df[col_name].isin(test_set).astype("boolean") + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +def test_isin_bigframes_index(scalars_dfs, session): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = ( + scalars_df["string_col"] + .isin(bigframes.pandas.Index(["Hello, World!", "Hi", "こんにちは"], session=session)) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df["string_col"] + .isin(pd.Index(["Hello, World!", "Hi", "こんにちは"])) + .astype("boolean") + ) + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.skip(reason="fixture 'scalars_dfs_maybe_ordered' not found") +@pytest.mark.parametrize( + ( + "col_name", + "test_set", + ), + [ + ( + "int64_col", + [314159, 2.0, 3, pd.NA], + ), + ( + "int64_col", + [2, 55555, 4], + ), + ( + "float64_col", + [-123.456, 1.25, pd.NA], + ), + ( + "int64_too", + [1, 2, pd.NA], + ), + ( + "string_col", + ["Hello, World!", "Hi", "こんにちは"], + ), + ], +) +def test_isin_bigframes_values_as_predicate( + scalars_dfs_maybe_ordered, col_name, test_set +): + scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered + bf_predicate = scalars_df[col_name].isin( + series.Series(test_set, session=scalars_df._session) + ) + bf_result = scalars_df[bf_predicate].to_pandas() + pd_predicate = scalars_pandas_df[col_name].isin(test_set) + pd_result = scalars_pandas_df[pd_predicate] + + pd.testing.assert_frame_equal( + pd_result.reset_index(), + bf_result.reset_index(), + ) + + +def test_isnull(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "float64_col" + bf_series = scalars_df[col_name].isnull().to_pandas() + pd_series = scalars_pandas_df[col_name].isnull() + + # One of dtype mismatches to be documented. Here, the `bf_series.dtype` is `BooleanDtype` but + # the `pd_series.dtype` is `bool`. + assert_series_equal(pd_series.astype(pd.BooleanDtype()), bf_series) + + +def test_notnull(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_series = scalars_df[col_name].notnull().to_pandas() + pd_series = scalars_pandas_df[col_name].notnull() + + # One of dtype mismatches to be documented. Here, the `bf_series.dtype` is `BooleanDtype` but + # the `pd_series.dtype` is `bool`. + assert_series_equal(pd_series.astype(pd.BooleanDtype()), bf_series) + + +def test_eq_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = scalars_df[col_name].eq(0).to_pandas() + pd_result = scalars_pandas_df[col_name].eq(0) + + assert_series_equal(pd_result, bf_result) + + +def test_eq_wider_type_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = scalars_df[col_name].eq(1.0).to_pandas() + pd_result = scalars_pandas_df[col_name].eq(1.0) + + assert_series_equal(pd_result, bf_result) + + +def test_ne_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = (scalars_df[col_name] != 0).to_pandas() + pd_result = scalars_pandas_df[col_name] != 0 + + assert_series_equal(pd_result, bf_result) + + +def test_eq_int_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = (scalars_df[col_name] == 0).to_pandas() + pd_result = scalars_pandas_df[col_name] == 0 + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("string_col",), + ("float64_col",), + ("int64_too",), + ), +) +def test_eq_same_type_series(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = (scalars_df[col_name] == scalars_df[col_name]).to_pandas() + pd_result = scalars_pandas_df[col_name] == scalars_pandas_df[col_name] + + # One of dtype mismatches to be documented. Here, the `bf_series.dtype` is `BooleanDtype` but + # the `pd_series.dtype` is `bool`. + assert_series_equal(pd_result.astype(pd.BooleanDtype()), bf_result) + + +def test_loc_setitem_cell(scalars_df_index, scalars_pandas_df_index): + bf_original = scalars_df_index["string_col"] + bf_series = scalars_df_index["string_col"] + pd_original = scalars_pandas_df_index["string_col"] + pd_series = scalars_pandas_df_index["string_col"].copy() + bf_series.loc[2] = "This value isn't in the test data." + pd_series.loc[2] = "This value isn't in the test data." + bf_result = bf_series.to_pandas() + pd_result = pd_series + pd.testing.assert_series_equal(bf_result, pd_result) + # Per Copy-on-Write semantics, other references to the original DataFrame + # should remain unchanged. + pd.testing.assert_series_equal(bf_original.to_pandas(), pd_original) + + +def test_at_setitem_row_label_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_series = scalars_df["int64_col"] + pd_series = scalars_pandas_df["int64_col"].copy() + bf_series.at[1] = 1000 + pd_series.at[1] = 1000 + bf_result = bf_series.to_pandas() + pd_result = pd_series.astype("Int64") + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_ne_obj_series(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = (scalars_df[col_name] != scalars_df[col_name]).to_pandas() + pd_result = scalars_pandas_df[col_name] != scalars_pandas_df[col_name] + + # One of dtype mismatches to be documented. Here, the `bf_series.dtype` is `BooleanDtype` but + # the `pd_series.dtype` is `bool`. + assert_series_equal(pd_result.astype(pd.BooleanDtype()), bf_result) + + +def test_indexing_using_unselected_series(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name][scalars_df["int64_too"].eq(0)].to_pandas() + pd_result = scalars_pandas_df[col_name][scalars_pandas_df["int64_too"].eq(0)] + + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_indexing_using_selected_series(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name][ + scalars_df["string_col"].eq("Hello, World!") + ].to_pandas() + pd_result = scalars_pandas_df[col_name][ + scalars_pandas_df["string_col"].eq("Hello, World!") + ] + + assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ("indices"), + [ + ([1, 3, 5]), + ([5, -3, -5, -6]), + ([-2, -4, -6]), + ], +) +def test_take(scalars_dfs, indices): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df.take(indices).to_pandas() + pd_result = scalars_pandas_df.take(indices) + + assert_frame_equal(bf_result, pd_result) + + +def test_nested_filter(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + string_col = scalars_df["string_col"] + int64_too = scalars_df["int64_too"] + bool_col = scalars_df["bool_col"] == bool( + True + ) # Convert from nullable bool to nonnullable bool usable as indexer + bf_result = string_col[int64_too == 0][~bool_col].to_pandas() + + pd_string_col = scalars_pandas_df["string_col"] + pd_int64_too = scalars_pandas_df["int64_too"] + pd_bool_col = scalars_pandas_df["bool_col"] == bool( + True + ) # Convert from nullable bool to nonnullable bool usable as indexer + pd_result = pd_string_col[pd_int64_too == 0][~pd_bool_col] + + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_binop_opposite_filters(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + int64_col1 = scalars_df["int64_col"] + int64_col2 = scalars_df["int64_col"] + bool_col = scalars_df["bool_col"] + bf_result = (int64_col1[bool_col] + int64_col2[bool_col.__invert__()]).to_pandas() + + pd_int64_col1 = scalars_pandas_df["int64_col"] + pd_int64_col2 = scalars_pandas_df["int64_col"] + pd_bool_col = scalars_pandas_df["bool_col"] + pd_result = pd_int64_col1[pd_bool_col] + pd_int64_col2[pd_bool_col.__invert__()] + + # Passes with ignore_order=False only with some dependency sets + # TODO: Determine desired behavior and make test more strict + assert_series_equal(bf_result, pd_result, ignore_order=True) + + +def test_binop_left_filtered(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + int64_col = scalars_df["int64_col"] + float64_col = scalars_df["float64_col"] + bool_col = scalars_df["bool_col"] + bf_result = (int64_col[bool_col] + float64_col).to_pandas() + + pd_int64_col = scalars_pandas_df["int64_col"] + pd_float64_col = scalars_pandas_df["float64_col"] + pd_bool_col = scalars_pandas_df["bool_col"] + pd_result = pd_int64_col[pd_bool_col] + pd_float64_col + + # Passes with ignore_order=False only with some dependency sets + # TODO: Determine desired behavior and make test more strict + assert_series_equal(bf_result, pd_result, ignore_order=True) + + +def test_binop_right_filtered(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + int64_col = scalars_df["int64_col"] + float64_col = scalars_df["float64_col"] + bool_col = scalars_df["bool_col"] + bf_result = (float64_col + int64_col[bool_col]).to_pandas() + + pd_int64_col = scalars_pandas_df["int64_col"] + pd_float64_col = scalars_pandas_df["float64_col"] + pd_bool_col = scalars_pandas_df["bool_col"] + pd_result = pd_float64_col + pd_int64_col[pd_bool_col] + + assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("other",), + [ + ([-1.4, 2.3, None],), + (pd.Index([-1.4, 2.3, None]),), + (pd.Series([-1.4, 2.3, None], index=[44, 2, 1]),), + ], +) +def test_series_binop_w_other_types(scalars_dfs, other): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (scalars_df["int64_col"].head(3) + other).to_pandas() + pd_result = scalars_pandas_df["int64_col"].head(3) + other + + if isinstance(other, pd.Series): + # pandas 3.0 preserves series name, bigframe, earlier pandas do not + pd_result.index.name = bf_result.index.name + + assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("other",), + [ + ([-1.4, 2.3, None],), + (pd.Index([-1.4, 2.3, None]),), + (pd.Series([-1.4, 2.3, None], index=[44, 2, 1]),), + ], +) +def test_series_reverse_binop_w_other_types(scalars_dfs, other): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (other + scalars_df["int64_col"].head(3)).to_pandas() + pd_result = other + scalars_pandas_df["int64_col"].head(3) + + assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_combine_first(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + int64_col = scalars_df["int64_col"].head(7) + float64_col = scalars_df["float64_col"].tail(7) + bf_result = int64_col.combine_first(float64_col).to_pandas() + + pd_int64_col = scalars_pandas_df["int64_col"].head(7) + pd_float64_col = scalars_pandas_df["float64_col"].tail(7) + pd_result = pd_int64_col.combine_first(pd_float64_col) + + assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_update(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + int64_col = scalars_df["int64_col"].head(7) + float64_col = scalars_df["float64_col"].tail(7).copy() + float64_col.update(int64_col) + + pd_int64_col = scalars_pandas_df["int64_col"].head(7) + pd_float64_col = scalars_pandas_df["float64_col"].tail(7).copy() + pd_float64_col.update(pd_int64_col) + + assert_series_equal( + float64_col.to_pandas(), + pd_float64_col, + ) + + +def test_mean(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].mean() + pd_result = scalars_pandas_df[col_name].mean() + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name"), + [ + pytest.param( + "int64_col", + marks=[ + pytest.mark.skip( + reason="pyarrow.lib.ArrowInvalid: Float value 27778.500000 was truncated converting to int64" + ) + ], + ), + # Non-numeric column + pytest.param( + "bytes_col", + marks=[ + pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: `median` operation not supported for dtype `binary`" + ) + ], + ), + "date_col", + "datetime_col", + pytest.param( + "time_col", + marks=[ + pytest.mark.skip( + reason="pyarrow.lib.ArrowInvalid: Casting from time64[ns] to time64[us] would lose data: 42651538080500" + ) + ], + ), + "timestamp_col", + pytest.param( + "string_col", + marks=[ + pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: `median` operation not supported for dtype `str`" + ) + ], + ), + ], +) +def test_median(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].median(exact=False) + pd_max = scalars_pandas_df[col_name].max() + pd_min = scalars_pandas_df[col_name].min() + # Median is approximate, so just check for plausibility. + assert pd_min < bf_result < pd_max + + +def test_numeric_literal(scalars_dfs): + scalars_df, _ = scalars_dfs + col_name = "numeric_col" + assert scalars_df[col_name].dtype == pd.ArrowDtype(pa.decimal128(38, 9)) + bf_result = scalars_df[col_name] + 42 + assert bf_result.size == scalars_df[col_name].size + assert bf_result.dtype == pd.ArrowDtype(pa.decimal128(38, 9)) + + +def test_series_small_repr(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + col_name = "int64_col" + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name] + with bigframes.pandas.option_context("display.repr_mode", "head"): + assert repr(bf_series) == pd_series.to_string( + length=False, dtype=True, name=True + ) + + +def test_sum(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].sum() + pd_result = scalars_pandas_df[col_name].sum() + assert pd_result == bf_result + + +def test_product(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "float64_col" + bf_result = scalars_df[col_name].product() + pd_result = scalars_pandas_df[col_name].product() + assert math.isclose(pd_result, bf_result) + + +def test_cumprod(scalars_dfs): + if pd.__version__.startswith("1."): + pytest.skip("Series.cumprod NA mask are different in pandas 1.x.") + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "float64_col" + bf_result = scalars_df[col_name].cumprod() + pd_result = scalars_pandas_df[col_name].cumprod() + pd.testing.assert_series_equal( + pd_result, + bf_result.to_pandas(), + ) + + +def test_count(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].count() + pd_result = scalars_pandas_df[col_name].count() + assert pd_result == bf_result + + +def test_nunique(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = (scalars_df[col_name] % 3).nunique() + pd_result = (scalars_pandas_df[col_name] % 3).nunique() + assert pd_result == bf_result + + +def test_all(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].all() + pd_result = scalars_pandas_df[col_name].all() + assert pd_result == bf_result + + +def test_any(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].any() + pd_result = scalars_pandas_df[col_name].any() + assert pd_result == bf_result + + +def test_groupby_sum(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = ( + scalars_df[col_name] + .groupby([scalars_df["bool_col"], ~scalars_df["bool_col"]]) + .sum() + ) + pd_series = ( + scalars_pandas_df[col_name] + .groupby([scalars_pandas_df["bool_col"], ~scalars_pandas_df["bool_col"]]) + .sum() + ) + # TODO(swast): Update groupby to use index based on group by key(s). + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + check_exact=False, + ) + + +def test_groupby_std(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = scalars_df[col_name].groupby(scalars_df["string_col"]).std() + pd_series = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"]) + .std() + .astype(pd.Float64Dtype()) + ) + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + check_exact=False, + ) + + +def test_groupby_var(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = scalars_df[col_name].groupby(scalars_df["string_col"]).var() + pd_series = ( + scalars_pandas_df[col_name].groupby(scalars_pandas_df["string_col"]).var() + ) + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + check_exact=False, + ) + + +def test_groupby_level_sum(scalars_dfs): + # TODO(tbergeron): Use a non-unique index once that becomes possible in tests + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + + bf_series = scalars_df[col_name].groupby(level=0).sum() + pd_series = scalars_pandas_df[col_name].groupby(level=0).sum() + # TODO(swast): Update groupby to use index based on group by key(s). + pd.testing.assert_series_equal( + pd_series.sort_index(), + bf_series.to_pandas().sort_index(), + ) + + +def test_groupby_level_list_sum(scalars_dfs): + # TODO(tbergeron): Use a non-unique index once that becomes possible in tests + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + + bf_series = scalars_df[col_name].groupby(level=["rowindex"]).sum() + pd_series = scalars_pandas_df[col_name].groupby(level=["rowindex"]).sum() + # TODO(swast): Update groupby to use index based on group by key(s). + pd.testing.assert_series_equal( + pd_series.sort_index(), + bf_series.to_pandas().sort_index(), + ) + + +def test_groupby_mean(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = ( + scalars_df[col_name].groupby(scalars_df["string_col"], dropna=False).mean() + ) + pd_series = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"], dropna=False) + .mean() + ) + # TODO(swast): Update groupby to use index based on group by key(s). + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + ) + + +@pytest.mark.skip( + reason="Aggregate op QuantileOp(q=0.5, should_floor_result=False) not yet supported in polars engine." +) +def test_groupby_median_exact(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = ( + scalars_df[col_name].groupby(scalars_df["string_col"], dropna=False).median() + ) + pd_result = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"], dropna=False) + .median() + ) + + assert_series_equal( + pd_result, + bf_result.to_pandas(), + ) + + +@pytest.mark.skip( + reason="pyarrow.lib.ArrowInvalid: Float value -1172.500000 was truncated converting to int64" +) +def test_groupby_median_inexact(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = ( + scalars_df[col_name] + .groupby(scalars_df["string_col"], dropna=False) + .median(exact=False) + ) + pd_max = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"], dropna=False) + .max() + ) + pd_min = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"], dropna=False) + .min() + ) + # TODO(swast): Update groupby to use index based on group by key(s). + bf_result = bf_series.to_pandas() + + # Median is approximate, so just check that it's plausible. + assert ((pd_min <= bf_result) & (bf_result <= pd_max)).all() + + +def test_groupby_prod(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = scalars_df[col_name].groupby(scalars_df["int64_col"]).prod() + pd_series = ( + scalars_pandas_df[col_name].groupby(scalars_pandas_df["int64_col"]).prod() + ).astype(pd.Float64Dtype()) + # TODO(swast): Update groupby to use index based on group by key(s). + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + ) + + +@pytest.mark.skip(reason="AssertionError: Series are different") +@pytest.mark.parametrize( + ("operator"), + [ + (lambda x: x.cumsum()), + (lambda x: x.cumcount()), + (lambda x: x.cummin()), + (lambda x: x.cummax()), + # Pandas 2.2 casts to cumprod to float. + (lambda x: x.cumprod().astype("Float64")), + (lambda x: x.diff()), + (lambda x: x.shift(2)), + (lambda x: x.shift(-2)), + ], + ids=[ + "cumsum", + "cumcount", + "cummin", + "cummax", + "cumprod", + "diff", + "shiftpostive", + "shiftnegative", + ], +) +def test_groupby_window_ops(scalars_df_index, scalars_pandas_df_index, operator): + col_name = "int64_col" + group_key = "int64_too" # has some duplicates values, good for grouping + bf_series = ( + operator(scalars_df_index[col_name].groupby(scalars_df_index[group_key])) + ).to_pandas() + pd_series = operator( + scalars_pandas_df_index[col_name].groupby(scalars_pandas_df_index[group_key]) + ).astype(bf_series.dtype) + + pd.testing.assert_series_equal( + pd_series, + bf_series, + ) + + +@pytest.mark.parametrize( + ("label", "col_name"), + [ + (0, "bool_col"), + (1, "int64_col"), + ], +) +def test_drop_label(scalars_df_index, scalars_pandas_df_index, label, col_name): + bf_series = scalars_df_index[col_name].drop(label).to_pandas() + pd_series = scalars_pandas_df_index[col_name].drop(label) + pd.testing.assert_series_equal( + pd_series, + bf_series, + ) + + +def test_drop_label_list(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + bf_series = scalars_df_index[col_name].drop([1, 3]).to_pandas() + pd_series = scalars_pandas_df_index[col_name].drop([1, 3]) + pd.testing.assert_series_equal( + pd_series, + bf_series, + ) + + +@pytest.mark.skip(reason="AssertionError: Series.index are different") +@pytest.mark.parametrize( + ("col_name",), + [ + ("bool_col",), + ("int64_too",), + ], +) +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + (False,), + ], +) +def test_drop_duplicates(scalars_df_index, scalars_pandas_df_index, keep, col_name): + bf_series = scalars_df_index[col_name].drop_duplicates(keep=keep).to_pandas() + pd_series = scalars_pandas_df_index[col_name].drop_duplicates(keep=keep) + pd.testing.assert_series_equal( + pd_series, + bf_series, + ) + + +@pytest.mark.skip(reason="TypeError: boolean value of NA is ambiguous") +@pytest.mark.parametrize( + ("col_name",), + [ + ("bool_col",), + ("int64_too",), + ], +) +def test_unique(scalars_df_index, scalars_pandas_df_index, col_name): + bf_uniq = scalars_df_index[col_name].unique().to_numpy(na_value=None) + pd_uniq = scalars_pandas_df_index[col_name].unique() + numpy.array_equal(pd_uniq, bf_uniq) + + +@pytest.mark.skip(reason="AssertionError: Series are different") +@pytest.mark.parametrize( + ("col_name",), + [ + ("bool_col",), + ("int64_too",), + ], +) +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + (False,), + ], +) +def test_duplicated(scalars_df_index, scalars_pandas_df_index, keep, col_name): + bf_series = scalars_df_index[col_name].duplicated(keep=keep).to_pandas() + pd_series = scalars_pandas_df_index[col_name].duplicated(keep=keep) + pd.testing.assert_series_equal(pd_series, bf_series, check_dtype=False) + + +def test_shape(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].shape + pd_result = scalars_pandas_df["string_col"].shape + + assert pd_result == bf_result + + +def test_len(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = len(scalars_df["string_col"]) + pd_result = len(scalars_pandas_df["string_col"]) + + assert pd_result == bf_result + + +def test_size(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].size + pd_result = scalars_pandas_df["string_col"].size + + assert pd_result == bf_result + + +def test_series_hasnans_true(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].hasnans + pd_result = scalars_pandas_df["string_col"].hasnans + + assert pd_result == bf_result + + +def test_series_hasnans_false(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].dropna().hasnans + pd_result = scalars_pandas_df["string_col"].dropna().hasnans + + assert pd_result == bf_result + + +def test_empty_false(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].empty + pd_result = scalars_pandas_df["string_col"].empty + + assert pd_result == bf_result + + +def test_empty_true_row_filter(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"][ + scalars_df["string_col"] == "won't find this" + ].empty + pd_result = scalars_pandas_df["string_col"][ + scalars_pandas_df["string_col"] == "won't find this" + ].empty + + assert pd_result + assert pd_result == bf_result + + +def test_series_names(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].copy() + bf_result.index.name = "new index name" + bf_result.name = "new series name" + + pd_result = scalars_pandas_df["string_col"].copy() + pd_result.index.name = "new index name" + pd_result.name = "new series name" + + assert pd_result.name == bf_result.name + assert pd_result.index.name == bf_result.index.name + + +def test_dtype(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].dtype + pd_result = scalars_pandas_df["string_col"].dtype + + assert pd_result == bf_result + + +def test_dtypes(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["int64_col"].dtypes + pd_result = scalars_pandas_df["int64_col"].dtypes + + assert pd_result == bf_result + + +def test_head(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].head(2).to_pandas() + pd_result = scalars_pandas_df["string_col"].head(2) + + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_tail(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].tail(2).to_pandas() + pd_result = scalars_pandas_df["string_col"].tail(2) + + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_head_then_scalar_operation(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (scalars_df["float64_col"].head(1) + 4).to_pandas() + pd_result = scalars_pandas_df["float64_col"].head(1) + 4 + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_head_then_series_operation(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = ( + scalars_df["float64_col"].head(4) + scalars_df["float64_col"].head(2) + ).to_pandas() + pd_result = scalars_pandas_df["float64_col"].head(4) + scalars_pandas_df[ + "float64_col" + ].head(2) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_peek(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + peek_result = scalars_df["float64_col"].peek(n=3, force=False) + + pd.testing.assert_series_equal( + peek_result, + scalars_pandas_df["float64_col"].reindex_like(peek_result), + ) + assert len(peek_result) == 3 + + +def test_series_peek_with_large_results_not_allowed(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + session = scalars_df._block.session + slot_millis_sum = session.slot_millis_sum + peek_result = scalars_df["float64_col"].peek( + n=3, force=False, allow_large_results=False + ) + + # The metrics won't be fully updated when we call query_and_wait. + print(session.slot_millis_sum - slot_millis_sum) + assert session.slot_millis_sum - slot_millis_sum < 500 + pd.testing.assert_series_equal( + peek_result, + scalars_pandas_df["float64_col"].reindex_like(peek_result), + ) + assert len(peek_result) == 3 + + +def test_series_peek_multi_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_series = scalars_df.set_index(["string_col", "bool_col"])["float64_col"] + bf_series.name = ("2-part", "name") + pd_series = scalars_pandas_df.set_index(["string_col", "bool_col"])["float64_col"] + pd_series.name = ("2-part", "name") + peek_result = bf_series.peek(n=3, force=False) + pd.testing.assert_series_equal( + peek_result, + pd_series.reindex_like(peek_result), + ) + + +def test_series_peek_filtered(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + peek_result = scalars_df[scalars_df.int64_col > 0]["float64_col"].peek( + n=3, force=False + ) + pd_result = scalars_pandas_df[scalars_pandas_df.int64_col > 0]["float64_col"] + pd.testing.assert_series_equal( + peek_result, + pd_result.reindex_like(peek_result), + ) + + +def test_series_peek_force(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + cumsum_df = scalars_df[["int64_col", "int64_too"]].cumsum() + df_filtered = cumsum_df[cumsum_df.int64_col > 0]["int64_too"] + peek_result = df_filtered.peek(n=3, force=True) + pd_cumsum_df = scalars_pandas_df[["int64_col", "int64_too"]].cumsum() + pd_result = pd_cumsum_df[pd_cumsum_df.int64_col > 0]["int64_too"] + pd.testing.assert_series_equal( + peek_result, + pd_result.reindex_like(peek_result), + ) + + +def test_series_peek_force_float(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + cumsum_df = scalars_df[["int64_col", "float64_col"]].cumsum() + df_filtered = cumsum_df[cumsum_df.float64_col > 0]["float64_col"] + peek_result = df_filtered.peek(n=3, force=True) + pd_cumsum_df = scalars_pandas_df[["int64_col", "float64_col"]].cumsum() + pd_result = pd_cumsum_df[pd_cumsum_df.float64_col > 0]["float64_col"] + pd.testing.assert_series_equal( + peek_result, + pd_result.reindex_like(peek_result), + ) + + +def test_shift(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + bf_result = scalars_df_index[col_name].shift().to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].shift().astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_ffill(scalars_df_index, scalars_pandas_df_index): + col_name = "numeric_col" + bf_result = scalars_df_index[col_name].ffill(limit=1).to_pandas() + pd_result = scalars_pandas_df_index[col_name].ffill(limit=1) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_bfill(scalars_df_index, scalars_pandas_df_index): + col_name = "numeric_col" + bf_result = scalars_df_index[col_name].bfill(limit=2).to_pandas() + pd_result = scalars_pandas_df_index[col_name].bfill(limit=2) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_int(scalars_df_index, scalars_pandas_df_index): + if pd.__version__.startswith("1."): + pytest.skip("Series.cumsum NA mask are different in pandas 1.x.") + + col_name = "int64_col" + bf_result = scalars_df_index[col_name].cumsum().to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].cumsum().astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_int_ordered(scalars_df_index, scalars_pandas_df_index): + if pd.__version__.startswith("1."): + pytest.skip("Series.cumsum NA mask are different in pandas 1.x.") + + col_name = "int64_col" + bf_result = ( + scalars_df_index.sort_values(by="rowindex_2")[col_name].cumsum().to_pandas() + ) + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = ( + scalars_pandas_df_index.sort_values(by="rowindex_2")[col_name] + .cumsum() + .astype(pd.Int64Dtype()) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Aggregate op RankOp() not yet supported in polars engine." +) +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + ("all",), + ], +) +def test_series_nlargest(scalars_df_index, scalars_pandas_df_index, keep): + col_name = "bool_col" + bf_result = scalars_df_index[col_name].nlargest(4, keep=keep).to_pandas() + pd_result = scalars_pandas_df_index[col_name].nlargest(4, keep=keep) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("periods",), + [ + (1,), + (2,), + (-1,), + ], +) +def test_diff(scalars_df_index, scalars_pandas_df_index, periods): + bf_result = scalars_df_index["int64_col"].diff(periods=periods).to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = ( + scalars_pandas_df_index["int64_col"] + .diff(periods=periods) + .astype(pd.Int64Dtype()) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("periods",), + [ + (1,), + (2,), + (-1,), + ], +) +def test_series_pct_change(scalars_df_index, scalars_pandas_df_index, periods): + bf_result = scalars_df_index["int64_col"].pct_change(periods=periods).to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index["int64_col"].ffill().pct_change(periods=periods) + + assert_series_equal(bf_result, pd_result, nulls_are_nan=True) + + +@pytest.mark.skip( + reason="NotImplementedError: Aggregate op RankOp() not yet supported in polars engine." +) +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + ("all",), + ], +) +def test_series_nsmallest(scalars_df_index, scalars_pandas_df_index, keep): + col_name = "bool_col" + bf_result = scalars_df_index[col_name].nsmallest(2, keep=keep).to_pandas() + pd_result = scalars_pandas_df_index[col_name].nsmallest(2, keep=keep) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Aggregate op DenseRankOp() not yet supported in polars engine." +) +@pytest.mark.parametrize( + ("na_option", "method", "ascending", "numeric_only", "pct"), + [ + ("keep", "average", True, True, False), + ("top", "min", False, False, True), + ("bottom", "max", False, False, False), + ("top", "first", False, False, True), + ("bottom", "dense", False, False, False), + ], +) +def test_series_rank( + scalars_df_index, + scalars_pandas_df_index, + na_option, + method, + ascending, + numeric_only, + pct, +): + col_name = "int64_too" + bf_result = ( + scalars_df_index[col_name] + .rank( + na_option=na_option, + method=method, + ascending=ascending, + numeric_only=numeric_only, + pct=pct, + ) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df_index[col_name] + .rank( + na_option=na_option, + method=method, + ascending=ascending, + numeric_only=numeric_only, + pct=pct, + ) + .astype(pd.Float64Dtype()) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cast_float_to_int(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + bf_result = scalars_df_index[col_name].astype(pd.Int64Dtype()).to_pandas() + # cumsum does not behave well on nullable floats in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cast_float_to_bool(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + bf_result = scalars_df_index[col_name].astype(pd.BooleanDtype()).to_pandas() + # cumsum does not behave well on nullable floats in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].astype(pd.BooleanDtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_nested(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + bf_result = scalars_df_index[col_name].cumsum().cumsum().cumsum().to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = ( + scalars_pandas_df_index[col_name] + .cumsum() + .cumsum() + .cumsum() + .astype(pd.Float64Dtype()) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: min_period not yet supported for polars engine" +) +def test_nested_analytic_ops_align(scalars_df_index, scalars_pandas_df_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + col_name = "float64_col" + # set non-unique index to check implicit alignment + bf_series = scalars_df_index.set_index("bool_col")[col_name].fillna(0.0) + pd_series = scalars_pandas_df_index.set_index("bool_col")[col_name].fillna(0.0) + + bf_result = ( + (bf_series + 5) + + (bf_series.cumsum().cumsum().cumsum() + bf_series.rolling(window=3).mean()) + + bf_series.expanding().max() + ).to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = ( + (pd_series + 5) + + ( + pd_series.cumsum().cumsum().cumsum().astype(pd.Float64Dtype()) + + pd_series.rolling(window=3).mean() + ) + + pd_series.expanding().max() + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_int_filtered(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + + bf_col = scalars_df_index[col_name] + bf_result = bf_col[bf_col > -2].cumsum().to_pandas() + + pd_col = scalars_pandas_df_index[col_name] + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = pd_col[pd_col > -2].cumsum().astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_float(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + bf_result = scalars_df_index[col_name].cumsum().to_pandas() + # cumsum does not behave well on nullable floats in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].cumsum().astype(pd.Float64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cummin_int(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + bf_result = scalars_df_index[col_name].cummin().to_pandas() + pd_result = scalars_pandas_df_index[col_name].cummin() + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cummax_int(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + bf_result = scalars_df_index[col_name].cummax().to_pandas() + pd_result = scalars_pandas_df_index[col_name].cummax() + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("kwargs"), + [ + {}, + {"normalize": True}, + {"ascending": True}, + ], + ids=[ + "default", + "normalize", + "ascending", + ], +) +def test_value_counts(scalars_dfs, kwargs): + if pd.__version__.startswith("1."): + pytest.skip("pandas 1.x produces different column labels.") + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + + # Pandas `value_counts` can produce non-deterministic results with tied counts. + # Remove duplicates to enforce a consistent output. + s = scalars_df[col_name].drop(0) + pd_s = scalars_pandas_df[col_name].drop(0) + + bf_result = s.value_counts(**kwargs).to_pandas() + pd_result = pd_s.value_counts(**kwargs) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_value_counts_with_na(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + + bf_result = scalars_df[col_name].value_counts(dropna=False).to_pandas() + pd_result = scalars_pandas_df[col_name].value_counts(dropna=False) + + # Older pandas version may not have these values, bigframes tries to emulate 2.0+ + pd_result.name = "count" + pd_result.index.name = col_name + + assert_series_equal( + bf_result, + pd_result, + # bigframes values_counts does not honor ordering in the original data + ignore_order=True, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Aggregate op CutOp(bins=3, right=True, labels=False) not yet supported in polars engine." +) +def test_value_counts_w_cut(scalars_dfs): + if pd.__version__.startswith("1."): + pytest.skip("value_counts results different in pandas 1.x.") + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + + bf_cut = bigframes.pandas.cut(scalars_df[col_name], 3, labels=False) + pd_cut = pd.cut(scalars_pandas_df[col_name], 3, labels=False) + + bf_result = bf_cut.value_counts().to_pandas() + pd_result = pd_cut.value_counts() + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result.astype(pd.Int64Dtype()), + ) + + +def test_iloc_nested(scalars_df_index, scalars_pandas_df_index): + + bf_result = scalars_df_index["string_col"].iloc[1:].iloc[1:].to_pandas() + pd_result = scalars_pandas_df_index["string_col"].iloc[1:].iloc[1:] + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (1, None, None), + (None, 4, None), + (None, None, 2), + (None, 50000000000, 1), + (5, 4, None), + (3, None, 2), + (1, 7, 2), + (1, 7, 50000000000), + (-1, -7, -2), + (None, -7, -2), + (-1, None, -2), + (-7, -1, 2), + (-7, -1, None), + (-7, 7, None), + (7, -7, -2), + ], +) +def test_series_iloc(scalars_df_index, scalars_pandas_df_index, start, stop, step): + bf_result = scalars_df_index["string_col"].iloc[start:stop:step].to_pandas() + pd_result = scalars_pandas_df_index["string_col"].iloc[start:stop:step] + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_at(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("int64_too", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index("int64_too", drop=False) + index = -2345 + bf_result = scalars_df_index["string_col"].at[index] + pd_result = scalars_pandas_df_index["string_col"].at[index] + + assert bf_result == pd_result + + +def test_iat(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].iat[3] + pd_result = scalars_pandas_df_index["int64_too"].iat[3] + + assert bf_result == pd_result + + +def test_iat_error(scalars_df_index, scalars_pandas_df_index): + with pytest.raises(ValueError): + scalars_pandas_df_index["int64_too"].iat["asd"] + with pytest.raises(ValueError): + scalars_df_index["int64_too"].iat["asd"] + + +def test_series_add_prefix(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].add_prefix("prefix_").to_pandas() + + pd_result = scalars_pandas_df_index["int64_too"].add_prefix("prefix_") + + # Index will be object type in pandas, string type in bigframes, but same values + pd.testing.assert_series_equal( + bf_result, + pd_result, + check_index_type=False, + ) + + +def test_series_add_suffix(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].add_suffix("_suffix").to_pandas() + + pd_result = scalars_pandas_df_index["int64_too"].add_suffix("_suffix") + + # Index will be object type in pandas, string type in bigframes, but same values + pd.testing.assert_series_equal( + bf_result, + pd_result, + check_index_type=False, + ) + + +def test_series_filter_items(scalars_df_index, scalars_pandas_df_index): + if pd.__version__.startswith("2.0") or pd.__version__.startswith("1."): + pytest.skip("pandas filter items behavior different pre-2.1") + bf_result = scalars_df_index["float64_col"].filter(items=[5, 1, 3]).to_pandas() + + pd_result = scalars_pandas_df_index["float64_col"].filter(items=[5, 1, 3]) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + # Ignore ordering as pandas order differently depending on version + assert_series_equal(bf_result, pd_result, check_names=False, ignore_order=True) + + +def test_series_filter_like(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.copy().set_index("string_col") + scalars_pandas_df_index = scalars_pandas_df_index.copy().set_index("string_col") + + bf_result = scalars_df_index["float64_col"].filter(like="ello").to_pandas() + + pd_result = scalars_pandas_df_index["float64_col"].filter(like="ello") + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_filter_regex(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.copy().set_index("string_col") + scalars_pandas_df_index = scalars_pandas_df_index.copy().set_index("string_col") + + bf_result = scalars_df_index["float64_col"].filter(regex="^[GH].*").to_pandas() + + pd_result = scalars_pandas_df_index["float64_col"].filter(regex="^[GH].*") + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_reindex(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + scalars_df_index["float64_col"].reindex(index=[5, 1, 3, 99, 1]).to_pandas() + ) + + pd_result = scalars_pandas_df_index["float64_col"].reindex(index=[5, 1, 3, 99, 1]) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_reindex_nonunique(scalars_df_index): + with pytest.raises(ValueError): + # int64_too is non-unique + scalars_df_index.set_index("int64_too")["float64_col"].reindex( + index=[5, 1, 3, 99, 1], validate=True + ) + + +def test_series_reindex_like(scalars_df_index, scalars_pandas_df_index): + bf_reindex_target = scalars_df_index["float64_col"].reindex(index=[5, 1, 3, 99, 1]) + bf_result = ( + scalars_df_index["int64_too"].reindex_like(bf_reindex_target).to_pandas() + ) + + pd_reindex_target = scalars_pandas_df_index["float64_col"].reindex( + index=[5, 1, 3, 99, 1] + ) + pd_result = scalars_pandas_df_index["int64_too"].reindex_like(pd_reindex_target) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_where_with_series(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + scalars_df_index["int64_col"] + .where(scalars_df_index["bool_col"], scalars_df_index["int64_too"]) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].where( + scalars_pandas_df_index["bool_col"], scalars_pandas_df_index["int64_too"] + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_where_with_different_indices(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + scalars_df_index["int64_col"] + .iloc[::2] + .where( + scalars_df_index["bool_col"].iloc[2:], + scalars_df_index["int64_too"].iloc[:5], + ) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df_index["int64_col"] + .iloc[::2] + .where( + scalars_pandas_df_index["bool_col"].iloc[2:], + scalars_pandas_df_index["int64_too"].iloc[:5], + ) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_where_with_default(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + scalars_df_index["int64_col"].where(scalars_df_index["bool_col"]).to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].where( + scalars_pandas_df_index["bool_col"] + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_where_with_callable(scalars_df_index, scalars_pandas_df_index): + def _is_positive(x): + return x > 0 + + # Both cond and other are callable. + bf_result = ( + scalars_df_index["int64_col"] + .where(cond=_is_positive, other=lambda x: x * 10) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].where( + cond=_is_positive, other=lambda x: x * 10 + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented ClipOp()" +) +@pytest.mark.parametrize( + ("ordered"), + [ + (True), + (False), + ], +) +def test_clip(scalars_df_index, scalars_pandas_df_index, ordered): + col_bf = scalars_df_index["int64_col"] + lower_bf = scalars_df_index["int64_too"] - 1 + upper_bf = scalars_df_index["int64_too"] + 1 + bf_result = col_bf.clip(lower_bf, upper_bf).to_pandas(ordered=ordered) + + col_pd = scalars_pandas_df_index["int64_col"] + lower_pd = scalars_pandas_df_index["int64_too"] - 1 + upper_pd = scalars_pandas_df_index["int64_too"] + 1 + pd_result = col_pd.clip(lower_pd, upper_pd) + + assert_series_equal(bf_result, pd_result, ignore_order=not ordered) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented ClipOp()" +) +def test_clip_int_with_float_bounds(scalars_df_index, scalars_pandas_df_index): + col_bf = scalars_df_index["int64_too"] + bf_result = col_bf.clip(-100, 3.14151593).to_pandas() + + col_pd = scalars_pandas_df_index["int64_too"] + # pandas doesn't work with Int64 and clip with floats + pd_result = col_pd.astype("int64").clip(-100, 3.14151593).astype("Float64") + + assert_series_equal(bf_result, pd_result) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented ClipOp()" +) +def test_clip_filtered_two_sided(scalars_df_index, scalars_pandas_df_index): + col_bf = scalars_df_index["int64_col"].iloc[::2] + lower_bf = scalars_df_index["int64_too"].iloc[2:] - 1 + upper_bf = scalars_df_index["int64_too"].iloc[:5] + 1 + bf_result = col_bf.clip(lower_bf, upper_bf).to_pandas() + + col_pd = scalars_pandas_df_index["int64_col"].iloc[::2] + lower_pd = scalars_pandas_df_index["int64_too"].iloc[2:] - 1 + upper_pd = scalars_pandas_df_index["int64_too"].iloc[:5] + 1 + pd_result = col_pd.clip(lower_pd, upper_pd) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented maximum()" +) +def test_clip_filtered_one_sided(scalars_df_index, scalars_pandas_df_index): + col_bf = scalars_df_index["int64_col"].iloc[::2] + lower_bf = scalars_df_index["int64_too"].iloc[2:] - 1 + bf_result = col_bf.clip(lower_bf, None).to_pandas() + + col_pd = scalars_pandas_df_index["int64_col"].iloc[::2] + lower_pd = scalars_pandas_df_index["int64_too"].iloc[2:] - 1 + pd_result = col_pd.clip(lower_pd, None) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_dot(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_too"] @ scalars_df["int64_too"] + + pd_result = scalars_pandas_df["int64_too"] @ scalars_pandas_df["int64_too"] + + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("left", "right", "inclusive"), + [ + (-234892, 55555, "left"), + (-234892, 55555, "both"), + (-234892, 55555, "neither"), + (-234892, 55555, "right"), + ], +) +def test_between(scalars_df_index, scalars_pandas_df_index, left, right, inclusive): + bf_result = ( + scalars_df_index["int64_col"].between(left, right, inclusive).to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].between(left, right, inclusive) + + pd.testing.assert_series_equal( + bf_result, + pd_result.astype(pd.BooleanDtype()), + ) + + +@pytest.mark.skip(reason="fixture 'scalars_dfs_maybe_ordered' not found") +def test_series_case_when(scalars_dfs_maybe_ordered): + pytest.importorskip( + "pandas", + minversion="2.2.0", + reason="case_when added in pandas 2.2.0", + ) + scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered + + bf_series = scalars_df["int64_col"] + pd_series = scalars_pandas_df["int64_col"] + + # TODO(tswast): pandas case_when appears to assume True when a value is + # null. I suspect this should be considered a bug in pandas. + + # Generate 150 conditions to test case_when with a large number of conditions + bf_conditions = ( + [((bf_series > 645).fillna(True), bf_series - 1)] + + [((bf_series > (-100 + i * 5)).fillna(True), i) for i in range(148, 0, -1)] + + [((bf_series <= -100).fillna(True), pd.NA)] + ) + + pd_conditions = ( + [((pd_series > 645), pd_series - 1)] + + [((pd_series > (-100 + i * 5)), i) for i in range(148, 0, -1)] + + [(pd_series <= -100, pd.NA)] + ) + + assert len(bf_conditions) == 150 + + bf_result = bf_series.case_when(bf_conditions).to_pandas() + pd_result = pd_series.case_when(pd_conditions) + + pd.testing.assert_series_equal( + bf_result, + pd_result.astype(pd.Int64Dtype()), + ) + + +@pytest.mark.skip(reason="fixture 'scalars_dfs_maybe_ordered' not found") +def test_series_case_when_change_type(scalars_dfs_maybe_ordered): + pytest.importorskip( + "pandas", + minversion="2.2.0", + reason="case_when added in pandas 2.2.0", + ) + scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered + + bf_series = scalars_df["int64_col"] + pd_series = scalars_pandas_df["int64_col"] + + # TODO(tswast): pandas case_when appears to assume True when a value is + # null. I suspect this should be considered a bug in pandas. + + bf_conditions = [ + ((bf_series > 645).fillna(True), scalars_df["string_col"]), + ((bf_series <= -100).fillna(True), pd.NA), + (True, "not_found"), + ] + + pd_conditions = [ + ((pd_series > 645).fillna(True), scalars_pandas_df["string_col"]), + ((pd_series <= -100).fillna(True), pd.NA), + # pandas currently fails if both the condition and the value are literals. + ([True] * len(pd_series), ["not_found"] * len(pd_series)), + ] + + bf_result = bf_series.case_when(bf_conditions).to_pandas() + pd_result = pd_series.case_when(pd_conditions) + + pd.testing.assert_series_equal( + bf_result, + pd_result.astype("string[pyarrow]"), + ) + + +def test_to_frame(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["int64_col"].to_frame().to_pandas() + pd_result = scalars_pandas_df["int64_col"].to_frame() + + assert_frame_equal(bf_result, pd_result) + + +def test_to_frame_no_name(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["int64_col"].rename(None).to_frame().to_pandas() + pd_result = scalars_pandas_df["int64_col"].rename(None).to_frame() + + assert_frame_equal(bf_result, pd_result) + + +@pytest.mark.skip(reason="fixture 'gcs_folder' not found") +def test_to_json(gcs_folder, scalars_df_index, scalars_pandas_df_index): + path = gcs_folder + "test_series_to_json*.jsonl" + scalars_df_index["int64_col"].to_json(path, lines=True, orient="records") + gcs_df = pd.read_json(get_first_file_from_wildcard(path), lines=True) + + pd.testing.assert_series_equal( + gcs_df["int64_col"].astype(pd.Int64Dtype()), + scalars_pandas_df_index["int64_col"], + check_dtype=False, + check_index=False, + ) + + +@pytest.mark.skip(reason="fixture 'gcs_folder' not found") +def test_to_csv(gcs_folder, scalars_df_index, scalars_pandas_df_index): + path = gcs_folder + "test_series_to_csv*.csv" + scalars_df_index["int64_col"].to_csv(path) + gcs_df = pd.read_csv(get_first_file_from_wildcard(path)) + + pd.testing.assert_series_equal( + gcs_df["int64_col"].astype(pd.Int64Dtype()), + scalars_pandas_df_index["int64_col"], + check_dtype=False, + check_index=False, + ) + + +def test_to_latex(scalars_df_index, scalars_pandas_df_index): + pytest.importorskip("jinja2") + bf_result = scalars_df_index["int64_col"].to_latex() + pd_result = scalars_pandas_df_index["int64_col"].to_latex() + + assert bf_result == pd_result + + +def test_series_to_json_local_str(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_col.to_json() + pd_result = scalars_pandas_df_index.int64_col.to_json() + + assert bf_result == pd_result + + +def test_series_to_json_local_file(scalars_df_index, scalars_pandas_df_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: + scalars_df_index.int64_col.to_json(bf_result_file) + scalars_pandas_df_index.int64_col.to_json(pd_result_file) + + bf_result = bf_result_file.read() + pd_result = pd_result_file.read() + + assert bf_result == pd_result + + +def test_series_to_csv_local_str(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_col.to_csv() + # default_handler for arrow types that have no default conversion + pd_result = scalars_pandas_df_index.int64_col.to_csv() + + assert bf_result == pd_result + + +def test_series_to_csv_local_file(scalars_df_index, scalars_pandas_df_index): + with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: + scalars_df_index.int64_col.to_csv(bf_result_file) + scalars_pandas_df_index.int64_col.to_csv(pd_result_file) + + bf_result = bf_result_file.read() + pd_result = pd_result_file.read() + + assert bf_result == pd_result + + +def test_to_dict(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_dict() + + pd_result = scalars_pandas_df_index["int64_too"].to_dict() + + assert bf_result == pd_result + + +def test_to_excel(scalars_df_index, scalars_pandas_df_index): + pytest.importorskip("openpyxl") + bf_result_file = tempfile.TemporaryFile() + pd_result_file = tempfile.TemporaryFile() + scalars_df_index["int64_too"].to_excel(bf_result_file) + scalars_pandas_df_index["int64_too"].to_excel(pd_result_file) + bf_result = bf_result_file.read() + pd_result = bf_result_file.read() + + assert bf_result == pd_result + + +def test_to_pickle(scalars_df_index, scalars_pandas_df_index): + bf_result_file = tempfile.TemporaryFile() + pd_result_file = tempfile.TemporaryFile() + scalars_df_index["int64_too"].to_pickle(bf_result_file) + scalars_pandas_df_index["int64_too"].to_pickle(pd_result_file) + bf_result = bf_result_file.read() + pd_result = bf_result_file.read() + + assert bf_result == pd_result + + +def test_to_string(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_string() + + pd_result = scalars_pandas_df_index["int64_too"].to_string() + + assert bf_result == pd_result + + +def test_to_list(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_list() + + pd_result = scalars_pandas_df_index["int64_too"].to_list() + + assert bf_result == pd_result + + +def test_to_numpy(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_numpy() + + pd_result = scalars_pandas_df_index["int64_too"].to_numpy() + + assert (bf_result == pd_result).all() + + +def test_to_xarray(scalars_df_index, scalars_pandas_df_index): + pytest.importorskip("xarray") + bf_result = scalars_df_index["int64_too"].to_xarray() + + pd_result = scalars_pandas_df_index["int64_too"].to_xarray() + + assert bf_result.equals(pd_result) + + +def test_to_markdown(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_markdown() + + pd_result = scalars_pandas_df_index["int64_too"].to_markdown() + + assert bf_result == pd_result + + +def test_series_values(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].values + + pd_result = scalars_pandas_df_index["int64_too"].values + # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe + pd.testing.assert_series_equal( + pd.Series(bf_result), pd.Series(pd_result), check_dtype=False + ) + + +def test_series___array__(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["float64_col"].__array__() + + pd_result = scalars_pandas_df_index["float64_col"].__array__() + # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe + numpy.array_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("ascending", "na_position"), + [ + (True, "first"), + (True, "last"), + (False, "first"), + (False, "last"), + ], +) +def test_sort_values(scalars_df_index, scalars_pandas_df_index, ascending, na_position): + # Test needs values to be unique + bf_result = ( + scalars_df_index["int64_col"] + .sort_values(ascending=ascending, na_position=na_position) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].sort_values( + ascending=ascending, na_position=na_position + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_sort_values_inplace(scalars_df_index, scalars_pandas_df_index): + # Test needs values to be unique + bf_series = scalars_df_index["int64_col"].copy() + bf_series.sort_values(ascending=False, inplace=True) + bf_result = bf_series.to_pandas() + pd_result = scalars_pandas_df_index["int64_col"].sort_values(ascending=False) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("ascending"), + [ + (True,), + (False,), + ], +) +def test_sort_index(scalars_df_index, scalars_pandas_df_index, ascending): + bf_result = ( + scalars_df_index["int64_too"].sort_index(ascending=ascending).to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_too"].sort_index(ascending=ascending) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_sort_index_inplace(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_too"].copy() + bf_series.sort_index(ascending=False, inplace=True) + bf_result = bf_series.to_pandas() + pd_result = scalars_pandas_df_index["int64_too"].sort_index(ascending=False) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_mask_default_value(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"] + bf_col_masked = bf_col.mask(bf_col % 2 == 1) + bf_result = bf_col.to_frame().assign(int64_col_masked=bf_col_masked).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_col_masked = pd_col.mask(pd_col % 2 == 1) + pd_result = pd_col.to_frame().assign(int64_col_masked=pd_col_masked) + + assert_frame_equal(bf_result, pd_result) + + +def test_mask_custom_value(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"] + bf_col_masked = bf_col.mask(bf_col % 2 == 1, -1) + bf_result = bf_col.to_frame().assign(int64_col_masked=bf_col_masked).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_col_masked = pd_col.mask(pd_col % 2 == 1, -1) + pd_result = pd_col.to_frame().assign(int64_col_masked=pd_col_masked) + + # TODO(shobs): There is a pd.NA value in the original series, which is not + # odd so should be left as is, but it is being masked in pandas. + # Accidentally the bigframes bahavior matches, but it should be updated + # after the resolution of https://github.com/pandas-dev/pandas/issues/52955 + assert_frame_equal(bf_result, pd_result) + + +def test_mask_with_callable(scalars_df_index, scalars_pandas_df_index): + def _ten_times(x): + return x * 10 + + # Both cond and other are callable. + bf_result = ( + scalars_df_index["int64_col"] + .mask(cond=lambda x: x > 0, other=_ten_times) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].mask( + cond=lambda x: x > 0, other=_ten_times + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("lambda_",), + [ + pytest.param(lambda x: x > 0), + pytest.param( + lambda x: True if x > 0 else False, + marks=pytest.mark.xfail( + raises=ValueError, + ), + ), + ], + ids=[ + "lambda_arithmatic", + "lambda_arbitrary", + ], +) +def test_mask_lambda(scalars_dfs, lambda_): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"] + bf_result = bf_col.mask(lambda_).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_result = pd_col.mask(lambda_) + + # ignore dtype check, which are Int64 and object respectively + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +def test_mask_simple_udf(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + def foo(x): + return x < 1000000 + + bf_col = scalars_df["int64_col"] + bf_result = bf_col.mask(foo).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_result = pd_col.mask(foo) + + # ignore dtype check, which are Int64 and object respectively + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +@pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: decimal precision should be <= 38 & >= 1" +) +@pytest.mark.parametrize("errors", ["raise", "null"]) +@pytest.mark.parametrize( + ("column", "to_type"), + [ + ("int64_col", "Float64"), + ("int64_col", "Int64"), # No-op + ("int64_col", pd.Float64Dtype()), + ("int64_col", "string[pyarrow]"), + ("int64_col", "boolean"), + ("int64_col", pd.ArrowDtype(pa.decimal128(38, 9))), + ("int64_col", pd.ArrowDtype(pa.decimal256(76, 38))), + ("int64_col", pd.ArrowDtype(pa.timestamp("us"))), + ("int64_col", pd.ArrowDtype(pa.timestamp("us", tz="UTC"))), + ("int64_col", "time64[us][pyarrow]"), + ("int64_col", pd.ArrowDtype(db_dtypes.JSONArrowType())), + ("bool_col", "Int64"), + ("bool_col", "string[pyarrow]"), + ("bool_col", "Float64"), + ("bool_col", pd.ArrowDtype(db_dtypes.JSONArrowType())), + ("string_col", "binary[pyarrow]"), + ("bytes_col", "string[pyarrow]"), + # pandas actually doesn't let folks convert to/from naive timestamp and + # raises a deprecation warning to use tz_localize/tz_convert instead, + # but BigQuery always stores values as UTC and doesn't have to deal + # with timezone conversions, so we'll allow it. + ("timestamp_col", "date32[day][pyarrow]"), + ("timestamp_col", "time64[us][pyarrow]"), + ("timestamp_col", pd.ArrowDtype(pa.timestamp("us"))), + ("datetime_col", "date32[day][pyarrow]"), + pytest.param( + "datetime_col", + "string[pyarrow]", + marks=pytest.mark.skipif( + pd.__version__.startswith("2.2"), + reason="pandas 2.2 uses T as date/time separator whereas earlier versions use space", + ), + ), + ("datetime_col", "time64[us][pyarrow]"), + ("datetime_col", pd.ArrowDtype(pa.timestamp("us", tz="UTC"))), + ("date_col", "string[pyarrow]"), + ("date_col", pd.ArrowDtype(pa.timestamp("us"))), + ("date_col", pd.ArrowDtype(pa.timestamp("us", tz="UTC"))), + ("time_col", "string[pyarrow]"), + # TODO(bmil): fix Ibis bug: BigQuery backend rounds to nearest int + # ("float64_col", "Int64"), + # TODO(bmil): decide whether to fix Ibis bug: BigQuery backend + # formats floats with no decimal places if they have no fractional + # part, and does not switch to scientific notation for > 10^15 + # ("float64_col", "string[pyarrow]") + # TODO(bmil): add any other compatible conversions per + # https://cloud.google.com/bigquery/docs/reference/standard-sql/conversion_functions + ], +) +def test_astype(scalars_df_index, scalars_pandas_df_index, column, to_type, errors): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + bf_result = scalars_df_index[column].astype(to_type, errors=errors).to_pandas() + pd_result = scalars_pandas_df_index[column].astype(to_type) + pd.testing.assert_series_equal(bf_result, pd_result) + + +@pytest.mark.skip( + reason="AttributeError: 'DataFrame' object has no attribute 'dtype'. Did you mean: 'dtypes'?" +) +def test_series_astype_python(session): + input = pd.Series(["hello", "world", "3.11", "4000"]) + exepcted = pd.Series( + [None, None, 3.11, 4000], + dtype="Float64", + index=pd.Index([0, 1, 2, 3], dtype="Int64"), + ) + result = session.read_pandas(input).astype(float, errors="null").to_pandas() + pd.testing.assert_series_equal(result, exepcted) + + +@pytest.mark.skip( + reason="AttributeError: 'DataFrame' object has no attribute 'dtype'. Did you mean: 'dtypes'?" +) +def test_astype_safe(session): + input = pd.Series(["hello", "world", "3.11", "4000"]) + exepcted = pd.Series( + [None, None, 3.11, 4000], + dtype="Float64", + index=pd.Index([0, 1, 2, 3], dtype="Int64"), + ) + result = session.read_pandas(input).astype("Float64", errors="null").to_pandas() + pd.testing.assert_series_equal(result, exepcted) + + +def test_series_astype_w_invalid_error(session): + input = pd.Series(["hello", "world", "3.11", "4000"]) + with pytest.raises(ValueError): + session.read_pandas(input).astype("Float64", errors="bad_value") + + +@pytest.mark.parametrize( + ("column", "to_type"), + [ + ("timestamp_col", "int64[pyarrow]"), + ("datetime_col", "int64[pyarrow]"), + ("time_col", "int64[pyarrow]"), + ], +) +def test_date_time_astype_int( + scalars_df_index, scalars_pandas_df_index, column, to_type +): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + bf_result = scalars_df_index[column].astype(to_type).to_pandas() + pd_result = scalars_pandas_df_index[column].astype(to_type) + pd.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + assert bf_result.dtype == "Int64" + + +@pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: conversion from `str` to `i64` failed in column 'column_0' for 1 out of 4 values: [' -03']" +) +def test_string_astype_int(): + pd_series = pd.Series(["4", "-7", "0", " -03"]) + bf_series = series.Series(pd_series) + + pd_result = pd_series.astype("Int64") + bf_result = bf_series.astype("Int64").to_pandas() + + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + + +@pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: conversion from `str` to `f64` failed in column 'column_0' for 1 out of 10 values: [' -03.235']" +) +def test_string_astype_float(): + pd_series = pd.Series( + ["1", "-1", "-0", "000", " -03.235", "naN", "-inf", "INf", ".33", "7.235e-8"] + ) + + bf_series = series.Series(pd_series) + + pd_result = pd_series.astype("Float64") + bf_result = bf_series.astype("Float64").to_pandas() + + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + + +def test_string_astype_date(): + if int(pa.__version__.split(".")[0]) < 15: + pytest.skip( + "Avoid pyarrow.lib.ArrowNotImplementedError: " + "Unsupported cast from string to date32 using function cast_date32." + ) + + pd_series = pd.Series(["2014-08-15", "2215-08-15", "2016-02-29"]).astype( + pd.ArrowDtype(pa.string()) + ) + + bf_series = series.Series(pd_series) + + # TODO(b/340885567): fix type error + pd_result = pd_series.astype("date32[day][pyarrow]") # type: ignore + bf_result = bf_series.astype("date32[day][pyarrow]").to_pandas() + + assert_series_equal(bf_result, pd_result, check_index_type=False) + + +def test_string_astype_datetime(): + pd_series = pd.Series( + ["2014-08-15 08:15:12", "2015-08-15 08:15:12.654754", "2016-02-29 00:00:00"] + ).astype(pd.ArrowDtype(pa.string())) + + bf_series = series.Series(pd_series) + + pd_result = pd_series.astype(pd.ArrowDtype(pa.timestamp("us"))) + bf_result = bf_series.astype(pd.ArrowDtype(pa.timestamp("us"))).to_pandas() + + assert_series_equal(bf_result, pd_result, check_index_type=False) + + +def test_string_astype_timestamp(): + pd_series = pd.Series( + [ + "2014-08-15 08:15:12+00:00", + "2015-08-15 08:15:12.654754+05:00", + "2016-02-29 00:00:00+08:00", + ] + ).astype(pd.ArrowDtype(pa.string())) + + bf_series = series.Series(pd_series) + + pd_result = pd_series.astype(pd.ArrowDtype(pa.timestamp("us", tz="UTC"))) + bf_result = bf_series.astype( + pd.ArrowDtype(pa.timestamp("us", tz="UTC")) + ).to_pandas() + + assert_series_equal(bf_result, pd_result, check_index_type=False) + + +@pytest.mark.skip(reason="AssertionError: Series are different") +def test_timestamp_astype_string(): + bf_series = series.Series( + [ + "2014-08-15 08:15:12+00:00", + "2015-08-15 08:15:12.654754+05:00", + "2016-02-29 00:00:00+08:00", + ] + ).astype(pd.ArrowDtype(pa.timestamp("us", tz="UTC"))) + + expected_result = pd.Series( + [ + "2014-08-15 08:15:12+00", + "2015-08-15 03:15:12.654754+00", + "2016-02-28 16:00:00+00", + ] + ) + bf_result = bf_series.astype(pa.string()).to_pandas() + + pd.testing.assert_series_equal( + bf_result, expected_result, check_index_type=False, check_dtype=False + ) + assert bf_result.dtype == "string[pyarrow]" + + +@pytest.mark.skip(reason="AssertionError: Series are different") +@pytest.mark.parametrize("errors", ["raise", "null"]) +def test_float_astype_json(errors): + data = ["1.25", "2500000000", None, "-12323.24"] + bf_series = series.Series(data, dtype=dtypes.FLOAT_DTYPE) + + bf_result = bf_series.astype(dtypes.JSON_DTYPE, errors=errors) + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected_result = pd.Series(data, dtype=dtypes.JSON_DTYPE) + expected_result.index = expected_result.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected_result) + + +@pytest.mark.skip(reason="AssertionError: Series are different") +def test_float_astype_json_str(): + data = ["1.25", "2500000000", None, "-12323.24"] + bf_series = series.Series(data, dtype=dtypes.FLOAT_DTYPE) + + bf_result = bf_series.astype("json") + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected_result = pd.Series(data, dtype=dtypes.JSON_DTYPE) + expected_result.index = expected_result.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected_result) + + +@pytest.mark.parametrize("errors", ["raise", "null"]) +def test_string_astype_json(errors): + data = [ + "1", + None, + '["1","3","5"]', + '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}', + ] + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) + + bf_result = bf_series.astype(dtypes.JSON_DTYPE, errors=errors) + assert bf_result.dtype == dtypes.JSON_DTYPE + + pd_result = bf_series.to_pandas().astype(dtypes.JSON_DTYPE) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +@pytest.mark.skip(reason="AssertionError: Series NA mask are different") +def test_string_astype_json_in_safe_mode(): + data = ["this is not a valid json string"] + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) + bf_result = bf_series.astype(dtypes.JSON_DTYPE, errors="null") + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected = pd.Series([None], dtype=dtypes.JSON_DTYPE) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) + + +@pytest.mark.skip( + reason="Failed: DID NOT RAISE " +) +def test_string_astype_json_raise_error(): + data = ["this is not a valid json string"] + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) + with pytest.raises( + google.api_core.exceptions.BadRequest, + match="syntax error while parsing value", + ): + bf_series.astype(dtypes.JSON_DTYPE, errors="raise").to_pandas() + + +@pytest.mark.parametrize("errors", ["raise", "null"]) +@pytest.mark.parametrize( + ("data", "to_type"), + [ + pytest.param(["1", "10.0", None], dtypes.INT_DTYPE, id="to_int"), + pytest.param(["0.0001", "2500000000", None], dtypes.FLOAT_DTYPE, id="to_float"), + pytest.param(["true", "false", None], dtypes.BOOL_DTYPE, id="to_bool"), + pytest.param(['"str"', None], dtypes.STRING_DTYPE, id="to_string"), + pytest.param( + ['"str"', None], + dtypes.TIME_DTYPE, + id="invalid", + marks=pytest.mark.xfail(raises=TypeError), + ), + ], +) +def test_json_astype_others(data, to_type, errors): + bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE) + + bf_result = bf_series.astype(to_type, errors=errors) + assert bf_result.dtype == to_type + + load_data = [json.loads(item) if item is not None else None for item in data] + expected = pd.Series(load_data, dtype=to_type) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) + + +@pytest.mark.skip( + reason="Failed: DID NOT RAISE " +) +@pytest.mark.parametrize( + ("data", "to_type"), + [ + pytest.param(["10.2", None], dtypes.INT_DTYPE, id="to_int"), + pytest.param(["false", None], dtypes.FLOAT_DTYPE, id="to_float"), + pytest.param(["10.2", None], dtypes.BOOL_DTYPE, id="to_bool"), + pytest.param(["true", None], dtypes.STRING_DTYPE, id="to_string"), + ], +) +def test_json_astype_others_raise_error(data, to_type): + bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE) + with pytest.raises(google.api_core.exceptions.BadRequest): + bf_series.astype(to_type, errors="raise").to_pandas() + + +@pytest.mark.skip(reason="AssertionError: Series NA mask are different") +@pytest.mark.parametrize( + ("data", "to_type"), + [ + pytest.param(["10.2", None], dtypes.INT_DTYPE, id="to_int"), + pytest.param(["false", None], dtypes.FLOAT_DTYPE, id="to_float"), + pytest.param(["10.2", None], dtypes.BOOL_DTYPE, id="to_bool"), + pytest.param(["true", None], dtypes.STRING_DTYPE, id="to_string"), + ], +) +def test_json_astype_others_in_safe_mode(data, to_type): + bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE) + bf_result = bf_series.astype(to_type, errors="null") + assert bf_result.dtype == to_type + + expected = pd.Series([None, None], dtype=to_type) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) + + +@pytest.mark.parametrize( + "index", + [0, 5, -2], +) +def test_iloc_single_integer(scalars_df_index, scalars_pandas_df_index, index): + bf_result = scalars_df_index.string_col.iloc[index] + pd_result = scalars_pandas_df_index.string_col.iloc[index] + + assert bf_result == pd_result + + +def test_iloc_single_integer_out_of_bound_error(scalars_df_index): + with pytest.raises(IndexError, match="single positional indexer is out-of-bounds"): + scalars_df_index.string_col.iloc[99] + + +def test_loc_bool_series_explicit_index(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.loc[scalars_df_index.bool_col].to_pandas() + pd_result = scalars_pandas_df_index.string_col.loc[scalars_pandas_df_index.bool_col] + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip(reason="fixture 'scalars_pandas_df_default_index' not found") +def test_loc_bool_series_default_index( + scalars_df_default_index, scalars_pandas_df_default_index +): + bf_result = scalars_df_default_index.string_col.loc[ + scalars_df_default_index.bool_col + ].to_pandas() + pd_result = scalars_pandas_df_default_index.string_col.loc[ + scalars_pandas_df_default_index.bool_col + ] + + assert_frame_equal( + bf_result.to_frame(), + pd_result.to_frame(), + ) + + +def test_argmin(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.argmin() + pd_result = scalars_pandas_df_index.string_col.argmin() + assert bf_result == pd_result + + +def test_argmax(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_too.argmax() + pd_result = scalars_pandas_df_index.int64_too.argmax() + assert bf_result == pd_result + + +def test_series_idxmin(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.idxmin() + pd_result = scalars_pandas_df_index.string_col.idxmin() + assert bf_result == pd_result + + +def test_series_idxmax(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_too.idxmax() + pd_result = scalars_pandas_df_index.int64_too.idxmax() + assert bf_result == pd_result + + +def test_getattr_attribute_error_when_pandas_has(scalars_df_index): + # asof is implemented in pandas but not in bigframes + with pytest.raises(AttributeError): + scalars_df_index.string_col.asof() + + +def test_getattr_attribute_error(scalars_df_index): + with pytest.raises(AttributeError): + scalars_df_index.string_col.not_a_method() + + +def test_rename(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.rename("newname") + pd_result = scalars_pandas_df_index.string_col.rename("newname") + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_rename_nonstring(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.rename((4, 2)) + pd_result = scalars_pandas_df_index.string_col.rename((4, 2)) + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_rename_dict_same_type(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.rename({1: 100, 2: 200}) + pd_result = scalars_pandas_df_index.string_col.rename({1: 100, 2: 200}) + + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_rename_axis(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.rename_axis("newindexname") + pd_result = scalars_pandas_df_index.string_col.rename_axis("newindexname") + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_list_string_index(scalars_df_index, scalars_pandas_df_index): + index_list = scalars_pandas_df_index.string_col.iloc[[0, 1, 1, 5]].values + + scalars_df_index = scalars_df_index.set_index("string_col", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index( + "string_col", drop=False + ) + + bf_result = scalars_df_index.string_col.loc[index_list] + pd_result = scalars_pandas_df_index.string_col.loc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_list_integer_index(scalars_df_index, scalars_pandas_df_index): + index_list = [3, 2, 1, 3, 2, 1] + + bf_result = scalars_df_index.bool_col.loc[index_list] + pd_result = scalars_pandas_df_index.bool_col.loc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_list_multiindex(scalars_df_index, scalars_pandas_df_index): + scalars_df_multiindex = scalars_df_index.set_index(["string_col", "int64_col"]) + scalars_pandas_df_multiindex = scalars_pandas_df_index.set_index( + ["string_col", "int64_col"] + ) + index_list = [("Hello, World!", -234892), ("Hello, World!", 123456789)] + + bf_result = scalars_df_multiindex.int64_too.loc[index_list] + pd_result = scalars_pandas_df_multiindex.int64_too.loc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_iloc_list(scalars_df_index, scalars_pandas_df_index): + index_list = [0, 0, 0, 5, 4, 7] + + bf_result = scalars_df_index.string_col.iloc[index_list] + pd_result = scalars_pandas_df_index.string_col.iloc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_iloc_list_nameless(scalars_df_index, scalars_pandas_df_index): + index_list = [0, 0, 0, 5, 4, 7] + + bf_series = scalars_df_index.string_col.rename(None) + bf_result = bf_series.iloc[index_list] + pd_series = scalars_pandas_df_index.string_col.rename(None) + pd_result = pd_series.iloc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_list_nameless(scalars_df_index, scalars_pandas_df_index): + index_list = [0, 0, 0, 5, 4, 7] + + bf_series = scalars_df_index.string_col.rename(None) + bf_result = bf_series.loc[index_list] + + pd_series = scalars_pandas_df_index.string_col.rename(None) + pd_result = pd_series.loc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_series_string_index(scalars_df_index, scalars_pandas_df_index): + pd_string_series = scalars_pandas_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + bf_string_series = scalars_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + + scalars_df_index = scalars_df_index.set_index("string_col") + scalars_pandas_df_index = scalars_pandas_df_index.set_index("string_col") + + bf_result = scalars_df_index.date_col.loc[bf_string_series] + pd_result = scalars_pandas_df_index.date_col.loc[pd_string_series] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_series_multiindex(scalars_df_index, scalars_pandas_df_index): + pd_string_series = scalars_pandas_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + bf_string_series = scalars_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + + scalars_df_multiindex = scalars_df_index.set_index(["string_col", "int64_col"]) + scalars_pandas_df_multiindex = scalars_pandas_df_index.set_index( + ["string_col", "int64_col"] + ) + + bf_result = scalars_df_multiindex.int64_too.loc[bf_string_series] + pd_result = scalars_pandas_df_multiindex.int64_too.loc[pd_string_series] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_index_integer_index(scalars_df_index, scalars_pandas_df_index): + pd_index = scalars_pandas_df_index.iloc[[0, 5, 1, 1, 5]].index + bf_index = scalars_df_index.iloc[[0, 5, 1, 1, 5]].index + + bf_result = scalars_df_index.date_col.loc[bf_index] + pd_result = scalars_pandas_df_index.date_col.loc[pd_index] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_single_index_with_duplicate(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("string_col", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index( + "string_col", drop=False + ) + index = "Hello, World!" + bf_result = scalars_df_index.date_col.loc[index] + pd_result = scalars_pandas_df_index.date_col.loc[index] + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_single_index_no_duplicate(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("int64_too", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index("int64_too", drop=False) + index = -2345 + bf_result = scalars_df_index.date_col.loc[index] + pd_result = scalars_pandas_df_index.date_col.loc[index] + assert bf_result == pd_result + + +def test_series_bool_interpretation_error(scalars_df_index): + with pytest.raises(ValueError): + True if scalars_df_index["string_col"] else False + + +@pytest.mark.skip( + reason="NotImplementedError: dry_run not implemented for this executor" +) +def test_query_job_setters(scalars_dfs): + # if allow_large_results=False, might not create query job + with bigframes.option_context("compute.allow_large_results", True): + job_ids = set() + df, _ = scalars_dfs + series = df["int64_col"] + assert series.query_job is not None + repr(series) + job_ids.add(series.query_job.job_id) + series.to_pandas() + job_ids.add(series.query_job.job_id) + assert len(job_ids) == 2 + + +@pytest.mark.parametrize( + ("series_input",), + [ + ([1, 2, 3, 4, 5],), + ([1, 1, 3, 5, 5],), + ([1, pd.NA, 4, 5, 5],), + ([1, 3, 2, 5, 4],), + ([pd.NA, pd.NA],), + ([1, 1, 1, 1, 1],), + ], +) +def test_is_monotonic_increasing(series_input): + scalars_df = series.Series(series_input, dtype=pd.Int64Dtype()) + scalars_pandas_df = pd.Series(series_input, dtype=pd.Int64Dtype()) + assert ( + scalars_df.is_monotonic_increasing == scalars_pandas_df.is_monotonic_increasing + ) + + +@pytest.mark.parametrize( + ("series_input",), + [ + ([1],), + ([5, 4, 3, 2, 1],), + ([5, 5, 3, 1, 1],), + ([1, pd.NA, 4, 5, 5],), + ([5, pd.NA, 4, 2, 1],), + ([1, 1, 1, 1, 1],), + ], +) +def test_is_monotonic_decreasing(series_input): + scalars_df = series.Series(series_input) + scalars_pandas_df = pd.Series(series_input) + assert ( + scalars_df.is_monotonic_decreasing == scalars_pandas_df.is_monotonic_decreasing + ) + + +def test_map_dict_input(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + local_map = dict() + # construct a local map, incomplete to cover behavior + for s in scalars_pandas_df.string_col[:-3]: + if isinstance(s, str): + local_map[s] = ord(s[0]) + + pd_result = scalars_pandas_df.string_col.map(local_map) + pd_result = pd_result.astype("Int64") # pandas type differences + bf_result = scalars_df.string_col.map(local_map) + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_map_series_input(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + new_index = scalars_pandas_df.int64_too.drop_duplicates() + pd_map_series = scalars_pandas_df.string_col.iloc[0 : len(new_index)] + pd_map_series.index = new_index + bf_map_series = series.Series( + pd_map_series, session=scalars_df._get_block().expr.session + ) + + pd_result = scalars_pandas_df.int64_too.map(pd_map_series) + bf_result = scalars_df.int64_too.map(bf_map_series) + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_map_series_input_duplicates_error(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + new_index = scalars_pandas_df.int64_too + pd_map_series = scalars_pandas_df.string_col.iloc[0 : len(new_index)] + pd_map_series.index = new_index + bf_map_series = series.Series( + pd_map_series, session=scalars_df._get_block().expr.session + ) + + with pytest.raises(pd.errors.InvalidIndexError): + scalars_pandas_df.int64_too.map(pd_map_series) + with pytest.raises(pd.errors.InvalidIndexError): + scalars_df.int64_too.map(bf_map_series, verify_integrity=True) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented hash()" +) +@pytest.mark.parametrize( + ("frac", "n", "random_state"), + [ + (None, 4, None), + (0.5, None, None), + (None, 4, 10), + (0.5, None, 10), + (None, None, None), + ], + ids=[ + "n_wo_random_state", + "frac_wo_random_state", + "n_w_random_state", + "frac_w_random_state", + "n_default", + ], +) +def test_sample(scalars_dfs, frac, n, random_state): + scalars_df, _ = scalars_dfs + df = scalars_df.int64_col.sample(frac=frac, n=n, random_state=random_state) + bf_result = df.to_pandas() + + n = 1 if n is None else n + expected_sample_size = round(frac * scalars_df.shape[0]) if frac is not None else n + assert bf_result.shape[0] == expected_sample_size + + +def test_series_iter( + scalars_df_index, + scalars_pandas_df_index, +): + for bf_i, pd_i in zip( + scalars_df_index["int64_too"], scalars_pandas_df_index["int64_too"] + ): + assert bf_i == pd_i + + +@pytest.mark.parametrize( + ( + "col", + "lambda_", + ), + [ + pytest.param("int64_col", lambda x: x * x + x + 1), + pytest.param("int64_col", lambda x: x % 2 == 1), + pytest.param("string_col", lambda x: x + "_suffix"), + ], + ids=[ + "lambda_int_int", + "lambda_int_bool", + "lambda_str_str", + ], +) +def test_apply_lambda(scalars_dfs, col, lambda_): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df[col] + + # Can't be applied to BigFrames Series without by_row=False + with pytest.raises(ValueError, match="by_row=False"): + bf_col.apply(lambda_) + + bf_result = bf_col.apply(lambda_, by_row=False).to_pandas() + + pd_col = scalars_pandas_df[col] + if pd.__version__[:3] in ("2.2", "2.3") or pandas_major_version() >= 3: + pd_result = pd_col.apply(lambda_, by_row=False) + else: + pd_result = pd_col.apply(lambda_) + + # ignore dtype check, which are Int64 and object respectively + # Some columns implicitly convert to floating point. Use check_exact=False to ensure we're "close enough" + assert_series_equal( + bf_result, + pd_result, + check_dtype=False, + check_exact=False, + rtol=0.001, + nulls_are_nan=True, + ) + + +@pytest.mark.parametrize( + ("ufunc",), + [ + pytest.param(numpy.cos, id="cos"), + pytest.param(numpy.log, id="log"), + pytest.param(numpy.log10, id="log10"), + pytest.param(numpy.log1p, id="log1p"), + pytest.param(numpy.sqrt, id="sqrt"), + pytest.param(numpy.sin, id="sin"), + ], +) +def test_apply_numpy_ufunc(scalars_dfs, ufunc): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"] + + # Can't be applied to BigFrames Series without by_row=False + with pytest.raises(ValueError, match="by_row=False"): + bf_col.apply(ufunc) + + bf_result = bf_col.apply(ufunc, by_row=False).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_result = pd_col.apply(ufunc) + + assert_series_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("ufunc",), + [ + pytest.param(math.log), + pytest.param(math.log10), + pytest.param(math.sin), + pytest.param(math.cos), + pytest.param(math.tan), + pytest.param(math.sinh), + pytest.param(math.cosh), + pytest.param(math.tanh), + pytest.param(math.asin), + pytest.param(math.acos), + pytest.param(math.atan), + pytest.param(abs), + ], +) +@pytest.mark.parametrize( + ("col",), + [pytest.param("float64_col"), pytest.param("int64_col")], +) +def test_series_apply_python_numeric_fns(scalars_dfs, ufunc, col): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df[col] + bf_result = bf_col.apply(ufunc).to_pandas() + + pd_col = scalars_pandas_df[col] + + def wrapped(x): + try: + return ufunc(x) + except ValueError: + return pd.NA + except OverflowError: + if ufunc == math.sinh and x < 0: + return float("-inf") + return float("inf") + + pd_result = pd_col.apply(wrapped) + + assert_series_equal(bf_result, pd_result, check_dtype=False, nulls_are_nan=True) + + +@pytest.mark.parametrize( + ("ufunc",), + [ + pytest.param(str.upper), + pytest.param(str.lower), + pytest.param(len), + ], +) +def test_series_apply_python_string_fns(scalars_dfs, ufunc): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["string_col"] + bf_result = bf_col.apply(ufunc).to_pandas() + + pd_col = scalars_pandas_df["string_col"] + + def wrapped(x): + return ufunc(x) if isinstance(x, str) else None + + pd_result = pd_col.apply(wrapped) + + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("ufunc",), + [ + pytest.param(numpy.add), + pytest.param(numpy.divide), + ], + ids=[ + "add", + "divide", + ], +) +def test_combine_series_ufunc(scalars_dfs, ufunc): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"].dropna() + bf_result = bf_col.combine(bf_col, ufunc).to_pandas() + + pd_col = scalars_pandas_df["int64_col"].dropna() + pd_result = pd_col.combine(pd_col, ufunc) + + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("func",), + [ + pytest.param(operator.add), + pytest.param(operator.truediv), + ], + ids=[ + "add", + "divide", + ], +) +def test_combine_series_pyfunc(scalars_dfs, func): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"].dropna() + bf_result = bf_col.combine(bf_col, func).to_pandas() + + pd_col = scalars_pandas_df["int64_col"].dropna() + pd_result = pd_col.combine(pd_col, func) + + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +def test_combine_scalar_ufunc(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"].dropna() + bf_result = bf_col.combine(2.5, numpy.add).to_pandas() + + pd_col = scalars_pandas_df["int64_col"].dropna() + pd_result = pd_col.combine(2.5, numpy.add) + + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +def test_apply_simple_udf(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + def foo(x): + return x * x + 2 * x + 3 + + bf_col = scalars_df["int64_col"] + + # Can't be applied to BigFrames Series without by_row=False + with pytest.raises(ValueError, match="by_row=False"): + bf_col.apply(foo) + + bf_result = bf_col.apply(foo, by_row=False).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + + if pd.__version__[:3] in ("2.2", "2.3"): + pd_result = pd_col.apply(foo, by_row=False) + else: + pd_result = pd_col.apply(foo) + + # ignore dtype check, which are Int64 and object respectively + # Some columns implicitly convert to floating point. Use check_exact=False to ensure we're "close enough" + assert_series_equal( + bf_result, + pd_result, + check_dtype=False, + check_exact=False, + rtol=0.001, + nulls_are_nan=True, + ) + + +@pytest.mark.parametrize( + ("col", "lambda_", "exception"), + [ + pytest.param("int64_col", {1: 2, 3: 4}, ValueError), + pytest.param("int64_col", numpy.square, TypeError), + pytest.param("string_col", lambda x: x.capitalize(), AttributeError), + ], + ids=[ + "not_callable", + "numpy_ufunc", + "custom_lambda", + ], +) +def test_apply_not_supported(scalars_dfs, col, lambda_, exception): + scalars_df, _ = scalars_dfs + + bf_col = scalars_df[col] + with pytest.raises(exception): + bf_col.apply(lambda_, by_row=False) + + +def test_series_pipe( + scalars_df_index, + scalars_pandas_df_index, +): + column = "int64_too" + + def foo(x: int, y: int, df): + return (df + x) % y + + bf_result = ( + scalars_df_index[column] + .pipe((foo, "df"), x=7, y=9) + .pipe(lambda x: x**2) + .to_pandas() + ) + + pd_result = ( + scalars_pandas_df_index[column] + .pipe((foo, "df"), x=7, y=9) + .pipe(lambda x: x**2) + ) + + assert_series_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("data"), + [ + pytest.param([1, 2, 3], id="int"), + pytest.param([[1, 2, 3], [], numpy.nan, [3, 4]], id="int_array"), + pytest.param( + [["A", "AA", "AAA"], ["BB", "B"], numpy.nan, [], ["C"]], id="string_array" + ), + pytest.param( + [ + {"A": {"x": 1.0}, "B": "b"}, + {"A": {"y": 2.0}, "B": "bb"}, + {"A": {"z": 4.0}}, + {}, + numpy.nan, + ], + id="struct_array", + ), + ], +) +def test_series_explode(data): + s = bigframes.pandas.Series(data) + pd_s = s.to_pandas() + pd.testing.assert_series_equal( + s.explode().to_pandas(), + pd_s.explode(), + check_index_type=False, + check_dtype=False, + ) + + +@pytest.mark.parametrize( + ("index", "ignore_index"), + [ + pytest.param(None, True, id="default_index"), + pytest.param(None, False, id="ignore_default_index"), + pytest.param([5, 1, 3, 2], True, id="unordered_index"), + pytest.param([5, 1, 3, 2], False, id="ignore_unordered_index"), + pytest.param(["z", "x", "a", "b"], True, id="str_index"), + pytest.param(["z", "x", "a", "b"], False, id="ignore_str_index"), + pytest.param( + pd.Index(["z", "x", "a", "b"], name="idx"), True, id="str_named_index" + ), + pytest.param( + pd.Index(["z", "x", "a", "b"], name="idx"), + False, + id="ignore_str_named_index", + ), + pytest.param( + pd.MultiIndex.from_frame( + pd.DataFrame({"idx0": [5, 1, 3, 2], "idx1": ["z", "x", "a", "b"]}) + ), + True, + id="multi_index", + ), + pytest.param( + pd.MultiIndex.from_frame( + pd.DataFrame({"idx0": [5, 1, 3, 2], "idx1": ["z", "x", "a", "b"]}) + ), + False, + id="ignore_multi_index", + ), + ], +) +def test_series_explode_w_index(index, ignore_index): + data = [[], [200.0, 23.12], [4.5, -9.0], [1.0]] + s = bigframes.pandas.Series(data, index=index) + pd_s = pd.Series(data, index=index) + # TODO(b/340885567): fix type error + assert_series_equal( + s.explode(ignore_index=ignore_index).to_pandas(), # type: ignore + pd_s.explode(ignore_index=ignore_index).astype(pd.Float64Dtype()), # type: ignore + check_index_type=False, + ) + + +@pytest.mark.parametrize( + ("ignore_index", "ordered"), + [ + pytest.param(True, True, id="include_index_ordered"), + pytest.param(True, False, id="include_index_unordered"), + pytest.param(False, True, id="ignore_index_ordered"), + ], +) +def test_series_explode_reserve_order(ignore_index, ordered): + data = [numpy.random.randint(0, 10, 10) for _ in range(10)] + s = bigframes.pandas.Series(data) + pd_s = pd.Series(data) + + # TODO(b/340885567): fix type error + res = s.explode(ignore_index=ignore_index).to_pandas(ordered=ordered) # type: ignore + # TODO(b/340885567): fix type error + pd_res = pd_s.explode(ignore_index=ignore_index).astype(pd.Int64Dtype()) # type: ignore + pd_res.index = pd_res.index.astype(pd.Int64Dtype()) + pd.testing.assert_series_equal( + res if ordered else res.sort_index(), + pd_res, + ) + + +def test_series_explode_w_aggregate(): + data = [[1, 2, 3], [], numpy.nan, [3, 4]] + s = bigframes.pandas.Series(data) + pd_s = pd.Series(data) + assert s.explode().sum() == pd_s.explode().sum() + + +def test_series_construct_empty_array(): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + s = bigframes.pandas.Series([[]]) + expected = pd.Series( + [[]], + dtype=pd.ArrowDtype(pa.list_(pa.float64())), + index=pd.Index([0], dtype=pd.Int64Dtype()), + ) + pd.testing.assert_series_equal( + expected, + s.to_pandas(), + ) + + +@pytest.mark.parametrize( + ("data"), + [ + pytest.param(numpy.nan, id="null"), + pytest.param([numpy.nan], id="null_array"), + pytest.param([[]], id="empty_array"), + pytest.param([numpy.nan, []], id="null_and_empty_array"), + ], +) +def test_series_explode_null(data): + s = bigframes.pandas.Series(data) + pd.testing.assert_series_equal( + s.explode().to_pandas(), + s.to_pandas().explode(), + check_dtype=False, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented IntegerLabelToDatetimeOp(freq=<75 * Days>, label=None, origin='start_day')" +) +@pytest.mark.parametrize( + ("append", "level", "col", "rule"), + [ + pytest.param(False, None, "timestamp_col", "75D"), + pytest.param(True, 1, "timestamp_col", "25W"), + pytest.param(False, None, "datetime_col", "3ME"), + pytest.param(True, "timestamp_col", "timestamp_col", "1YE"), + ], +) +def test_resample(scalars_df_index, scalars_pandas_df_index, append, level, col, rule): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df_index = scalars_df_index.set_index(col, append=append)["int64_col"] + scalars_pandas_df_index = scalars_pandas_df_index.set_index(col, append=append)[ + "int64_col" + ] + bf_result = scalars_df_index.resample(rule=rule, level=level).min().to_pandas() + pd_result = scalars_pandas_df_index.resample(rule=rule, level=level).min() + pd.testing.assert_series_equal(bf_result, pd_result) + + +@pytest.mark.skip(reason="fixture 'nested_structs_df' not found") +def test_series_struct_get_field_by_attribute( + nested_structs_df, nested_structs_pandas_df +): + if Version(pd.__version__) < Version("2.2.0"): + pytest.skip("struct accessor is not supported before pandas 2.2") + + bf_series = nested_structs_df["person"] + df_series = nested_structs_pandas_df["person"] + + pd.testing.assert_series_equal( + bf_series.address.city.to_pandas(), + df_series.struct.field("address").struct.field("city"), + check_dtype=False, + check_index=False, + ) + pd.testing.assert_series_equal( + bf_series.address.country.to_pandas(), + df_series.struct.field("address").struct.field("country"), + check_dtype=False, + check_index=False, + ) + + +@pytest.mark.skip(reason="fixture 'nested_structs_df' not found") +def test_series_struct_fields_in_dir(nested_structs_df): + series = nested_structs_df["person"] + + assert "age" in dir(series) + assert "address" in dir(series) + assert "city" in dir(series.address) + assert "country" in dir(series.address) + + +@pytest.mark.skip(reason="fixture 'nested_structs_df' not found") +def test_series_struct_class_attributes_shadow_struct_fields(nested_structs_df): + series = nested_structs_df["person"] + + assert series.name == "person" + + +@pytest.mark.skip( + reason="NotImplementedError: dry_run not implemented for this executor" +) +def test_series_to_pandas_dry_run(scalars_df_index): + bf_series = scalars_df_index["int64_col"] + + result = bf_series.to_pandas(dry_run=True) + + assert isinstance(result, pd.Series) + assert len(result) > 0 + + +def test_series_item(session): + # Test with a single item + bf_s_single = bigframes.pandas.Series([42], session=session) + pd_s_single = pd.Series([42]) + assert bf_s_single.item() == pd_s_single.item() + + +def test_series_item_with_multiple(session): + # Test with multiple items + bf_s_multiple = bigframes.pandas.Series([1, 2, 3], session=session) + pd_s_multiple = pd.Series([1, 2, 3]) + + try: + pd_s_multiple.item() + except ValueError as e: + expected_message = str(e) + else: + raise AssertionError("Expected ValueError from pandas, but didn't get one") + + with pytest.raises(ValueError, match=re.escape(expected_message)): + bf_s_multiple.item() + + +def test_series_item_with_empty(session): + # Test with an empty Series + bf_s_empty = bigframes.pandas.Series([], dtype="Int64", session=session) + pd_s_empty = pd.Series([], dtype="Int64") + + try: + pd_s_empty.item() + except ValueError as e: + expected_message = str(e) + else: + raise AssertionError("Expected ValueError from pandas, but didn't get one") + + with pytest.raises(ValueError, match=re.escape(expected_message)): + bf_s_empty.item() + + +def test_series_dt_total_seconds(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["duration_col"].dt.total_seconds().to_pandas() + + pd_result = scalars_pandas_df_index["duration_col"].dt.total_seconds() + + # Index will be object type in pandas, string type in bigframes, but same values + pd.testing.assert_series_equal( + bf_result, + pd_result, + check_index_type=False, + # bigframes uses Float64, newer pandas may use double[pyarrow] + check_dtype=False, + ) diff --git a/tests/unit/test_series_struct.py b/tests/unit/test_series_struct.py new file mode 100644 index 0000000000..c92b87cf48 --- /dev/null +++ b/tests/unit/test_series_struct.py @@ -0,0 +1,138 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import pathlib +from typing import Generator, TYPE_CHECKING + +import pandas as pd +import pandas.testing +import pyarrow as pa # type: ignore +import pytest + +import bigframes + +if TYPE_CHECKING: + from bigframes.testing import polars_session + +pytest.importorskip("polars") +pytest.importorskip("pandas", minversion="2.2.0") + +CURRENT_DIR = pathlib.Path(__file__).parent +DATA_DIR = CURRENT_DIR.parent / "data" + + +@pytest.fixture(scope="module", autouse=True) +def session() -> Generator[bigframes.Session, None, None]: + import bigframes.core.global_session + from bigframes.testing import polars_session + + session = polars_session.TestSession() + with bigframes.core.global_session._GlobalSessionContext(session): + yield session + + +@pytest.fixture +def struct_df(session: polars_session.TestSession): + pa_type = pa.struct( + [ + ("str_field", pa.string()), + ("int_field", pa.int64()), + ] + ) + return session.DataFrame( + { + "struct_col": pd.Series( + pa.array( + [ + { + "str_field": "my string", + "int_field": 1, + }, + { + "str_field": None, + "int_field": 2, + }, + { + "str_field": "another string", + "int_field": None, + }, + { + "str_field": "some string", + "int_field": 3, + }, + ], + pa_type, + ), + dtype=pd.ArrowDtype(pa_type), + ), + } + ) + + +@pytest.fixture +def struct_series(struct_df): + return struct_df["struct_col"] + + +def test_struct_dtypes(struct_series): + bf_series = struct_series + pd_series = struct_series.to_pandas() + assert isinstance(pd_series.dtype, pd.ArrowDtype) + + bf_result = bf_series.struct.dtypes + pd_result = pd_series.struct.dtypes + + pandas.testing.assert_series_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("field_name", "common_dtype"), + ( + ("str_field", "string[pyarrow]"), + ("int_field", "int64[pyarrow]"), + # TODO(tswast): Support referencing fields by number, too. + ), +) +def test_struct_field(struct_series, field_name, common_dtype): + bf_series = struct_series + pd_series = struct_series.to_pandas() + assert isinstance(pd_series.dtype, pd.ArrowDtype) + + bf_result = bf_series.struct.field(field_name).to_pandas() + pd_result = pd_series.struct.field(field_name) + + # TODO(tswast): if/when we support arrowdtype for int/string, we can remove + # this cast. + bf_result = bf_result.astype(common_dtype) + pd_result = pd_result.astype(common_dtype) + + pandas.testing.assert_series_equal(bf_result, pd_result) + + +def test_struct_explode(struct_series): + bf_series = struct_series + pd_series = struct_series.to_pandas() + assert isinstance(pd_series.dtype, pd.ArrowDtype) + + bf_result = bf_series.struct.explode().to_pandas() + pd_result = pd_series.struct.explode() + + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + # TODO(tswast): remove if/when we support arrowdtype for int/string. + check_dtype=False, + ) diff --git a/third_party/bigframes_vendored/constants.py b/third_party/bigframes_vendored/constants.py index d1aaa800cc..9705b19c90 100644 --- a/third_party/bigframes_vendored/constants.py +++ b/third_party/bigframes_vendored/constants.py @@ -23,9 +23,9 @@ import bigframes_vendored.version FEEDBACK_LINK = ( - "Share your usecase with the BigQuery DataFrames team at the " - "https://bit.ly/bigframes-feedback survey." - f"You are currently running BigFrames version {bigframes_vendored.version.__version__}" + "Share your use case with the BigQuery DataFrames team at the " + "https://bit.ly/bigframes-feedback survey. " + f"You are currently running BigFrames version {bigframes_vendored.version.__version__}." ) ABSTRACT_METHOD_ERROR_MESSAGE = ( @@ -47,6 +47,11 @@ ) WriteEngineType = Literal[ - "default", "bigquery_inline", "bigquery_load", "bigquery_streaming" + "default", + "bigquery_inline", + "bigquery_load", + "bigquery_streaming", + "bigquery_write", + "_deferred", ] VALID_WRITE_ENGINES = typing.get_args(WriteEngineType) diff --git a/third_party/bigframes_vendored/geopandas/geoseries.py b/third_party/bigframes_vendored/geopandas/geoseries.py index b7040d4321..642cf2fc90 100644 --- a/third_party/bigframes_vendored/geopandas/geoseries.py +++ b/third_party/bigframes_vendored/geopandas/geoseries.py @@ -18,7 +18,6 @@ class GeoSeries: >>> import bigframes.geopandas >>> import bigframes.pandas as bpd >>> from shapely.geometry import Point - >>> bpd.options.display.progress_bar = None >>> s = bigframes.geopandas.GeoSeries([Point(1, 1), Point(2, 2), Point(3, 3)]) >>> s @@ -37,6 +36,33 @@ class GeoSeries: e.g. ``name``. """ + # GeoSeries.area overrides Series.area with something totally different. + # Ignore this type error, as we are trying to be as close to geopandas as + # we can. + @property + def area(self, crs=None) -> bigframes.series.Series: # type: ignore + """[Not Implemented] Use ``bigframes.bigquery.st_area(series)``, + instead to return the area in square meters. + + In GeoPandas, this returns a Series containing the area of each geometry + in the GeoSeries expressed in the units of the CRS. + + Args: + crs (optional): + Coordinate Reference System of the geometry objects. Can be + anything accepted by pyproj.CRS.from_user_input(), such as an + authority string (eg “EPSG:4326”) or a WKT string. + + Returns: + bigframes.pandas.Series: + Series of float representing the areas. + + Raises: + NotImplementedError: + GeoSeries.area is not supported. Use bigframes.bigquery.st_area(series), instead. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property def x(self) -> bigframes.series.Series: """Return the x location of point geometries in a GeoSeries @@ -45,11 +71,10 @@ def x(self) -> bigframes.series.Series: >>> import bigframes.pandas as bpd >>> import geopandas.array - >>> import shapely - >>> bpd.options.display.progress_bar = None + >>> import shapely.geometry >>> series = bpd.Series( - ... [shapely.Point(1, 2), shapely.Point(2, 3), shapely.Point(3, 4)], + ... [shapely.geometry.Point(1, 2), shapely.geometry.Point(2, 3), shapely.geometry.Point(3, 4)], ... dtype=geopandas.array.GeometryDtype() ... ) >>> series.geo.x @@ -72,11 +97,10 @@ def y(self) -> bigframes.series.Series: >>> import bigframes.pandas as bpd >>> import geopandas.array - >>> import shapely - >>> bpd.options.display.progress_bar = None + >>> import shapely.geometry >>> series = bpd.Series( - ... [shapely.Point(1, 2), shapely.Point(2, 3), shapely.Point(3, 4)], + ... [shapely.geometry.Point(1, 2), shapely.geometry.Point(2, 3), shapely.geometry.Point(3, 4)], ... dtype=geopandas.array.GeometryDtype() ... ) >>> series.geo.y @@ -91,6 +115,45 @@ def y(self) -> bigframes.series.Series: """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property + def boundary(self) -> bigframes.geopandas.GeoSeries: + """ + Returns a GeoSeries of lower dimensional objects representing each + geometry's set-theoretic boundary. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import geopandas.array + >>> import shapely.geometry + + >>> from shapely.geometry import Polygon, LineString, Point + >>> s = geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (1, 1), (0, 1)]), + ... LineString([(0, 0), (1, 1), (1, 0)]), + ... Point(0, 0), + ... ] + ... ) + >>> s + 0 POLYGON ((0 0, 1 1, 0 1, 0 0)) + 1 LINESTRING (0 0, 1 1, 1 0) + 2 POINT (0 0) + dtype: geometry + + >>> s.boundary + 0 LINESTRING (0 0, 1 1, 0 1, 0 0) + 1 MULTIPOINT ((0 0), (1 0)) + 2 GEOMETRYCOLLECTION EMPTY + dtype: geometry + + Returns: + bigframes.geopandas.GeoSeries: + A GeoSeries of lower dimensional objects representing each + geometry's set-theoretic boundary + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @classmethod def from_xy(cls, x, y, index=None, **kwargs) -> bigframes.geopandas.GeoSeries: """ @@ -104,7 +167,6 @@ def from_xy(cls, x, y, index=None, **kwargs) -> bigframes.geopandas.GeoSeries: >>> import bigframes.pandas as bpd >>> import bigframes.geopandas - >>> bpd.options.display.progress_bar = None >>> x = [2.5, 5, -3.0] >>> y = [0.5, 1, 1.5] @@ -143,7 +205,6 @@ def from_wkt(cls, data, index=None) -> bigframes.geopandas.GeoSeries: >>> import bigframes as bpd >>> import bigframes.geopandas - >>> bpd.options.display.progress_bar = None >>> wkts = [ ... 'POINT (1 1)', @@ -179,7 +240,6 @@ def to_wkt(self) -> bigframes.series.Series: >>> import bigframes as bpd >>> import bigframes.geopandas >>> from shapely.geometry import Point - >>> bpd.options.display.progress_bar = None >>> s = bigframes.geopandas.GeoSeries([Point(1, 1), Point(2, 2), Point(3, 3)]) >>> s @@ -199,3 +259,268 @@ def to_wkt(self) -> bigframes.series.Series: WKT representations of the geometries. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def difference(self: GeoSeries, other: GeoSeries) -> GeoSeries: # type: ignore + """ + Returns a GeoSeries of the points in each aligned geometry that are not + in other. + + The operation works on a 1-to-1 row-wise manner. + + **Examples:** + + >>> import bigframes as bpd + >>> import bigframes.geopandas + >>> from shapely.geometry import Polygon, LineString, Point + + We can check two GeoSeries against each other, row by row: + + >>> s1 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (2, 2), (0, 2)]), + ... Polygon([(0, 0), (2, 2), (0, 2)]), + ... LineString([(0, 0), (2, 2)]), + ... LineString([(2, 0), (0, 2)]), + ... Point(0, 1), + ... ], + ... ) + >>> s2 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (1, 1), (0, 1)]), + ... LineString([(1, 0), (1, 3)]), + ... LineString([(2, 0), (0, 2)]), + ... Point(1, 1), + ... Point(0, 1), + ... ], + ... index=range(1, 6), + ... ) + + >>> s1 + 0 POLYGON ((0 0, 2 2, 0 2, 0 0)) + 1 POLYGON ((0 0, 2 2, 0 2, 0 0)) + 2 LINESTRING (0 0, 2 2) + 3 LINESTRING (2 0, 0 2) + 4 POINT (0 1) + dtype: geometry + + >>> s2 + 1 POLYGON ((0 0, 1 1, 0 1, 0 0)) + 2 LINESTRING (1 0, 1 3) + 3 LINESTRING (2 0, 0 2) + 4 POINT (1 1) + 5 POINT (0 1) + dtype: geometry + + >>> s1.difference(s2) + 0 None + 1 POLYGON ((0.99954 1, 2 2, 0 2, 0 1, 0.99954 1)) + 2 LINESTRING (0 0, 1 1.00046, 2 2) + 3 GEOMETRYCOLLECTION EMPTY + 4 POINT (0 1) + 5 None + dtype: geometry + + We can also check difference of single shapely geometries: + + >>> polygon_s1 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (10, 0), (10, 10), (0, 0)]) + ... ] + ... ) + >>> polygon_s2 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(4, 2), (6, 2), (8, 6), (4, 2)]) + ... ] + ... ) + + >>> polygon_s1 + 0 POLYGON ((0 0, 10 0, 10 10, 0 0)) + dtype: geometry + + >>> polygon_s2 + 0 POLYGON ((4 2, 6 2, 8 6, 4 2)) + dtype: geometry + + >>> polygon_s1.difference(polygon_s2) + 0 POLYGON ((0 0, 10 0, 10 10, 0 0), (8 6, 6 2, 4... + dtype: geometry + + Additionally, we can check difference of a GeoSeries against a single shapely geometry: + + >>> s1.difference(polygon_s2) + 0 POLYGON ((0 0, 2 2, 0 2, 0 0)) + 1 None + 2 None + 3 None + 4 None + dtype: geometry + + Args: + other (bigframes.geopandas.GeoSeries or geometric object): + The GeoSeries (elementwise) or geometric object to find the + difference to. + + Returns: + bigframes.geopandas.GeoSeries: + A GeoSeries of the points in each aligned geometry that are not + in other. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def distance(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: + """ + [Not Implemented] Use ``bigframes.bigquery.st_distance(series, other)`` + instead to return the shorted distance between two + ``GEOGRAPHY`` objects in meters. + + In GeoPandas, this returns a Series of the distances between each + aligned geometry in the expressed in the units of the CRS. + + Args: + other: + The Geoseries (elementwise) or geometric object to find the distance to. + + Returns: + bigframes.pandas.Series: + Series of float representing the distances. + + Raises: + NotImplementedError: + GeoSeries.distance is not supported. Use + ``bigframes.bigquery.st_distance(series, other)``, instead. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def intersection(self: GeoSeries, other: GeoSeries) -> GeoSeries: # type: ignore + """ + Returns a GeoSeries of the intersection of points in each aligned + geometry with other. + + The operation works on a 1-to-1 row-wise manner. + + **Examples:** + + >>> import bigframes as bpd + >>> import bigframes.geopandas + >>> from shapely.geometry import Polygon, LineString, Point + + We can check two GeoSeries against each other, row by row. + + >>> s1 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (2, 2), (0, 2)]), + ... Polygon([(0, 0), (2, 2), (0, 2)]), + ... LineString([(0, 0), (2, 2)]), + ... LineString([(2, 0), (0, 2)]), + ... Point(0, 1), + ... ], + ... ) + >>> s2 = bigframes.geopandas.GeoSeries( + ... [ + ... Polygon([(0, 0), (1, 1), (0, 1)]), + ... LineString([(1, 0), (1, 3)]), + ... LineString([(2, 0), (0, 2)]), + ... Point(1, 1), + ... Point(0, 1), + ... ], + ... index=range(1, 6), + ... ) + + >>> s1 + 0 POLYGON ((0 0, 2 2, 0 2, 0 0)) + 1 POLYGON ((0 0, 2 2, 0 2, 0 0)) + 2 LINESTRING (0 0, 2 2) + 3 LINESTRING (2 0, 0 2) + 4 POINT (0 1) + dtype: geometry + + >>> s2 + 1 POLYGON ((0 0, 1 1, 0 1, 0 0)) + 2 LINESTRING (1 0, 1 3) + 3 LINESTRING (2 0, 0 2) + 4 POINT (1 1) + 5 POINT (0 1) + dtype: geometry + + >>> s1.intersection(s2) + 0 None + 1 POLYGON ((0 0, 0.99954 1, 0 1, 0 0)) + 2 POINT (1 1.00046) + 3 LINESTRING (2 0, 0 2) + 4 GEOMETRYCOLLECTION EMPTY + 5 None + dtype: geometry + + + We can also do intersection of each geometry and a single shapely geometry: + + >>> s1.intersection(bigframes.geopandas.GeoSeries([Polygon([(0, 0), (1, 1), (0, 1)])])) + 0 POLYGON ((0 0, 0.99954 1, 0 1, 0 0)) + 1 None + 2 None + 3 None + 4 None + dtype: geometry + + + Args: + other (GeoSeries or geometric object): + The Geoseries (elementwise) or geometric object to find the + intersection with. + + Returns: + bigframes.geopandas.GeoSeries: + The Geoseries (elementwise) of the intersection of points in + each aligned geometry with other. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def is_closed(self: GeoSeries) -> bigframes.series.Series: + """ + [Not Implemented] Use ``bigframes.bigquery.st_isclosed(series)`` + instead to return a boolean indicating if a shape is closed. + + In GeoPandas, this returns a Series of booleans with value True if a + LineString's or LinearRing's first and last points are equal. + + Returns False for any other geometry type. + + Returns: + bigframes.pandas.Series: + Series of booleans. + + Raises: + NotImplementedError: + GeoSeries.is_closed is not supported. Use + ``bigframes.bigquery.st_isclosed(series)``, instead. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def simplify(self, tolerance: float, preserve_topology: bool = True) -> bigframes.series.Series: # type: ignore + """[Not Implemented] Use ``bigframes.bigquery.st_simplify(series, tolerance_meters)``, + instead to set the tolerance in meters. + + In GeoPandas, this returns a GeoSeries containing a simplified + representation of each geometry. + + Args: + tolerance (float): + All parts of a simplified geometry will be no more than + tolerance distance from the original. It has the same units as + the coordinate reference system of the GeoSeries. For example, + using tolerance=100 in a projected CRS with meters as units + means a distance of 100 meters in reality. + preserve_topology (bool): + Default True. False uses a quicker algorithm, but may produce + self-intersecting or otherwise invalid geometries. + + Returns: + bigframes.geopandas.GeoSeries: + Series of simplified geometries. + + Raises: + NotImplementedError: + GeoSeries.simplify is not supported. Use bigframes.bigquery.st_simplify(series, tolerance_meters), instead. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/google_cloud_bigquery/_pandas_helpers.py b/third_party/bigframes_vendored/google_cloud_bigquery/_pandas_helpers.py index 5e2a7a7ef0..3e35b1382e 100644 --- a/third_party/bigframes_vendored/google_cloud_bigquery/_pandas_helpers.py +++ b/third_party/bigframes_vendored/google_cloud_bigquery/_pandas_helpers.py @@ -17,6 +17,7 @@ import warnings +import db_dtypes import google.cloud.bigquery.schema as schema import pyarrow @@ -61,6 +62,7 @@ def pyarrow_timestamp(): "TIME": pyarrow_time, "TIMESTAMP": pyarrow_timestamp, "BIGNUMERIC": pyarrow_bignumeric, + "JSON": db_dtypes.JSONArrowType, } ARROW_SCALAR_IDS_TO_BQ = { # https://arrow.apache.org/docs/python/api/datatypes.html#type-classes diff --git a/third_party/bigframes_vendored/google_cloud_bigquery/retry.py b/third_party/bigframes_vendored/google_cloud_bigquery/retry.py new file mode 100644 index 0000000000..15ecda4fbc --- /dev/null +++ b/third_party/bigframes_vendored/google_cloud_bigquery/retry.py @@ -0,0 +1,220 @@ +# Original: https://github.com/googleapis/python-bigquery/blob/main/google/cloud/bigquery/retry.py +# Copyright 2018 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.api_core import exceptions, retry +import google.api_core.future.polling +from google.auth import exceptions as auth_exceptions # type: ignore +import requests.exceptions + +_RETRYABLE_REASONS = frozenset( + ["rateLimitExceeded", "backendError", "internalError", "badGateway"] +) + +_UNSTRUCTURED_RETRYABLE_TYPES = ( + ConnectionError, + exceptions.TooManyRequests, + exceptions.InternalServerError, + exceptions.BadGateway, + exceptions.ServiceUnavailable, + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + auth_exceptions.TransportError, +) + +_MINUTE_IN_SECONDS = 60.0 +_HOUR_IN_SECONDS = 60.0 * _MINUTE_IN_SECONDS +_DEFAULT_RETRY_DEADLINE = 10.0 * _MINUTE_IN_SECONDS + +# Ambiguous errors (e.g. internalError, backendError, rateLimitExceeded) retry +# until the full `_DEFAULT_RETRY_DEADLINE`. This is because the +# `jobs.getQueryResults` REST API translates a job failure into an HTTP error. +# +# TODO(https://github.com/googleapis/python-bigquery/issues/1903): Investigate +# if we can fail early for ambiguous errors in `QueryJob.result()`'s call to +# the `jobs.getQueryResult` API. +# +# We need `_DEFAULT_JOB_DEADLINE` to be some multiple of +# `_DEFAULT_RETRY_DEADLINE` to allow for a few retries after the retry +# timeout is reached. +# +# Note: This multiple should actually be a multiple of +# (2 * _DEFAULT_RETRY_DEADLINE). After an ambiguous exception, the first +# call from `job_retry()` refreshes the job state without actually restarting +# the query. The second `job_retry()` actually restarts the query. For a more +# detailed explanation, see the comments where we set `restart_query_job = True` +# in `QueryJob.result()`'s inner `is_job_done()` function. +_DEFAULT_JOB_DEADLINE = 2.0 * (2.0 * _DEFAULT_RETRY_DEADLINE) + + +def _should_retry(exc): + """Predicate for determining when to retry. + + We retry if and only if the 'reason' is 'backendError' + or 'rateLimitExceeded'. + """ + if not hasattr(exc, "errors") or len(exc.errors) == 0: + # Check for unstructured error returns, e.g. from GFE + return isinstance(exc, _UNSTRUCTURED_RETRYABLE_TYPES) + + reason = exc.errors[0]["reason"] + return reason in _RETRYABLE_REASONS + + +DEFAULT_RETRY = retry.Retry(predicate=_should_retry, deadline=_DEFAULT_RETRY_DEADLINE) +"""The default retry object. + +Any method with a ``retry`` parameter will be retried automatically, +with reasonable defaults. To disable retry, pass ``retry=None``. +To modify the default retry behavior, call a ``with_XXX`` method +on ``DEFAULT_RETRY``. For example, to change the deadline to 30 seconds, +pass ``retry=bigquery.DEFAULT_RETRY.with_deadline(30)``. +""" + + +def _should_retry_get_job_conflict(exc): + """Predicate for determining when to retry a jobs.get call after a conflict error. + + Sometimes we get a 404 after a Conflict. In this case, we + have pretty high confidence that by retrying the 404, we'll + (hopefully) eventually recover the job. + https://github.com/googleapis/python-bigquery/issues/2134 + + Note: we may be able to extend this to user-specified predicates + after https://github.com/googleapis/python-api-core/issues/796 + to tweak existing Retry object predicates. + """ + return isinstance(exc, exceptions.NotFound) or _should_retry(exc) + + +# Pick a deadline smaller than our other deadlines since we want to timeout +# before those expire. +_DEFAULT_GET_JOB_CONFLICT_DEADLINE = _DEFAULT_RETRY_DEADLINE / 3.0 +_DEFAULT_GET_JOB_CONFLICT_RETRY = retry.Retry( + predicate=_should_retry_get_job_conflict, + deadline=_DEFAULT_GET_JOB_CONFLICT_DEADLINE, +) +"""Private, may be removed in future.""" + + +# Note: Take care when updating DEFAULT_TIMEOUT to anything but None. We +# briefly had a default timeout, but even setting it at more than twice the +# theoretical server-side default timeout of 2 minutes was not enough for +# complex queries. See: +# https://github.com/googleapis/python-bigquery/issues/970#issuecomment-921934647 +DEFAULT_TIMEOUT = None +"""The default API timeout. + +This is the time to wait per request. To adjust the total wait time, set a +deadline on the retry object. +""" + +job_retry_reasons = ( + "rateLimitExceeded", + "backendError", + "internalError", + "jobBackendError", + "jobInternalError", + "jobRateLimitExceeded", +) + + +def _job_should_retry(exc): + # Sometimes we have ambiguous errors, such as 'backendError' which could + # be due to an API problem or a job problem. For these, make sure we retry + # our is_job_done() function. + # + # Note: This won't restart the job unless we know for sure it's because of + # the job status and set restart_query_job = True in that loop. This means + # that we might end up calling this predicate twice for the same job + # but from different paths: (1) from jobs.getQueryResults RetryError and + # (2) from translating the job error from the body of a jobs.get response. + # + # Note: If we start retrying job types other than queries where we don't + # call the problematic getQueryResults API to check the status, we need + # to provide a different predicate, as there shouldn't be ambiguous + # errors in those cases. + if isinstance(exc, exceptions.RetryError): + exc = exc.cause + + # Per https://github.com/googleapis/python-bigquery/issues/1929, sometimes + # retriable errors make their way here. Because of the separate + # `restart_query_job` logic to make sure we aren't restarting non-failed + # jobs, it should be safe to continue and not totally fail our attempt at + # waiting for the query to complete. + if _should_retry(exc): + return True + + if not hasattr(exc, "errors") or len(exc.errors) == 0: + return False + + reason = exc.errors[0]["reason"] + return reason in job_retry_reasons + + +DEFAULT_JOB_RETRY = retry.Retry( + predicate=_job_should_retry, deadline=_DEFAULT_JOB_DEADLINE +) +""" +The default job retry object. +""" + + +DEFAULT_ML_JOB_RETRY = retry.Retry( + predicate=_job_should_retry, deadline=_HOUR_IN_SECONDS +) +""" +The default job retry object for AI/ML jobs. + +Such jobs can take a long time to fail. See: b/436586523. +""" + + +def _query_job_insert_should_retry(exc): + # Per https://github.com/googleapis/python-bigquery/issues/2134, sometimes + # we get a 404 error. In this case, if we get this far, assume that the job + # doesn't actually exist and try again. We can't add 404 to the default + # job_retry because that happens for errors like "this table does not + # exist", which probably won't resolve with a retry. + if isinstance(exc, exceptions.RetryError): + exc = exc.cause + + if isinstance(exc, exceptions.NotFound): + message = exc.message + # Don't try to retry table/dataset not found, just job not found. + # The URL contains jobs, so use whitespace to disambiguate. + return message is not None and " job" in message.lower() + + return _job_should_retry(exc) + + +_DEFAULT_QUERY_JOB_INSERT_RETRY = retry.Retry( + predicate=_query_job_insert_should_retry, + # jobs.insert doesn't wait for the job to complete, so we don't need the + # long _DEFAULT_JOB_DEADLINE for this part. + deadline=_DEFAULT_RETRY_DEADLINE, +) +"""Private, may be removed in future.""" + + +DEFAULT_GET_JOB_TIMEOUT = 128 +""" +Default timeout for Client.get_job(). +""" + +POLLING_DEFAULT_VALUE = google.api_core.future.polling.PollingFuture._DEFAULT_VALUE +""" +Default value defined in google.api_core.future.polling.PollingFuture. +""" diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/bigquery/__init__.py index 71e5d9e3df..a87cb081cb 100644 --- a/third_party/bigframes_vendored/ibis/backends/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/__init__.py @@ -28,6 +28,7 @@ from bigframes_vendored.ibis.backends.sql.compilers import BigQueryCompiler from bigframes_vendored.ibis.backends.sql.datatypes import BigQueryType import bigframes_vendored.ibis.common.exceptions as com +import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes import bigframes_vendored.ibis.expr.operations as ops import bigframes_vendored.ibis.expr.schema as sch import bigframes_vendored.ibis.expr.types as ir @@ -773,7 +774,7 @@ def execute(self, expr, params=None, limit="default", **kwargs): self._run_pre_execute_hooks(expr) schema = expr.as_table().schema() - bigframes_vendored.ibis.schema( - {"_TABLE_SUFFIX": "string"} + {"_TABLE_SUFFIX": ibis_dtypes.string()} ) sql = self.compile(expr, limit=limit, params=params, **kwargs) diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/datatypes.py b/third_party/bigframes_vendored/ibis/backends/bigquery/datatypes.py index 5b4e4d85a1..fba0339ae9 100644 --- a/third_party/bigframes_vendored/ibis/backends/bigquery/datatypes.py +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/datatypes.py @@ -53,6 +53,8 @@ def from_ibis(cls, dtype: dt.DataType) -> str: ) elif dtype.is_integer(): return "INT64" + elif dtype.is_boolean(): + return "BOOLEAN" elif dtype.is_binary(): return "BYTES" elif dtype.is_string(): diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py index d1ab36c41a..c01d87fb28 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py @@ -537,7 +537,7 @@ def if_(self, condition, true, false: sge.Expression | None = None) -> sge.If: false=None if false is None else sge.convert(false), ) - def cast(self, arg, to: dt.DataType) -> sge.Cast: + def cast(self, arg, to: dt.DataType, format=None) -> sge.Cast: return sge.Cast( this=sge.convert(arg), to=self.type_mapper.from_ibis(to), copy=False ) @@ -706,6 +706,10 @@ def visit_Literal(self, op, *, value, dtype): else return the result of the previous step. """ if value is None: + if dtype.is_array(): + # hack: bq arrays are like semi-nullable, but want to treat as non-nullable for simplicity + # instead, use empty array as missing value sentinel + return self.cast(self.f.array(), dtype) if dtype.nullable: return NULL if dtype.is_null() else self.cast(NULL, dtype) raise ibis_exceptions.UnsupportedOperationError( @@ -763,8 +767,9 @@ def visit_DefaultLiteral(self, op, *, value, dtype): elif dtype.is_date(): return self.f.datefromparts(value.year, value.month, value.day) elif dtype.is_array(): + # array type is ambiguous if no elements value_type = dtype.value_type - return self.f.array( + values = self.f.array( *( self.visit_Literal( ops.Literal(v, value_type), value=v, dtype=value_type @@ -772,6 +777,7 @@ def visit_DefaultLiteral(self, op, *, value, dtype): for v in value ) ) + return values if len(value) > 0 else self.cast(values, dtype) elif dtype.is_map(): key_type = dtype.key_type keys = self.f.array( @@ -804,11 +810,11 @@ def visit_DefaultLiteral(self, op, *, value, dtype): return sge.Struct.from_arg_list(items) elif dtype.is_uuid(): return self.cast(str(value), dtype) + elif dtype.is_json(): + return sge.JSON(this=sge.convert(str(value))) elif dtype.is_geospatial(): - args = [value.wkt] - if (srid := dtype.srid) is not None: - args.append(srid) - return self.f.st_geomfromtext(*args) + wkt = value if isinstance(value, str) else value.wkt + return self.f.st_geogfromtext(wkt) raise NotImplementedError(f"Unsupported type: {dtype!r}") @@ -1216,6 +1222,12 @@ def __sql_name__(self, op: ops.ScalarUDF | ops.AggUDF) -> str: # not actually a table, but easier to quote individual namespace # components this way namespace = op.__udf_namespace__ + + # Function names prefixed with "SAFE.", such as `SAFE.PARSE_JSON`, + # are typically not quoted. + if funcname.startswith("SAFE."): + return funcname + return sg.table(funcname, db=namespace.database, catalog=namespace.catalog).sql( self.dialect ) diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index 7d6cd6d2b4..95d28991a9 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations +import datetime import decimal import math import re @@ -260,6 +261,16 @@ def visit_BoundingBox(self, op, *, arg): visit_GeoXMax = visit_GeoXMin = visit_GeoYMax = visit_GeoYMin = visit_BoundingBox + def visit_GeoRegionStats(self, op, *, arg, raster_id, band, include, options): + args = [arg, raster_id] + if op.band: + args.append(sge.Kwarg(this="band", expression=band)) + if op.include: + args.append(sge.Kwarg(this="include", expression=include)) + if op.options: + args.append(sge.Kwarg(this="options", expression=options)) + return sge.func("ST_REGIONSTATS", *args) + def visit_GeoSimplify(self, op, *, arg, tolerance, preserve_collapsed): if ( not isinstance(op.preserve_collapsed, ops.Literal) @@ -478,6 +489,11 @@ def visit_NonNullLiteral(self, op, *, value, dtype): return sge.convert(str(value)) elif dtype.is_int64(): + # allows directly using values out of a duration arrow array + if isinstance(value, datetime.timedelta): + value = ( + (value.days * 3600 * 24) + value.seconds + ) * 1_000_000 + value.microseconds return sge.convert(np.int64(value)) return None @@ -538,7 +554,7 @@ def visit_Cast(self, op, *, arg, to): f"BigQuery does not allow extracting date part `{from_.unit}` from intervals" ) return self.f.extract(self.v[to.resolution.upper()], arg) - elif from_.is_floating() and to.is_integer(): + elif (from_.is_floating() or from_.is_decimal()) and to.is_integer(): return self.cast(self.f.trunc(arg), dt.int64) return super().visit_Cast(op, arg=arg, to=to) @@ -693,6 +709,9 @@ def visit_ArrayFilter(self, op, *, arg, body, param): def visit_ArrayMap(self, op, *, arg, body, param): return self.f.array(sg.select(body).from_(self._unnest(arg, as_=param))) + def visit_ArrayReduce(self, op, *, arg, body, param): + return sg.select(body).from_(self._unnest(arg, as_=param)).subquery() + def visit_ArrayZip(self, op, *, arg): lengths = [self.f.array_length(arr) - 1 for arr in arg] idx = sg.to_identifier(util.gen_name("bq_arr_idx")) @@ -1024,7 +1043,7 @@ def visit_InMemoryTable(self, op, *, name, schema, data): # Avoid creating temp tables for small data, which is how memtable is # used in BigQuery DataFrames. Inspired by: # https://github.com/ibis-project/ibis/blob/efa6fb72bf4c790450d00a926d7bd809dade5902/ibis/backends/druid/compiler.py#L95 - tuples = data.to_frame().itertuples(index=False) + rows = data.to_pyarrow(schema=None).to_pylist() # type: ignore quoted = self.quoted columns = [sg.column(col, quoted=quoted) for col in schema.names] array_expr = sge.DataType( @@ -1042,10 +1061,10 @@ def visit_InMemoryTable(self, op, *, name, schema, data): sge.Struct( expressions=tuple( self.visit_Literal(None, value=value, dtype=type_) - for value, type_ in zip(row, schema.types) + for value, type_ in zip(row.values(), schema.types) ) ) - for row in tuples + for row in rows ] expr = sge.Unnest( expressions=[ @@ -1061,7 +1080,6 @@ def visit_InMemoryTable(self, op, *, name, schema, data): columns=columns, ), ) - # return expr return sg.select(sge.Star()).from_(expr) def visit_ArrayAggregate(self, op, *, arg, order_by, where): @@ -1080,6 +1098,57 @@ def visit_ArrayAggregate(self, op, *, arg, order_by, where): expr = arg return sge.IgnoreNulls(this=self.agg.array_agg(expr, where=where)) + def visit_StringAgg(self, op, *, arg, sep, order_by, where): + if len(order_by) > 0: + expr = sge.Order( + this=arg, + expressions=[ + # Avoid adding NULLS FIRST / NULLS LAST in SQL, which is + # unsupported in ARRAY_AGG by reconstructing the node as + # plain SQL text. + f"({order_column.args['this'].sql(dialect='bigquery')}) {'DESC' if order_column.args.get('desc') else 'ASC'}" + for order_column in order_by + ], + ) + else: + expr = arg + return self.agg.string_agg(expr, sep, where=where) + + def visit_AIGenerate(self, op, **kwargs): + return sge.func("AI.GENERATE", *self._compile_ai_args(**kwargs)) + + def visit_AIGenerateBool(self, op, **kwargs): + return sge.func("AI.GENERATE_BOOL", *self._compile_ai_args(**kwargs)) + + def visit_AIGenerateInt(self, op, **kwargs): + return sge.func("AI.GENERATE_INT", *self._compile_ai_args(**kwargs)) + + def visit_AIGenerateDouble(self, op, **kwargs): + return sge.func("AI.GENERATE_DOUBLE", *self._compile_ai_args(**kwargs)) + + def visit_AIIf(self, op, **kwargs): + return sge.func("AI.IF", *self._compile_ai_args(**kwargs)) + + def visit_AIClassify(self, op, **kwargs): + return sge.func("AI.CLASSIFY", *self._compile_ai_args(**kwargs)) + + def visit_AIScore(self, op, **kwargs): + return sge.func("AI.SCORE", *self._compile_ai_args(**kwargs)) + + def _compile_ai_args(self, **kwargs): + args = [] + + for key, val in kwargs.items(): + if val is None: + continue + + if key == "model_params": + val = sge.JSON(this=val) + + args.append(sge.Kwarg(this=sge.Identifier(this=key), expression=val)) + + return args + def visit_FirstNonNullValue(self, op, *, arg): return sge.IgnoreNulls(this=sge.FirstValue(this=arg)) diff --git a/third_party/bigframes_vendored/ibis/common/temporal.py b/third_party/bigframes_vendored/ibis/common/temporal.py index 1b0e4fa985..8d84caf5a1 100644 --- a/third_party/bigframes_vendored/ibis/common/temporal.py +++ b/third_party/bigframes_vendored/ibis/common/temporal.py @@ -260,3 +260,8 @@ def _from_numpy_datetime64(value): raise TypeError("Unable to convert np.datetime64 without pandas") else: return pd.Timestamp(value).to_pydatetime() + + +@normalize_datetime.register("pyarrow.Scalar") +def _from_pyarrow_scalar(value): + return value.as_py() diff --git a/third_party/bigframes_vendored/ibis/expr/api.py b/third_party/bigframes_vendored/ibis/expr/api.py index 8427ab1c4b..fa09e23b75 100644 --- a/third_party/bigframes_vendored/ibis/expr/api.py +++ b/third_party/bigframes_vendored/ibis/expr/api.py @@ -1532,7 +1532,6 @@ def read_parquet( Examples -------- >>> import ibis - >>> import pandas as pd >>> ibis.options.interactive = True >>> df = pd.DataFrame({"a": [1, 2, 3], "b": list("ghi")}) >>> df @@ -1582,7 +1581,6 @@ def read_delta( Examples -------- >>> import ibis - >>> import pandas as pd >>> ibis.options.interactive = True >>> df = pd.DataFrame({"a": [1, 2, 3], "b": list("ghi")}) >>> df @@ -2369,7 +2367,7 @@ def ifelse(condition: Any, true_expr: Any, false_expr: Any) -> ir.Value: if not isinstance(condition, ir.Value): condition = literal(condition, type="bool") elif not condition.type().is_boolean(): - condition = condition.cast("bool") + condition = condition.cast(bool) return condition.ifelse(true_expr, false_expr) diff --git a/third_party/bigframes_vendored/ibis/expr/datatypes/__init__.py b/third_party/bigframes_vendored/ibis/expr/datatypes/__init__.py index e17050c865..2ff4d41ab5 100644 --- a/third_party/bigframes_vendored/ibis/expr/datatypes/__init__.py +++ b/third_party/bigframes_vendored/ibis/expr/datatypes/__init__.py @@ -4,7 +4,6 @@ from bigframes_vendored.ibis.expr.datatypes.cast import * # noqa: F403 from bigframes_vendored.ibis.expr.datatypes.core import * # noqa: F403 -from bigframes_vendored.ibis.expr.datatypes.parse import * # noqa: F403 from bigframes_vendored.ibis.expr.datatypes.value import * # noqa: F403 halffloat = float16 # noqa: F405 diff --git a/third_party/bigframes_vendored/ibis/expr/datatypes/core.py b/third_party/bigframes_vendored/ibis/expr/datatypes/core.py index 73dd375563..4bacebd6d7 100644 --- a/third_party/bigframes_vendored/ibis/expr/datatypes/core.py +++ b/third_party/bigframes_vendored/ibis/expr/datatypes/core.py @@ -62,7 +62,6 @@ def dtype(value: Any, nullable: bool = True) -> DataType: Or other type systems, like numpy/pandas/pyarrow types: - >>> import pyarrow as pa >>> ibis.dtype(pa.int32()) Int32(nullable=True) @@ -167,15 +166,6 @@ def castable(self, to, **kwargs) -> bool: return castable(self, to, **kwargs) - @classmethod - def from_string(cls, value) -> Self: - from bigframes_vendored.ibis.expr.datatypes.parse import parse - - try: - return parse(value) - except SyntaxError: - raise TypeError(f"{value!r} cannot be parsed as a datatype") - @classmethod def from_typehint(cls, typ, nullable=True) -> Self: origin_type = get_origin(typ) diff --git a/third_party/bigframes_vendored/ibis/expr/datatypes/parse.py b/third_party/bigframes_vendored/ibis/expr/datatypes/parse.py deleted file mode 100644 index 78bbe0347c..0000000000 --- a/third_party/bigframes_vendored/ibis/expr/datatypes/parse.py +++ /dev/null @@ -1,211 +0,0 @@ -# Contains code from https://github.com/ibis-project/ibis/blob/9.2.0/ibis/expr/datatypes/parse.py - -from __future__ import annotations - -import ast -import functools -from operator import methodcaller -import re - -import bigframes_vendored.ibis.expr.datatypes.core as dt -import parsy -from public import public - -_STRING_REGEX = ( - """('[^\n'\\\\]*(?:\\\\.[^\n'\\\\]*)*'|"[^\n"\\\\"]*(?:\\\\.[^\n"\\\\]*)*")""" -) - -SPACES = parsy.regex(r"\s*", re.MULTILINE) - - -def spaceless(parser): - return SPACES.then(parser).skip(SPACES) - - -def spaceless_string(*strings: str): - return spaceless( - parsy.alt(*(parsy.string(string, transform=str.lower) for string in strings)) - ) - - -SINGLE_DIGIT = parsy.decimal_digit -RAW_NUMBER = SINGLE_DIGIT.at_least(1).concat() -PRECISION = SCALE = NUMBER = LENGTH = RAW_NUMBER.map(int) -TEMPORAL_SCALE = SINGLE_DIGIT.map(int) - -LPAREN = spaceless_string("(") -RPAREN = spaceless_string(")") - -LBRACKET = spaceless_string("[") -RBRACKET = spaceless_string("]") - -LANGLE = spaceless_string("<") -RANGLE = spaceless_string(">") - -COMMA = spaceless_string(",") -COLON = spaceless_string(":") -SEMICOLON = spaceless_string(";") - -RAW_STRING = parsy.regex(_STRING_REGEX).map(ast.literal_eval) -FIELD = parsy.regex("[a-zA-Z_0-9]+") | parsy.string("") - - -@public -@functools.lru_cache(maxsize=100) -def parse( - text: str, default_decimal_parameters: tuple[int | None, int | None] = (None, None) -) -> dt.DataType: - """Parse a type from a [](`str`) `text`. - - The default `maxsize` parameter for caching is chosen to cache the most - commonly used types--there are about 30--along with some capacity for less - common but repeatedly-used complex types. - - Parameters - ---------- - text - The type string to parse - default_decimal_parameters - Default precision and scale for decimal types - - Examples - -------- - Parse an array type from a string - - >>> import ibis - >>> import ibis.expr.datatypes as dt - >>> dt.parse("array") - Array(value_type=Int64(nullable=True), nullable=True) - - You can avoid parsing altogether by constructing objects directly - - >>> import ibis - >>> import ibis.expr.datatypes as dt - >>> ty = dt.parse("array") - >>> ty == dt.Array(dt.int64) - True - - """ - geotype = spaceless_string("geography", "geometry") - - srid_geotype = SEMICOLON.then(parsy.seq(srid=NUMBER.skip(COLON), geotype=geotype)) - geotype_part = COLON.then(parsy.seq(geotype=geotype)) - srid_part = SEMICOLON.then(parsy.seq(srid=NUMBER)) - - def geotype_parser(typ: type[dt.DataType]) -> dt.DataType: - return spaceless_string(typ.__name__.lower()).then( - (srid_geotype | geotype_part | srid_part).optional(dict()).combine_dict(typ) - ) - - primitive = ( - spaceless_string("boolean", "bool").result(dt.boolean) - | spaceless_string("halffloat", "float16").result(dt.float16) - | spaceless_string("float32").result(dt.float32) - | spaceless_string("double", "float64", "float").result(dt.float64) - | spaceless_string( - "int8", - "int16", - "int32", - "int64", - "uint8", - "uint16", - "uint32", - "uint64", - "string", - "binary", - "timestamp", - "time", - "date", - "null", - ).map(functools.partial(getattr, dt)) - | spaceless_string("bytes").result(dt.binary) - | geotype.map(dt.GeoSpatial) - | geotype_parser(dt.LineString) - | geotype_parser(dt.Polygon) - | geotype_parser(dt.Point) - | geotype_parser(dt.MultiLineString) - | geotype_parser(dt.MultiPolygon) - | geotype_parser(dt.MultiPoint) - ) - - varchar_or_char = ( - spaceless_string("varchar", "char") - .then(LPAREN.then(RAW_NUMBER).skip(RPAREN).optional()) - .result(dt.string) - ) - - decimal = spaceless_string("decimal").then( - parsy.seq( - LPAREN.then(spaceless(PRECISION)).skip(COMMA), spaceless(SCALE).skip(RPAREN) - ) - .optional(default_decimal_parameters) - .combine(dt.Decimal) - ) - - bignumeric = spaceless_string("bignumeric", "bigdecimal").then( - parsy.seq( - LPAREN.then(spaceless(PRECISION)).skip(COMMA), spaceless(SCALE).skip(RPAREN) - ) - .optional((76, 38)) - .combine(dt.Decimal) - ) - - parened_string = LPAREN.then(RAW_STRING).skip(RPAREN) - timestamp_scale = SINGLE_DIGIT.map(int) - - timestamp_tz_args = LPAREN.then( - parsy.seq(timezone=RAW_STRING, scale=COMMA.then(timestamp_scale).optional()) - ).skip(RPAREN) - - timestamp_no_tz_args = LPAREN.then(parsy.seq(scale=timestamp_scale).skip(RPAREN)) - - timestamp = spaceless_string("timestamp").then( - (timestamp_tz_args | timestamp_no_tz_args) - .optional({}) - .combine_dict(dt.Timestamp) - ) - - interval = spaceless_string("interval").then( - parsy.seq(unit=parened_string.optional("s")).combine_dict(dt.Interval) - ) - - ty = parsy.forward_declaration() - angle_type = LANGLE.then(ty).skip(RANGLE) - array = spaceless_string("array").then(angle_type).map(dt.Array) - - map = ( - spaceless_string("map") - .then(LANGLE) - .then(parsy.seq(ty, COMMA.then(ty)).combine(dt.Map)) - .skip(RANGLE) - ) - - struct = ( - spaceless_string("struct") - .then(LANGLE) - .then(parsy.seq(spaceless(FIELD).skip(COLON), ty).sep_by(COMMA)) - .skip(RANGLE) - .map(dt.Struct.from_tuples) - ) - - nullable = spaceless_string("!").then(ty).map(methodcaller("copy", nullable=False)) - - ty.become( - nullable - | timestamp - | primitive - | decimal - | bignumeric - | varchar_or_char - | interval - | array - | map - | struct - | spaceless_string("jsonb", "json", "uuid", "macaddr", "inet").map( - functools.partial(getattr, dt) - ) - | spaceless_string("int").result(dt.int64) - | spaceless_string("str").result(dt.string) - ) - - return ty.parse(text) diff --git a/third_party/bigframes_vendored/ibis/expr/datatypes/value.py b/third_party/bigframes_vendored/ibis/expr/datatypes/value.py index f9302b63f4..85be0ac749 100644 --- a/third_party/bigframes_vendored/ibis/expr/datatypes/value.py +++ b/third_party/bigframes_vendored/ibis/expr/datatypes/value.py @@ -27,6 +27,7 @@ import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.datatypes.cast import highest_precedence from public import public +import pyarrow as pa import toolz @@ -71,6 +72,14 @@ def infer_list(values: Sequence[Any]) -> dt.Array: return dt.Array(highest_precedence(map(infer, values))) +@infer.register("pyarrow.Scalar") +def infer_pyarrow_scalar(value: "pa.Scalar"): + """Infert the type of a PyArrow Scalar value.""" + import bigframes_vendored.ibis.formats.pyarrow + + return bigframes_vendored.ibis.formats.pyarrow.PyArrowType.to_ibis(value.type) + + @infer.register(datetime.time) def infer_time(value: datetime.time) -> dt.Time: return dt.time @@ -253,6 +262,9 @@ def infer_shapely_multipolygon(value) -> dt.MultiPolygon: def normalize(typ, value): """Ensure that the Python type underlying a literal resolves to a single type.""" + if pa is not None and isinstance(value, pa.Scalar): + value = value.as_py() + dtype = dt.dtype(typ) if value is None: if not dtype.nullable: @@ -312,15 +324,16 @@ def normalize(typ, value): ) return frozendict({k: normalize(t, value[k]) for k, t in dtype.items()}) elif dtype.is_geospatial(): - import shapely as shp + import shapely + import shapely.geometry if isinstance(value, (tuple, list)): if dtype.is_point(): - return shp.Point(value) + return shapely.geometry.Point(value) elif dtype.is_linestring(): - return shp.LineString(value) + return shapely.geometry.LineString(value) elif dtype.is_polygon(): - return shp.Polygon( + return shapely.geometry.Polygon( toolz.concat( map( attrgetter("coords"), @@ -329,19 +342,23 @@ def normalize(typ, value): ) ) elif dtype.is_multipoint(): - return shp.MultiPoint(tuple(map(partial(normalize, dt.point), value))) + return shapely.geometry.MultiPoint( + tuple(map(partial(normalize, dt.point), value)) + ) elif dtype.is_multilinestring(): - return shp.MultiLineString( + return shapely.geometry.MultiLineString( tuple(map(partial(normalize, dt.linestring), value)) ) elif dtype.is_multipolygon(): - return shp.MultiPolygon(map(partial(normalize, dt.polygon), value)) + return shapely.geometry.MultiPolygon( + map(partial(normalize, dt.polygon), value) + ) else: raise IbisTypeError(f"Unsupported geospatial type: {dtype}") - elif isinstance(value, shp.geometry.base.BaseGeometry): + elif isinstance(value, shapely.geometry.base.BaseGeometry): return value else: - return shp.from_wkt(value) + return shapely.from_wkt(value) elif dtype.is_date(): return normalize_datetime(value).date() elif dtype.is_time(): diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py new file mode 100644 index 0000000000..ef387d3379 --- /dev/null +++ b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py @@ -0,0 +1,151 @@ +# Contains code from https://github.com/ibis-project/ibis/blob/9.2.0/ibis/expr/operations/maps.py + +"""Operations for working with AI operators.""" + +from __future__ import annotations + +from typing import Optional + +from bigframes_vendored.ibis.common.annotations import attribute +import bigframes_vendored.ibis.expr.datatypes as dt +from bigframes_vendored.ibis.expr.operations.core import Value +import bigframes_vendored.ibis.expr.rules as rlz +from public import public +import pyarrow as pa + +from bigframes.operations import output_schemas + + +@public +class AIGenerate(Value): + """Generate content based on the prompt""" + + prompt: Value + connection_id: Optional[Value[dt.String]] + endpoint: Optional[Value[dt.String]] + request_type: Value[dt.String] + model_params: Optional[Value[dt.String]] + output_schema: Optional[Value[dt.String]] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + if self.output_schema is None: + output_pa_fields = (pa.field("result", pa.string()),) + else: + output_pa_fields = output_schemas.parse_sql_fields(self.output_schema.value) + + pyarrow_output_type = pa.struct( + ( + *output_pa_fields, + pa.field("full_resposne", pa.string()), + pa.field("status", pa.string()), + ) + ) + + return dt.Struct.from_pyarrow(pyarrow_output_type) + + +@public +class AIGenerateBool(Value): + """Generate Bool based on the prompt""" + + prompt: Value + connection_id: Optional[Value[dt.String]] + endpoint: Optional[Value[dt.String]] + request_type: Value[dt.String] + model_params: Optional[Value[dt.String]] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.Struct.from_tuples( + (("result", dt.bool), ("full_resposne", dt.string), ("status", dt.string)) + ) + + +@public +class AIGenerateInt(Value): + """Generate integers based on the prompt""" + + prompt: Value + connection_id: Optional[Value[dt.String]] + endpoint: Optional[Value[dt.String]] + request_type: Value[dt.String] + model_params: Optional[Value[dt.String]] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.Struct.from_tuples( + (("result", dt.int64), ("full_resposne", dt.string), ("status", dt.string)) + ) + + +@public +class AIGenerateDouble(Value): + """Generate doubles based on the prompt""" + + prompt: Value + connection_id: Optional[Value[dt.String]] + endpoint: Optional[Value[dt.String]] + request_type: Value[dt.String] + model_params: Optional[Value[dt.String]] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.Struct.from_tuples( + ( + ("result", dt.float64), + ("full_resposne", dt.string), + ("status", dt.string), + ) + ) + + +@public +class AIIf(Value): + """Generate True/False based on the prompt""" + + prompt: Value + connection_id: Value[dt.String] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.bool + + +@public +class AIClassify(Value): + """Generate True/False based on the prompt""" + + input: Value + categories: Value[dt.Array[dt.String]] + connection_id: Value[dt.String] + + shape = rlz.shape_like("input") + + @attribute + def dtype(self) -> dt.Struct: + return dt.string + + +@public +class AIScore(Value): + """Generate doubles based on the prompt""" + + prompt: Value + connection_id: Value[dt.String] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.float64 diff --git a/third_party/bigframes_vendored/ibis/expr/operations/arrays.py b/third_party/bigframes_vendored/ibis/expr/operations/arrays.py index 638b24a212..8134506255 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/arrays.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/arrays.py @@ -105,6 +105,21 @@ def dtype(self) -> dt.DataType: return dt.Array(self.body.dtype) +@public +class ArrayReduce(Value): + """Apply a function to every element of an array.""" + + arg: Value[dt.Array] + body: Value + param: str + + shape = rlz.shape_like("arg") + + @attribute + def dtype(self) -> dt.DataType: + return self.body.dtype + + @public class ArrayFilter(Value): """Filter array elements with a function.""" diff --git a/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py b/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py index 0be832af78..efe038599a 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py @@ -343,6 +343,28 @@ class GeoNRings(GeoSpatialUnOp): dtype = dt.int64 +@public +class GeoRegionStats(GeoSpatialUnOp): + """Returns results of ST_REGIONSTATS.""" + + raster_id: Value[dt.String] + band: Value[dt.String] + include: Value[dt.String] + options: Value[dt.JSON] + + dtype = dt.Struct( + fields={ + "count": dt.int64, + "min": dt.float64, + "max": dt.float64, + "stdDev": dt.float64, + "sum": dt.float64, + "mean": dt.float64, + "area": dt.float64, + } + ) + + @public class GeoSRID(GeoSpatialUnOp): """Returns the spatial reference identifier for the ST_Geometry.""" diff --git a/third_party/bigframes_vendored/ibis/expr/operations/numeric.py b/third_party/bigframes_vendored/ibis/expr/operations/numeric.py index 174de5ab7f..384323c596 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/numeric.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/numeric.py @@ -326,7 +326,7 @@ class Tan(TrigonometricUnary): class BitwiseNot(Unary): """Bitwise NOT operation.""" - arg: Integer + arg: Value[dt.Integer | dt.Binary] dtype = rlz.numeric_like("args", operator.invert) diff --git a/third_party/bigframes_vendored/ibis/expr/operations/reductions.py b/third_party/bigframes_vendored/ibis/expr/operations/reductions.py index 34f6406e0c..c3f2a03223 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/reductions.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/reductions.py @@ -401,3 +401,20 @@ class ArrayAggregate(Filterable, Reduction): @attribute def dtype(self): return dt.Array(self.arg.dtype) + + +@public +class StringAgg(Filterable, Reduction): + """ + Collects the elements of this expression into a string. Similar to + the ibis `GroupConcat`, but adds `order_by_*` parameter. + """ + + arg: Column + sep: Value[dt.String] + + order_by: VarTuple[Value] = () + + @attribute + def dtype(self): + return dt.string diff --git a/third_party/bigframes_vendored/ibis/expr/operations/strings.py b/third_party/bigframes_vendored/ibis/expr/operations/strings.py index ffd93e6fdd..050c079b6b 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/strings.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/strings.py @@ -364,14 +364,14 @@ class ExtractFragment(ExtractURLField): class StringLength(StringUnary): """Compute the length of a string.""" - dtype = dt.int32 + dtype = dt.int64 @public class StringAscii(StringUnary): """Compute the ASCII code of the first character of a string.""" - dtype = dt.int32 + dtype = dt.int64 @public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/temporal.py b/third_party/bigframes_vendored/ibis/expr/operations/temporal.py index 75a0ef9efc..b527e14f04 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/temporal.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/temporal.py @@ -105,7 +105,7 @@ class ExtractTemporalField(Unary): """Extract a field from a temporal value.""" arg: Value[dt.Temporal] - dtype = dt.int32 + dtype = dt.int64 @public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/udf.py b/third_party/bigframes_vendored/ibis/expr/operations/udf.py index 4fb25a9d34..91366cace8 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/udf.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/udf.py @@ -109,6 +109,7 @@ def _make_node( database: str | None = None, catalog: str | None = None, signature: tuple[tuple, Any] | None = None, + param_name_overrides: tuple[str, ...] | None = None, **kwargs, ) -> type[S]: """Construct a scalar user-defined function that is built-in to the backend.""" @@ -133,7 +134,7 @@ def _make_node( else: arg_types, return_annotation = signature - arg_names = list(inspect.signature(fn).parameters) + arg_names = param_name_overrides or list(inspect.signature(fn).parameters) fields = { arg_name: Argument(pattern=rlz.ValueOf(typ), typehint=typ) for arg_name, typ in zip(arg_names, arg_types) diff --git a/third_party/bigframes_vendored/ibis/expr/rewrites.py b/third_party/bigframes_vendored/ibis/expr/rewrites.py index a85498b30b..779a5081ca 100644 --- a/third_party/bigframes_vendored/ibis/expr/rewrites.py +++ b/third_party/bigframes_vendored/ibis/expr/rewrites.py @@ -206,21 +206,26 @@ def replace_parameter(_, params, **kwargs): @replace(p.StringSlice) def lower_stringslice(_, **kwargs): """Rewrite StringSlice in terms of Substring.""" - if _.end is None: - return ops.Substring(_.arg, start=_.start) if _.start is None: - return ops.Substring(_.arg, start=0, length=_.end) - if ( - isinstance(_.start, ops.Literal) - and isinstance(_.start.value, int) - and isinstance(_.end, ops.Literal) - and isinstance(_.end.value, int) - ): - # optimization for constant values - length = _.end.value - _.start.value + real_start = 0 + else: + real_start = ops.IfElse( + ops.GreaterEqual(_.start, 0), + _.start, + ops.Greatest((0, ops.Add(ops.StringLength(_.arg), _.start))), + ) + + if _.end is None: + real_end = ops.StringLength(_.arg) else: - length = ops.Subtract(_.end, _.start) - return ops.Substring(_.arg, start=_.start, length=length) + real_end = ops.IfElse( + ops.GreaterEqual(_.end, 0), + _.end, + ops.Greatest((0, ops.Add(ops.StringLength(_.arg), _.end))), + ) + + length = ops.Greatest((0, ops.Subtract(real_end, real_start))) + return ops.Substring(_.arg, start=real_start, length=length) @replace(p.Analytic) @@ -252,7 +257,7 @@ def rewrite_project_input(value, relation): # relation return value.replace( project_wrap_analytic | project_wrap_reduction, - filter=p.Value & ~p.WindowFunction, + filter=p.Value & ~p.WindowFunction & ~p.ArrayReduce, context={"rel": relation}, ) diff --git a/third_party/bigframes_vendored/ibis/expr/types/arrays.py b/third_party/bigframes_vendored/ibis/expr/types/arrays.py index 5f86cfe477..47ae997738 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/arrays.py +++ b/third_party/bigframes_vendored/ibis/expr/types/arrays.py @@ -416,7 +416,7 @@ def map(self, func: Deferred | Callable[[ir.Value], ir.Value]) -> ir.ArrayValue: The most succinct way to use `map` is with `Deferred` expressions: - >>> t.a.map((_ + 100).cast("float")) + >>> t.a.map((_ + 100).cast(float)) ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ArrayMap(a, Cast(Add(_, 100), float64)) ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ @@ -429,7 +429,7 @@ def map(self, func: Deferred | Callable[[ir.Value], ir.Value]) -> ir.ArrayValue: You can also use `map` with a lambda function: - >>> t.a.map(lambda x: (x + 100).cast("float")) + >>> t.a.map(lambda x: (x + 100).cast(float)) ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ArrayMap(a, Cast(Add(x, 100), float64)) ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ @@ -486,6 +486,24 @@ def map(self, func: Deferred | Callable[[ir.Value], ir.Value]) -> ir.ArrayValue: body = resolve(parameter.to_expr()) return ops.ArrayMap(self, param=parameter.param, body=body).to_expr() + def reduce(self, func: Deferred | Callable[[ir.Value], ir.Value]) -> ir.ArrayValue: + if isinstance(func, Deferred): + name = "_" + resolve = func.resolve + elif callable(func): + name = next(iter(inspect.signature(func).parameters.keys())) + resolve = func + else: + raise TypeError( + f"`func` must be a Deferred or Callable, got `{type(func).__name__}`" + ) + + parameter = ops.Argument( + name=name, shape=self.op().shape, dtype=self.type().value_type + ) + body = resolve(parameter.to_expr()) + return ops.ArrayReduce(self, param=parameter.param, body=body).to_expr() + def filter( self, predicate: Deferred | Callable[[ir.Value], bool | ir.BooleanValue] ) -> ir.ArrayValue: @@ -990,7 +1008,6 @@ def flatten(self) -> ir.ArrayValue: ... "nulls_only": [None, None, None], ... "mixed_nulls": [[], None, [None]], ... } - >>> import pyarrow as pa >>> t = ibis.memtable( ... pa.Table.from_pydict( ... data, diff --git a/third_party/bigframes_vendored/ibis/expr/types/binary.py b/third_party/bigframes_vendored/ibis/expr/types/binary.py index ba6140a49f..08fea31a1c 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/binary.py +++ b/third_party/bigframes_vendored/ibis/expr/types/binary.py @@ -32,6 +32,9 @@ def hashbytes( """ return ops.HashBytes(self, how).to_expr() + def __invert__(self) -> BinaryValue: + return ops.BitwiseNot(self).to_expr() + @public class BinaryScalar(Scalar, BinaryValue): diff --git a/third_party/bigframes_vendored/ibis/expr/types/core.py b/third_party/bigframes_vendored/ibis/expr/types/core.py index 9685e4ddca..5704dc993a 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/core.py +++ b/third_party/bigframes_vendored/ibis/expr/types/core.py @@ -19,6 +19,7 @@ import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.types.pretty import to_rich from bigframes_vendored.ibis.util import experimental +import pandas as pd from public import public from rich.console import Console from rich.jupyter import JupyterMixin @@ -34,7 +35,6 @@ EdgeAttributeGetter, NodeAttributeGetter, ) - import pandas as pd import polars as pl import pyarrow as pa import torch @@ -744,9 +744,9 @@ def _binop(op_class: type[ops.Binary], left: ir.Value, right: ir.Value) -> ir.Va def _is_null_literal(value: Any) -> bool: """Detect whether `value` will be treated by ibis as a null literal.""" - if value is None: - return True if isinstance(value, Expr): op = value.op() return isinstance(op, ops.Literal) and op.value is None + if pd.isna(value): + return True return False diff --git a/third_party/bigframes_vendored/ibis/expr/types/generic.py b/third_party/bigframes_vendored/ibis/expr/types/generic.py index 607170e1ca..596d3134f6 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/generic.py +++ b/third_party/bigframes_vendored/ibis/expr/types/generic.py @@ -179,31 +179,10 @@ def cast(self, target_type: Any) -> Value: │ … │ └────────────────────────────┘ - or string names - - >>> x.cast("uint16") - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ Cast(bill_depth_mm, uint16) ┃ - ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ - │ uint16 │ - ├─────────────────────────────┤ - │ 19 │ - │ 17 │ - │ 18 │ - │ NULL │ - │ 19 │ - │ 21 │ - │ 18 │ - │ 20 │ - │ 18 │ - │ 20 │ - │ … │ - └─────────────────────────────┘ - If you make an illegal cast, you won't know until the backend actually executes it. Consider [`.try_cast()`](#ibis.expr.types.generic.Value.try_cast). - >>> ibis.literal("a string").cast("int64") # doctest: +SKIP + >>> ibis.literal("a string").cast(int) # doctest: +SKIP """ op = ops.Cast(self, to=target_type) @@ -794,7 +773,9 @@ def over( @deferrable def bind(table): - winfunc = rewrite_window_input(node, window.bind(table)) + winfunc = rewrite_window_input( + node, window.bind(table) if (table is not None) else window + ) if winfunc == node: raise com.IbisTypeError( "No reduction or analytic function found to construct a window expression" diff --git a/third_party/bigframes_vendored/ibis/expr/types/geospatial.py b/third_party/bigframes_vendored/ibis/expr/types/geospatial.py index 3f42a4ad14..298e74d6de 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/geospatial.py +++ b/third_party/bigframes_vendored/ibis/expr/types/geospatial.py @@ -135,7 +135,7 @@ def contains(self, right: GeoSpatialValue) -> ir.BooleanValue: >>> ibis.options.interactive = True >>> import shapely >>> t = ibis.examples.zones.fetch() - >>> p = shapely.Point(935996.821, 191376.75) # centroid for zone 1 + >>> p = shapely.geometry.Point(935996.821, 191376.75) # centroid for zone 1 >>> plit = ibis.literal(p, "geometry") >>> t.geom.contains(plit).name("contains") ┏━━━━━━━━━━┓ @@ -197,7 +197,7 @@ def covers(self, right: GeoSpatialValue) -> ir.BooleanValue: Polygon area center in zone 1 - >>> z1_ctr_buff = shapely.Point(935996.821, 191376.75).buffer(10) + >>> z1_ctr_buff = shapely.geometry.Point(935996.821, 191376.75).buffer(10) >>> z1_ctr_buff_lit = ibis.literal(z1_ctr_buff, "geometry") >>> t.geom.covers(z1_ctr_buff_lit).name("covers") ┏━━━━━━━━━┓ @@ -242,7 +242,7 @@ def covered_by(self, right: GeoSpatialValue) -> ir.BooleanValue: Polygon area center in zone 1 - >>> pol_big = shapely.Point(935996.821, 191376.75).buffer(10000) + >>> pol_big = shapely.geometry.Point(935996.821, 191376.75).buffer(10000) >>> pol_big_lit = ibis.literal(pol_big, "geometry") >>> t.geom.covered_by(pol_big_lit).name("covered_by") ┏━━━━━━━━━━━━┓ @@ -262,7 +262,7 @@ def covered_by(self, right: GeoSpatialValue) -> ir.BooleanValue: │ False │ │ … │ └────────────┘ - >>> pol_small = shapely.Point(935996.821, 191376.75).buffer(100) + >>> pol_small = shapely.geometry.Point(935996.821, 191376.75).buffer(100) >>> pol_small_lit = ibis.literal(pol_small, "geometry") >>> t.geom.covered_by(pol_small_lit).name("covered_by") ┏━━━━━━━━━━━━┓ @@ -387,7 +387,7 @@ def disjoint(self, right: GeoSpatialValue) -> ir.BooleanValue: >>> ibis.options.interactive = True >>> import shapely >>> t = ibis.examples.zones.fetch() - >>> p = shapely.Point(935996.821, 191376.75) # zone 1 centroid + >>> p = shapely.geometry.Point(935996.821, 191376.75) # zone 1 centroid >>> plit = ibis.literal(p, "geometry") >>> t.geom.disjoint(plit).name("disjoint") ┏━━━━━━━━━━┓ @@ -435,7 +435,7 @@ def d_within( >>> ibis.options.interactive = True >>> import shapely >>> t = ibis.examples.zones.fetch() - >>> penn_station = shapely.Point(986345.399, 211974.446) + >>> penn_station = shapely.geometry.Point(986345.399, 211974.446) >>> penn_lit = ibis.literal(penn_station, "geometry") Check zones within 1000ft of Penn Station centroid @@ -578,7 +578,7 @@ def intersects(self, right: GeoSpatialValue) -> ir.BooleanValue: >>> ibis.options.interactive = True >>> import shapely >>> t = ibis.examples.zones.fetch() - >>> p = shapely.Point(935996.821, 191376.75) # zone 1 centroid + >>> p = shapely.geometry.Point(935996.821, 191376.75) # zone 1 centroid >>> plit = ibis.literal(p, "geometry") >>> t.geom.intersects(plit).name("intersects") ┏━━━━━━━━━━━━┓ @@ -675,7 +675,7 @@ def overlaps(self, right: GeoSpatialValue) -> ir.BooleanValue: Polygon center in an edge point of zone 1 - >>> p_edge_buffer = shapely.Point(933100.918, 192536.086).buffer(100) + >>> p_edge_buffer = shapely.geometry.Point(933100.918, 192536.086).buffer(100) >>> buff_lit = ibis.literal(p_edge_buffer, "geometry") >>> t.geom.overlaps(buff_lit).name("overlaps") ┏━━━━━━━━━━┓ @@ -720,7 +720,7 @@ def touches(self, right: GeoSpatialValue) -> ir.BooleanValue: Edge point of zone 1 - >>> p_edge = shapely.Point(933100.9183527103, 192536.08569720192) + >>> p_edge = shapely.geometry.Point(933100.9183527103, 192536.08569720192) >>> p_edge_lit = ibis.literal(p_edge, "geometry") >>> t.geom.touches(p_edge_lit).name("touches") ┏━━━━━━━━━┓ @@ -765,7 +765,7 @@ def distance(self, right: GeoSpatialValue) -> ir.FloatingValue: Penn station zone centroid - >>> penn_station = shapely.Point(986345.399, 211974.446) + >>> penn_station = shapely.geometry.Point(986345.399, 211974.446) >>> penn_lit = ibis.literal(penn_station, "geometry") >>> t.geom.distance(penn_lit).name("distance_penn") ┏━━━━━━━━━━━━━━━┓ @@ -886,7 +886,7 @@ def union(self, right: GeoSpatialValue) -> GeoSpatialValue: Penn station zone centroid - >>> penn_station = shapely.Point(986345.399, 211974.446) + >>> penn_station = shapely.geometry.Point(986345.399, 211974.446) >>> penn_lit = ibis.literal(penn_station, "geometry") >>> t.geom.centroid().union(penn_lit).name("union_centroid_penn") ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -1312,7 +1312,7 @@ def within(self, right: GeoSpatialValue) -> ir.BooleanValue: >>> ibis.options.interactive = True >>> import shapely >>> t = ibis.examples.zones.fetch() - >>> penn_station_buff = shapely.Point(986345.399, 211974.446).buffer(5000) + >>> penn_station_buff = shapely.geometry.Point(986345.399, 211974.446).buffer(5000) >>> penn_lit = ibis.literal(penn_station_buff, "geometry") >>> t.filter(t.geom.within(penn_lit))["zone"] ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ diff --git a/third_party/bigframes_vendored/ibis/expr/types/json.py b/third_party/bigframes_vendored/ibis/expr/types/json.py index 388b4d8742..51d1642de0 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/json.py +++ b/third_party/bigframes_vendored/ibis/expr/types/json.py @@ -446,24 +446,6 @@ def str(self) -> ir.StringValue: │ NULL │ └──────────────────────┘ - Note the difference between `.string` and `.cast("string")`. - - The latter preserves quotes for JSON string values and returns a valid - JSON string. - - >>> t.js.cast("string") - ┏━━━━━━━━━━━━━━━━━━┓ - ┃ Cast(js, string) ┃ - ┡━━━━━━━━━━━━━━━━━━┩ - │ string │ - ├──────────────────┤ - │ "a" │ - │ "b" │ - │ 1 │ - │ {} │ - │ [{"a": 1}] │ - └──────────────────┘ - Here's a more complex example with a table containing a JSON column with nested fields. diff --git a/third_party/bigframes_vendored/ibis/expr/types/logical.py b/third_party/bigframes_vendored/ibis/expr/types/logical.py index 80a8527a04..cc86c747f6 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/logical.py +++ b/third_party/bigframes_vendored/ibis/expr/types/logical.py @@ -353,6 +353,9 @@ def resolve_exists_subquery(outer): return Deferred(Call(resolve_exists_subquery, _)) elif len(parents) == 1: op = ops.Any(self, where=self._bind_to_parent_table(where)) + elif len(parents) == 0: + # array reduction case + op = ops.Any(self, where=self._bind_to_parent_table(where)) else: raise NotImplementedError( f'Cannot compute "any" for expression of type {type(self)} ' diff --git a/third_party/bigframes_vendored/ibis/expr/types/maps.py b/third_party/bigframes_vendored/ibis/expr/types/maps.py index 881f8327d0..65237decc7 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/maps.py +++ b/third_party/bigframes_vendored/ibis/expr/types/maps.py @@ -35,7 +35,6 @@ class MapValue(Value): -------- >>> import ibis >>> ibis.options.interactive = True - >>> import pyarrow as pa >>> tab = pa.table( ... { ... "m": pa.array( @@ -101,7 +100,6 @@ def get(self, key: ir.Value, default: ir.Value | None = None) -> ir.Value: Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -167,7 +165,6 @@ def length(self) -> ir.IntegerValue: Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -224,7 +221,6 @@ def __getitem__(self, key: ir.Value) -> ir.Value: Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -276,7 +272,6 @@ def contains( Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -321,7 +316,6 @@ def keys(self) -> ir.ArrayValue: Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { diff --git a/third_party/bigframes_vendored/ibis/expr/types/relations.py b/third_party/bigframes_vendored/ibis/expr/types/relations.py index 919dec0669..d3d66b1512 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/relations.py +++ b/third_party/bigframes_vendored/ibis/expr/types/relations.py @@ -3798,7 +3798,7 @@ def pivot_longer( ... names_pattern=r"wk(.+)", ... names_transform=int, ... values_to="rank", - ... values_transform=_.cast("int"), + ... values_transform=_.cast(int), ... ).drop_null("rank") ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━━┓ ┃ artist ┃ track ┃ date_entered ┃ week ┃ rank ┃ diff --git a/third_party/bigframes_vendored/ibis/expr/types/strings.py b/third_party/bigframes_vendored/ibis/expr/types/strings.py index 85b455e66e..f63cf96e72 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/strings.py +++ b/third_party/bigframes_vendored/ibis/expr/types/strings.py @@ -96,15 +96,6 @@ def __getitem__(self, key: slice | int | ir.IntegerScalar) -> StringValue: if isinstance(step, ir.Expr) or (step is not None and step != 1): raise ValueError("Step can only be 1") - if start is not None and not isinstance(start, ir.Expr) and start < 0: - raise ValueError( - "Negative slicing not yet supported, got start value " - f"of {start:d}" - ) - if stop is not None and not isinstance(stop, ir.Expr) and stop < 0: - raise ValueError( - "Negative slicing not yet supported, got stop value " f"of {stop:d}" - ) if start is None and stop is None: return self return ops.StringSlice(self, start, stop).to_expr() diff --git a/third_party/bigframes_vendored/ibis/formats/pyarrow.py b/third_party/bigframes_vendored/ibis/formats/pyarrow.py index a6861b52e1..491e551ec1 100644 --- a/third_party/bigframes_vendored/ibis/formats/pyarrow.py +++ b/third_party/bigframes_vendored/ibis/formats/pyarrow.py @@ -24,7 +24,6 @@ @functools.cache def _from_pyarrow_types(): import pyarrow as pa - import pyarrow_hotfix # noqa: F401 return { pa.int8(): dt.Int8, @@ -87,7 +86,6 @@ class PyArrowType(TypeMapper): def to_ibis(cls, typ: pa.DataType, nullable=True) -> dt.DataType: """Convert a pyarrow type to an ibis type.""" import pyarrow as pa - import pyarrow_hotfix # noqa: F401 if pa.types.is_null(typ): return dt.null diff --git a/third_party/bigframes_vendored/pandas/_config/config.py b/third_party/bigframes_vendored/pandas/_config/config.py index 13ccfdac89..418f5868e5 100644 --- a/third_party/bigframes_vendored/pandas/_config/config.py +++ b/third_party/bigframes_vendored/pandas/_config/config.py @@ -2,8 +2,6 @@ import contextlib import operator -import bigframes - class option_context(contextlib.ContextDecorator): """ @@ -35,8 +33,11 @@ def __init__(self, *args) -> None: self.ops = list(zip(args[::2], args[1::2])) def __enter__(self) -> None: + # Avoid problems with circular imports. + import bigframes._config + self.undo = [ - (pat, operator.attrgetter(pat)(bigframes.options)) + (pat, operator.attrgetter(pat)(bigframes._config.options)) for pat, _ in self.ops # Don't try to undo changes to bigquery options. We're starting and # closing a new thread-local session if those are set. @@ -47,6 +48,10 @@ def __enter__(self) -> None: self._set_option(pat, val) def __exit__(self, *args) -> None: + # Avoid problems with circular imports. + import bigframes._config + import bigframes.core.global_session + if self.undo: for pat, val in self.undo: self._set_option(pat, val) @@ -54,18 +59,21 @@ def __exit__(self, *args) -> None: # TODO(tswast): What to do if someone nests several context managers # with separate "bigquery" options? We might need a "stack" of # sessions if we allow that. - if bigframes.options.is_bigquery_thread_local: - bigframes.close_session() + if bigframes._config.options.is_bigquery_thread_local: + bigframes.core.global_session.close_session() # Reset bigquery_options so that we're no longer thread-local. - bigframes.options._local.bigquery_options = None + bigframes._config.options._local.bigquery_options = None def _set_option(self, pat, val): + # Avoid problems with circular imports. + import bigframes._config + root, attr = pat.rsplit(".", 1) # We are now using a thread-specific session. if root == "bigquery": - bigframes.options._init_bigquery_thread_local() + bigframes._config.options._init_bigquery_thread_local() - parent = operator.attrgetter(root)(bigframes.options) + parent = operator.attrgetter(root)(bigframes._config.options) setattr(parent, attr, val) diff --git a/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py b/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py index fe15e7b40d..94319dbc10 100644 --- a/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py +++ b/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py @@ -19,8 +19,6 @@ def len(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... [1, 2, 3], @@ -45,8 +43,6 @@ def __getitem__(self, key: int | slice): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... [1, 2, 3], @@ -83,8 +79,6 @@ def field(self, name_or_index: str | int): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... {"version": 1, "project": "pandas"}, @@ -129,8 +123,6 @@ def explode(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... {"version": 1, "project": "pandas"}, @@ -158,6 +150,7 @@ def explode(self): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property def dtypes(self): """ Return the dtype object of each child field of the struct. @@ -165,8 +158,6 @@ def dtypes(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... {"version": 1, "project": "pandas"}, @@ -177,8 +168,8 @@ def dtypes(self): ... [("version", pa.int64()), ("project", pa.string())] ... )) ... ) - >>> s.struct.dtypes() - version Int64 + >>> s.struct.dtypes + version int64[pyarrow] project string[pyarrow] dtype: object @@ -200,8 +191,6 @@ def explode(self, column, *, separator: str = "."): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> countries = bpd.Series(["cn", "es", "us"]) >>> files = bpd.Series( ... [ diff --git a/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py b/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py index 1736a7f9ef..ace91dad1e 100644 --- a/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py +++ b/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py @@ -15,7 +15,6 @@ def strftime(self, date_format: str): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.to_datetime( ... ['2014-08-15 08:15:12', '2012-02-29 08:15:12+06:00', '2015-08-15 08:15:12+05:00'], @@ -51,7 +50,6 @@ def normalize(self): **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd >>> s = bpd.Series(pd.date_range( ... start='2014-08-01 10:00', @@ -85,8 +83,6 @@ def floor(self, freq: str): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd >>> rng = pd.date_range('1/1/2018 11:59:00', periods=3, freq='min') >>> bpd.Series(rng).dt.floor("h") 0 2018-01-01 11:00:00 diff --git a/third_party/bigframes_vendored/pandas/core/computation/eval.py b/third_party/bigframes_vendored/pandas/core/computation/eval.py index 56d60174a6..a1809f6cb3 100644 --- a/third_party/bigframes_vendored/pandas/core/computation/eval.py +++ b/third_party/bigframes_vendored/pandas/core/computation/eval.py @@ -171,8 +171,7 @@ def eval( with plain ol' Python evaluation. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None + >>> df = bpd.DataFrame({"animal": ["dog", "pig"], "age": [10, 20]}) >>> df diff --git a/third_party/bigframes_vendored/pandas/core/config_init.py b/third_party/bigframes_vendored/pandas/core/config_init.py index 4bca3f3c75..194ec4a8a7 100644 --- a/third_party/bigframes_vendored/pandas/core/config_init.py +++ b/third_party/bigframes_vendored/pandas/core/config_init.py @@ -10,101 +10,147 @@ module is imported, register them here rather than in the module. """ + from __future__ import annotations -display_options_doc = """ -Encapsulates the configuration for displaying objects. +import dataclasses +from typing import Literal, Optional -**Examples:** -Define Repr mode to "deferred" will prevent job execution in repr. +@dataclasses.dataclass +class DisplayOptions: + """ + Encapsulates the configuration for displaying objects. - >>> import bigframes.pandas as bpd - >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + **Examples:** - >>> bpd.options.display.repr_mode = "deferred" - >>> df.head(20) # will no longer run the job - Computation deferred. Computation will process 28.9 kB + Define Repr mode to "deferred" will prevent job execution in repr. -Users can also get a dry run of the job by accessing the query_job property before they've run the job. This will return a dry run instance of the job they can inspect. + >>> import bigframes.pandas as bpd + >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") - >>> df.query_job.total_bytes_processed - 28947 + >>> bpd.options.display.repr_mode = "deferred" + >>> df.head(20) # will no longer run the job + Computation deferred. Computation will process 28.9 kB -User can execute the job by calling .to_pandas() + Users can also get a dry run of the job by accessing the query_job property before they've run the job. This will return a dry run instance of the job they can inspect. - >>> # df.to_pandas() + >>> df.query_job.total_bytes_processed + 28947 -Reset repr_mode option + User can execute the job by calling .to_pandas() - >>> bpd.options.display.repr_mode = "head" + >>> # df.to_pandas() -Can also set the progress_bar option to see the progress bar in terminal, + Reset repr_mode option - >>> bpd.options.display.progress_bar = "terminal" + >>> bpd.options.display.repr_mode = "head" -notebook, + Can also set the progress_bar option to see the progress bar in terminal, - >>> bpd.options.display.progress_bar = "notebook" + >>> bpd.options.display.progress_bar = "terminal" -or just remove it. + notebook, - >>> bpd.options.display.progress_bar = None + >>> bpd.options.display.progress_bar = "notebook" -Setting to default value "auto" will detect and show progress bar automatically. + or just remove it. - >>> bpd.options.display.progress_bar = "auto" + Setting to default value "auto" will detect and show progress bar automatically. -Attributes: - max_columns (int, default 20): - If `max_columns` is exceeded, switch to truncate view. - max_rows (int, default 25): - If `max_rows` is exceeded, switch to truncate view. - progress_bar (Optional(str), default "auto"): - Determines if progress bars are shown during job runs. - Valid values are `auto`, `notebook`, and `terminal`. Set - to `None` to remove progress bars. - repr_mode (Literal[`head`, `deferred`]): - `head`: - Execute, download, and display results (limited to head) from - Dataframe and Series objects during repr. - `deferred`: - Prevent executions from repr statements in DataFrame and Series objects. - Instead, estimated bytes processed will be shown. DataFrame and Series - objects can still be computed with methods that explicitly execute and - download results. - max_info_columns (int): - max_info_columns is used in DataFrame.info method to decide if - information in each column will be printed. - max_info_rows (int or None): - df.info() will usually show null-counts for each column. - For large frames, this can be quite slow. max_info_rows and max_info_cols - limit this null check only to frames with smaller dimensions than - specified. - memory_usage (bool): - This specifies if the memory usage of a DataFrame should be displayed when - df.info() is called. Valid values True,False, -""" + >>> bpd.options.display.progress_bar = "auto" + """ -sampling_options_doc = """ -Encapsulates the configuration for data sampling. - -Attributes: - max_download_size (int, default 500): - Download size threshold in MB. If value set to None, the download size - won't be checked. - enable_downsampling (bool, default False): - Whether to enable downsampling, If max_download_size is exceeded when - downloading data (e.g., to_pandas()), the data will be downsampled - if enable_downsampling is True, otherwise, an error will be raised. - sampling_method (str, default "uniform"): - Downsampling algorithms to be chosen from, the choices are: - "head": This algorithm returns a portion of the data from - the beginning. It is fast and requires minimal computations - to perform the downsampling.; "uniform": This algorithm returns - uniform random samples of the data. - random_state (int, default None): - The seed for the uniform downsampling algorithm. If provided, - the uniform method may take longer to execute and require more - computation. -""" + # Options borrowed from pandas. + max_columns: int = 20 + """ + Maximum number of columns to display. Default 20. + + If `max_columns` is exceeded, switch to truncate view. + """ + + max_rows: int = 10 + """ + Maximum number of rows to display. Default 10. + + If `max_rows` is exceeded, switch to truncate view. + """ + + precision: int = 6 + """ + Controls the floating point output precision. Defaults to 6. + + See :attr:`pandas.options.display.precision`. + """ + + # Options unique to BigQuery DataFrames. + progress_bar: Optional[str] = "auto" + """ + Determines if progress bars are shown during job runs. Default "auto". + + Valid values are `auto`, `notebook`, and `terminal`. Set + to `None` to remove progress bars. + """ + + repr_mode: Literal["head", "deferred", "anywidget"] = "head" + """ + Determines how to display a DataFrame or Series. Default "head". + + `head` + Execute, download, and display results (limited to head) from + Dataframe and Series objects during repr. + + `deferred` + Prevent executions from repr statements in DataFrame and Series objects. + Instead, estimated bytes processed will be shown. DataFrame and Series + objects can still be computed with methods that explicitly execute and + download results. + """ + + max_colwidth: Optional[int] = 50 + """ + The maximum width in characters of a column in the repr. Default 50. + + When the column overflows, a "..." placeholder is embedded in the output. A + 'None' value means unlimited. + """ + + max_info_columns: int = 100 + """ + Used in DataFrame.info method to decide if information in each column will + be printed. Default 100. + """ + + max_info_rows: Optional[int] = 200_000 + """ + Limit null check in ``df.info()`` only to frames with smaller dimensions than + max_info_rows. Default 200,000. + + df.info() will usually show null-counts for each column. + For large frames, this can be quite slow. max_info_rows and max_info_cols + limit this null check only to frames with smaller dimensions than + specified. + """ + + memory_usage: bool = True + """ + If True, memory usage of a DataFrame should be displayed when + df.info() is called. Default True. + + Valid values True, False. + """ + + blob_display: bool = True + """ + If True, display the blob content in notebook DataFrame preview. Default + True. + """ + + blob_display_width: Optional[int] = None + """ + Width in pixels that the blob constrained to. Default None.. + """ + blob_display_height: Optional[int] = None + """ + Height in pixels that the blob constrained to. Default None.. + """ diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index f5aa23d00b..dc1bcca213 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -11,12 +11,14 @@ """ from __future__ import annotations -from typing import Hashable, Iterable, Literal, Mapping, Optional, Sequence, Union +import datetime +from typing import Hashable, Iterable, Literal, Optional, Sequence, Union from bigframes_vendored import constants import bigframes_vendored.pandas.core.generic as generic import numpy as np import pandas as pd +from pandas.api import extensions as pd_ext # ----------------------------------------------------------------------- # DataFrame class @@ -38,8 +40,6 @@ def shape(self) -> tuple[int, int]: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2, 3], ... 'col2': [4, 5, 6]}) @@ -62,8 +62,6 @@ def axes(self) -> list: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.axes[1:] @@ -77,8 +75,6 @@ def values(self) -> np.ndarray: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.values @@ -109,8 +105,6 @@ def T(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df col1 col2 @@ -145,8 +139,6 @@ def transpose(self) -> DataFrame: **Square DataFrame with homogeneous dtype** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> d1 = {'col1': [1, 2], 'col2': [3, 4]} >>> df1 = bpd.DataFrame(data=d1) @@ -255,8 +247,6 @@ def select_dtypes(self, include=None, exclude=None) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': ["hello", "world"], 'col3': [True, False]}) >>> df.select_dtypes(include=['Int64']) @@ -365,14 +355,20 @@ def from_records( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_numpy(self, dtype=None, copy=False, na_value=None, **kwargs) -> np.ndarray: + def to_numpy( + self, + dtype=None, + copy=False, + na_value=pd_ext.no_default, + *, + allow_large_results=None, + **kwargs, + ) -> np.ndarray: """ Convert the DataFrame to a NumPy array. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.to_numpy() @@ -388,7 +384,9 @@ def to_numpy(self, dtype=None, copy=False, na_value=None, **kwargs) -> np.ndarra na_value (Any, default None): The value to use for missing values. The default value depends on dtype and the dtypes of the DataFrame columns. - + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. Returns: numpy.ndarray: The converted NumPy array. """ @@ -409,7 +407,6 @@ def to_gbq( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Write a DataFrame to a BigQuery table. @@ -423,7 +420,7 @@ def to_gbq( >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> destination = df.to_gbq(ordering_id="ordering_id") >>> # The table created can be read outside of the current session. - >>> bpd.close_session() # Optional, to demonstrate a new session. + >>> bpd.close_session() # Optional, to demonstrate a new session. # doctest: +SKIP >>> bpd.read_gbq(destination, index_col="ordering_id") col1 col2 ordering_id @@ -509,6 +506,7 @@ def to_parquet( *, compression: Optional[Literal["snappy", "gzip"]] = "snappy", index: bool = True, + allow_large_results: Optional[bool] = None, ) -> Optional[bytes]: """Write a DataFrame to the binary Parquet format. @@ -518,8 +516,6 @@ def to_parquet( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> gcs_bucket = "gs://bigframes-dev-testing/sample_parquet*.parquet" >>> df.to_parquet(path=gcs_bucket) @@ -534,14 +530,16 @@ def to_parquet( should be formatted ``gs:///``. If the data size is more than 1GB, you must use a wildcard to export the data into multiple files and the size of the files varies. - compression (str, default 'snappy'): Name of the compression to use. Use ``None`` for no compression. Supported options: ``'gzip'``, ``'snappy'``. - index (bool, default True): If ``True``, include the dataframe's index(es) in the file output. If ``False``, they will not be written to the file. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. This parameter has + no effect when results are saved to Google Cloud Storage (GCS). Returns: None or bytes: @@ -560,6 +558,8 @@ def to_dict( "dict", "list", "series", "split", "tight", "records", "index" ] = "dict", into: type[dict] = dict, + *, + allow_large_results: Optional[bool] = None, **kwargs, ) -> dict | list[dict]: """ @@ -570,8 +570,6 @@ def to_dict( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.to_dict() @@ -613,11 +611,13 @@ def to_dict( in the return value. Can be the actual class or an empty instance of the mapping type you want. If you want a collections.defaultdict, you must pass it initialized. - index (bool, default True): Whether to include the index item (and index_names item if `orient` is 'tight') in the returned dictionary. Can only be ``False`` when `orient` is 'split' or 'tight'. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. Returns: dict or list of dict: Return a collections.abc.Mapping object representing the DataFrame. @@ -625,7 +625,14 @@ def to_dict( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_excel(self, excel_writer, sheet_name: str = "Sheet1", **kwargs) -> None: + def to_excel( + self, + excel_writer, + sheet_name: str = "Sheet1", + *, + allow_large_results: Optional[bool] = None, + **kwargs, + ) -> None: """ Write DataFrame to an Excel sheet. @@ -641,9 +648,7 @@ def to_excel(self, excel_writer, sheet_name: str = "Sheet1", **kwargs) -> None: **Examples:** - >>> import bigframes.pandas as bpd >>> import tempfile - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.to_excel(tempfile.TemporaryFile()) @@ -653,11 +658,21 @@ def to_excel(self, excel_writer, sheet_name: str = "Sheet1", **kwargs) -> None: File path or existing ExcelWriter. sheet_name (str, default 'Sheet1'): Name of sheet which will contain DataFrame. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) def to_latex( - self, buf=None, columns=None, header=True, index=True, **kwargs + self, + buf=None, + columns=None, + header=True, + index=True, + *, + allow_large_results=None, + **kwargs, ) -> str | None: r""" Render object to a LaTeX tabular, longtable, or nested table. @@ -668,8 +683,6 @@ def to_latex( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> print(df.to_latex()) @@ -693,6 +706,9 @@ def to_latex( it is assumed to be aliases for the column names. index (bool, default True): Write row names (index). + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. Returns: str or None: If buf is None, returns the result as a string. Otherwise returns @@ -701,7 +717,12 @@ def to_latex( raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) def to_records( - self, index: bool = True, column_dtypes=None, index_dtypes=None + self, + index: bool = True, + column_dtypes=None, + index_dtypes=None, + *, + allow_large_results=None, ) -> np.recarray: """ Convert DataFrame to a NumPy record array. @@ -711,8 +732,6 @@ def to_records( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.to_records() @@ -731,6 +750,9 @@ def to_records( If a string or type, the data type to store all index levels. If a dictionary, a mapping of index level names and indices (zero-indexed) to specific data types. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. This mapping is applied only if `index=True`. @@ -761,13 +783,13 @@ def to_string( min_rows: int | None = None, max_colwidth: int | None = None, encoding: str | None = None, + *, + allow_large_results: Optional[bool] = None, ): """Render a DataFrame to a console-friendly tabular output. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> print(df.to_string()) @@ -824,6 +846,9 @@ def to_string( Max width to truncate each column in characters. By default, no limit. encoding (str, default "utf-8"): Set character encoding. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. Returns: str or None: If buf is None, returns the result as a string. Otherwise returns @@ -856,13 +881,13 @@ def to_html( table_id: str | None = None, render_links: bool = False, encoding: str | None = None, + *, + allow_large_results: bool | None = None, ): """Render a DataFrame as an HTML table. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> print(df.to_html()) @@ -948,6 +973,9 @@ def to_html( Convert URLs to HTML links. encoding (str, default "utf-8"): Set character encoding. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. Returns: str or None: If buf is None, returns the result as a string. Otherwise @@ -960,14 +988,14 @@ def to_markdown( buf=None, mode: str = "wt", index: bool = True, + *, + allow_large_results: Optional[bool] = None, **kwargs, ): """Print DataFrame in Markdown-friendly format. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> print(df.to_markdown()) @@ -983,6 +1011,9 @@ def to_markdown( Mode in which file is opened. index (bool, optional, default True): Add index (row) labels. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. **kwargs These parameters will be passed to `tabulate `_. @@ -992,13 +1023,11 @@ def to_markdown( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_pickle(self, path, **kwargs) -> None: + def to_pickle(self, path, *, allow_large_results, **kwargs) -> None: """Pickle (serialize) object to file. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> gcs_bucket = "gs://bigframes-dev-testing/sample_pickle_gcs.pkl" @@ -1007,17 +1036,18 @@ def to_pickle(self, path, **kwargs) -> None: Args: path (str): File path where the pickled object will be stored. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_orc(self, path=None, **kwargs) -> bytes | None: + def to_orc(self, path=None, *, allow_large_results=None, **kwargs) -> bytes | None: """ Write a DataFrame to the ORC format. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> import tempfile @@ -1030,6 +1060,9 @@ def to_orc(self, path=None, **kwargs) -> bytes | None: we refer to objects with a write() method, such as a file handle (e.g. via builtin open function). If path is None, a bytes object is returned. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. Returns: bytes or None: @@ -1123,8 +1156,6 @@ def insert(self, loc, column, value, allow_duplicates=False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) @@ -1176,8 +1207,6 @@ def drop( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame(np.arange(12).reshape(3, 4), ... columns=['A', 'B', 'C', 'D']) @@ -1217,7 +1246,6 @@ def drop( Drop columns and/or rows of MultiIndex DataFrame: - >>> import pandas as pd >>> midx = pd.MultiIndex(levels=[['llama', 'cow', 'falcon'], ... ['speed', 'weight', 'length']], ... codes=[[0, 0, 0, 1, 1, 1, 2, 2, 2], @@ -1325,8 +1353,9 @@ def align( def rename( self, *, - columns: Mapping, - ) -> DataFrame: + columns, + inplace, + ): """Rename columns. Dict values must be unique (1-to-1). Labels not contained in a dict @@ -1334,8 +1363,6 @@ def rename( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) >>> df @@ -1359,16 +1386,20 @@ def rename( Args: columns (Mapping): Dict-like from old column labels to new column labels. + inplace (bool): + Default False. Whether to modify the DataFrame rather than + creating a new one. Returns: - bigframes.pandas.DataFrame: DataFrame with the renamed axis labels. + bigframes.pandas.DataFrame | None: + DataFrame with the renamed axis labels or None if ``inplace=True``. Raises: KeyError: If any of the labels is not found. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def rename_axis(self, mapper: Optional[str], **kwargs) -> DataFrame: + def rename_axis(self, mapper, *, inplace, **kwargs): """ Set the name of the axis for the index. @@ -1376,11 +1407,15 @@ def rename_axis(self, mapper: Optional[str], **kwargs) -> DataFrame: Currently only accepts a single string parameter (the new name of the index). Args: - mapper str: + mapper (str): Value to set the axis name attribute. + inplace (bool): + Default False. Modifies the object directly, instead of + creating a new Series or DataFrame. Returns: - bigframes.pandas.DataFrame: DataFrame with the new index name + bigframes.pandas.DataFrame | None: + DataFrame with the new index name or None if ``inplace=True``. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) @@ -1398,8 +1433,6 @@ def set_index( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'month': [1, 4, 7, 10], ... 'year': [2012, 2014, 2013, 2014], @@ -1525,8 +1558,14 @@ def droplevel(self, level, axis: str | int = 0): def reset_index( self, + level=None, *, drop: bool = False, + inplace: bool = False, + col_level: Hashable = 0, + col_fill: Hashable = "", + allow_duplicates: Optional[bool] = None, + names: Hashable | Sequence[Hashable] | None = None, ) -> DataFrame | None: """Reset the index. @@ -1534,10 +1573,7 @@ def reset_index( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np >>> df = bpd.DataFrame([('bird', 389.0), ... ('bird', 24.0), ... ('mammal', 80.5), @@ -1577,7 +1613,6 @@ class max_speed You can also use ``reset_index`` with ``MultiIndex``. - >>> import pandas as pd >>> index = pd.MultiIndex.from_tuples([('bird', 'falcon'), ... ('bird', 'parrot'), ... ('mammal', 'lion'), @@ -1620,9 +1655,27 @@ class name speed max Args: + level (int, str, tuple, or list, default None): + Only remove the given levels from the index. Removes all levels by + default. drop (bool, default False): Do not try to insert index into dataframe columns. This resets the index to the default integer index. + inplace (bool, default False): + Whether to modify the DataFrame rather than creating a new one. + col_level (int or str, default 0): + If the columns have multiple levels, determines which level the + labels are inserted into. By default it is inserted into the first + level. + col_fill (object, default ''): + If the columns have multiple levels, determines how the other + levels are named. If None then the index name is repeated. + allow_duplicates (bool, optional, default None): + Allow duplicate column labels to be created. + names (str or 1-dimensional list, default None): + Using the given string, rename the DataFrame column which contains the + index data. If the DataFrame has a MultiIndex, this has to be a list or + tuple with length equal to the number of levels Returns: bigframes.pandas.DataFrame: DataFrame with the new index. @@ -1686,6 +1739,7 @@ def dropna( *, axis: int | str = 0, how: str = "any", + thresh: Optional[int] = None, subset=None, inplace: bool = False, ignore_index=False, @@ -1694,12 +1748,10 @@ def dropna( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"name": ['Alfred', 'Batman', 'Catwoman'], ... "toy": [np.nan, 'Batmobile', 'Bullwhip'], - ... "born": [bpd.NA, "1940-04-25", bpd.NA]}) + ... "born": [pd.NA, "1940-04-25", pd.NA]}) >>> df name toy born 0 Alfred @@ -1736,6 +1788,25 @@ def dropna( [3 rows x 3 columns] + Keep rows with at least 2 non-null values. + + >>> df.dropna(thresh=2) + name toy born + 1 Batman Batmobile 1940-04-25 + 2 Catwoman Bullwhip + + [2 rows x 3 columns] + + Keep columns with at least 2 non-null values: + + >>> df.dropna(axis='columns', thresh=2) + name toy + 0 Alfred + 1 Batman Batmobile + 2 Catwoman Bullwhip + + [3 rows x 2 columns] + Define in which columns to look for missing values. >>> df.dropna(subset=['name', 'toy']) @@ -1746,7 +1817,7 @@ def dropna( [2 rows x 3 columns] Args: - axis ({0 or 'index', 1 or 'columns'}, default 'columns'): + axis ({0 or 'index', 1 or 'columns'}, default 0): Determine if rows or columns which contain missing values are removed. @@ -1758,6 +1829,8 @@ def dropna( * 'any' : If any NA values are present, drop that row or column. * 'all' : If all values are NA, drop that row or column. + thresh (int, optional): + Require that many non-NA values. Cannot be combined with how. subset (column label or sequence of labels, optional): Labels along other axis to consider, e.g. if you are dropping rows these would be a list of columns to include. @@ -1775,6 +1848,8 @@ def dropna( Raises: ValueError: If ``how`` is not one of ``any`` or ``all``. + TyperError: + If both ``how`` and ``thresh`` are specified. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) @@ -1784,8 +1859,6 @@ def isin(self, values): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'num_legs': [2, 4], 'num_wings': [2, 0]}, ... index=['falcon', 'dog']) @@ -1840,8 +1913,6 @@ def keys(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -1861,8 +1932,6 @@ def iterrows(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], ... 'B': [4, 5, 6], @@ -1887,8 +1956,6 @@ def itertuples(self, index: bool = True, name: str | None = "Pandas"): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], ... 'B': [4, 5, 6], @@ -1920,8 +1987,6 @@ def items(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'species': ['bear', 'bear', 'marsupial'], ... 'population': [1864, 22000, 80000]}, @@ -1961,8 +2026,6 @@ def where(self, cond, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'a': [20, 10, 0], 'b': [0, 10, 20]}) >>> df @@ -2053,8 +2116,6 @@ def mask(self, cond, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'a': [20, 10, 0], 'b': [0, 10, 20]}) >>> df @@ -2147,19 +2208,18 @@ def sort_values( self, by: str | Sequence[str], *, + inplace: bool = False, ascending: bool | Sequence[bool] = True, kind: str = "quicksort", - na_position="last", - ) -> DataFrame: + na_position: Literal["first", "last"] = "last", + ): """Sort by the values along row axis. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ - ... 'col1': ['A', 'A', 'B', bpd.NA, 'D', 'C'], + ... 'col1': ['A', 'A', 'B', pd.NA, 'D', 'C'], ... 'col2': [2, 1, 9, 8, 7, 4], ... 'col3': [0, 1, 9, 4, 2, 3], ... 'col4': ['a', 'B', 'c', 'D', 'e', 'F'] @@ -2234,6 +2294,8 @@ def sort_values( Sort ascending vs. descending. Specify list for multiple sort orders. If this is a list of bools, must match the length of the by. + inplace (bool, default False): + If True, perform operation in-place. kind (str, default 'quicksort'): Choice of sorting algorithm. Accepts 'quicksort', 'mergesort', 'heapsort', 'stable'. Ignored except when determining whether to @@ -2243,8 +2305,8 @@ def sort_values( if `first`; `last` puts NaNs at the end. Returns: - bigframes.pandas.DataFrame: - DataFrame with sorted values. + bigframes.pandas.DataFram or None: + DataFrame with sorted values or None if inplace=True. Raises: ValueError: @@ -2254,12 +2316,29 @@ def sort_values( def sort_index( self, - ) -> DataFrame: + *, + axis: str | int = 0, + ascending: bool = True, + inplace: bool = False, + na_position: Literal["first", "last"] = "last", + ): """Sort object by labels (along an axis). + Args: + axis ({0 or 'index', 1 or 'columns'}, default 0): + The axis along which to sort. The value 0 identifies the rows, + and 1 identifies the columns. + ascending (bool, default True) + Sort ascending vs. descending. + inplace (bool, default False): + Whether to modify the DataFrame rather than creating a new one. + na_position ({'first', 'last'}, default 'last'): + Puts NaNs at the beginning if `first`; `last` puts NaNs at the end. + Not implemented for MultiIndex. + Returns: bigframes.pandas.DataFrame: - The original DataFrame sorted by the labels. + DataFrame with sorted values or None if inplace=True. Raises: ValueError: @@ -2284,8 +2363,6 @@ def eq(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2327,8 +2404,6 @@ def __eq__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, 3, 4], @@ -2358,8 +2433,6 @@ def __invert__(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'a':[True, False, True], 'b':[-1, 0, 1]}) >>> ~df @@ -2387,8 +2460,6 @@ def ne(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2429,8 +2500,6 @@ def __ne__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, 3, 4], @@ -2469,8 +2538,6 @@ def le(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2512,8 +2579,6 @@ def __le__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2552,8 +2617,6 @@ def lt(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2595,8 +2658,6 @@ def __lt__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2635,8 +2696,6 @@ def ge(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2678,8 +2737,6 @@ def __ge__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2718,8 +2775,6 @@ def gt(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'angles': [0, 3, 4], ... 'degrees': [360, 180, 360]}, @@ -2759,8 +2814,6 @@ def __gt__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2796,8 +2849,6 @@ def add(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -2840,8 +2891,6 @@ def __add__(self, other) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'height': [1.5, 2.6], @@ -2915,8 +2964,6 @@ def radd(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -2951,6 +2998,20 @@ def radd(self, other, axis: str | int = "columns") -> DataFrame: """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def __radd__(self, other) -> DataFrame: + """Get addition of other and DataFrame, element-wise (binary operator `+`). + + Equivalent to ``DataFrame.radd(other)``. + + Args: + other (float, int, or Series): + Any single or multiple element data structure, or list-like object. + + Returns: + bigframes.pandas.DataFrame: DataFrame result of the arithmetic operation. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def sub(self, other, axis: str | int = "columns") -> DataFrame: """Get subtraction of DataFrame and other, element-wise (binary operator `-`). @@ -2964,8 +3025,6 @@ def sub(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3008,8 +3067,6 @@ def __sub__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can subtract a scalar: @@ -3056,8 +3113,6 @@ def rsub(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3117,8 +3172,6 @@ def mul(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3161,8 +3214,6 @@ def __mul__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3209,8 +3260,6 @@ def rmul(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3253,8 +3302,6 @@ def __rmul__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3301,8 +3348,6 @@ def truediv(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3345,8 +3390,6 @@ def __truediv__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3393,8 +3436,6 @@ def rtruediv(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3454,8 +3495,6 @@ def floordiv(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3498,8 +3537,6 @@ def __floordiv__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can divide by a scalar: @@ -3546,8 +3583,6 @@ def rfloordiv(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3607,8 +3642,6 @@ def mod(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3651,8 +3684,6 @@ def __mod__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can modulo with a scalar: @@ -3699,8 +3730,6 @@ def rmod(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3761,8 +3790,6 @@ def pow(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3806,8 +3833,6 @@ def __pow__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can exponentiate with a scalar: @@ -3855,8 +3880,6 @@ def rpow(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3951,8 +3974,6 @@ def combine( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df1 = bpd.DataFrame({'A': [0, 0], 'B': [4, 4]}) >>> df2 = bpd.DataFrame({'A': [1, 1], 'B': [3, 3]}) @@ -4001,8 +4022,6 @@ def combine_first(self, other) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df1 = bpd.DataFrame({'A': [None, 0], 'B': [None, 4]}) >>> df2 = bpd.DataFrame({'A': [1, 1], 'B': [3, 3]}) @@ -4031,8 +4050,6 @@ def explode( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [[0, 1, 2], [], [], [3, 4]], ... 'B': 1, @@ -4089,8 +4106,6 @@ def corr(self, method, min_periods, numeric_only) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 2, 3], ... 'B': [400, 500, 600], @@ -4123,8 +4138,6 @@ def cov(self, *, numeric_only) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 2, 3], ... 'B': [400, 500, 600], @@ -4161,8 +4174,7 @@ def corrwith( correlations. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None + >>> index = ["a", "b", "c", "d", "e"] >>> columns = ["one", "two", "three", "four"] @@ -4197,8 +4209,6 @@ def update( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 2, 3], ... 'B': [400, 500, 600]}) @@ -4262,8 +4272,6 @@ def groupby( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'Animal': ['Falcon', 'Falcon', ... 'Parrot', 'Parrot'], @@ -4359,15 +4367,12 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Let's use ``reuse=False`` flag to make sure a new ``remote_function`` is created every time we run the following code, but you can skip it to potentially reuse a previously deployed ``remote_function`` from the same user defined function. - >>> @bpd.remote_function(reuse=False) + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def minutes_to_hours(x: int) -> float: ... return x/60 @@ -4384,8 +4389,8 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: [5 rows x 2 columns] - >>> df_hours = df_minutes.map(minutes_to_hours) - >>> df_hours + >>> df_hours = df_minutes.map(minutes_to_hours) # doctest: +SKIP + >>> df_hours # doctest: +SKIP system_minutes user_minutes 0 0.0 0.0 1 0.5 0.25 @@ -4401,11 +4406,11 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: >>> df_minutes = bpd.DataFrame( ... { - ... "system_minutes" : [0, 30, 60, None, 90, 120, bpd.NA], - ... "user_minutes" : [0, 15, 75, 90, 6, None, bpd.NA] + ... "system_minutes" : [0, 30, 60, None, 90, 120, pd.NA], + ... "user_minutes" : [0, 15, 75, 90, 6, None, pd.NA] ... }, dtype="Int64") - >>> df_hours = df_minutes.map(minutes_to_hours, na_action='ignore') - >>> df_hours + >>> df_hours = df_minutes.map(minutes_to_hours, na_action='ignore') # doctest: +SKIP + >>> df_hours # doctest: +SKIP system_minutes user_minutes 0 0.0 0.0 1 0.5 0.25 @@ -4442,15 +4447,20 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: # ---------------------------------------------------------------------- # Merging / joining methods - def join(self, other, *, on: Optional[str] = None, how: str) -> DataFrame: + def join( + self, + other, + on: Optional[str] = None, + how: str = "left", + lsuffix: str = "", + rsuffix: str = "", + ) -> DataFrame: """Join columns of another DataFrame. Join columns with `other` DataFrame on index **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Join two DataFrames by specifying how to handle the operation: @@ -4508,13 +4518,26 @@ def join(self, other, *, on: Optional[str] = None, how: str) -> DataFrame: Another option to join using the key columns is to use the on parameter: - >>> df1.join(df2, on="col1", how="right") + >>> df1.join(df2, on="col2", how="right") col1 col2 col3 col4 - 11 foo 3 - 22 baz 4 + 11 foo 3 + 22 baz 4 [2 rows x 4 columns] + If there are overlapping columns, `lsuffix` and `rsuffix` can be used: + + >>> df1 = bpd.DataFrame({'key': ['K0', 'K1', 'K2'], 'A': ['A0', 'A1', 'A2']}) + >>> df2 = bpd.DataFrame({'key': ['K0', 'K1', 'K2'], 'A': ['B0', 'B1', 'B2']}) + >>> df1.set_index('key').join(df2.set_index('key'), lsuffix='_left', rsuffix='_right') + A_left A_right + key + K0 A0 B0 + K1 A1 B1 + K2 A2 B2 + + [3 rows x 2 columns] + Args: other: DataFrame or Series with an Index similar to the Index of this one. @@ -4531,6 +4554,10 @@ def join(self, other, *, on: Optional[str] = None, how: str) -> DataFrame: index, preserving the order of the calling's one. ``cross``: creates the cartesian product from both frames, preserves the order of the left keys. + lsuffix(str, default ''): + Suffix to use from left frame's overlapping columns. + rsuffix(str, default ''): + Suffix to use from right frame's overlapping columns. Returns: bigframes.pandas.DataFrame: @@ -4545,6 +4572,10 @@ def join(self, other, *, on: Optional[str] = None, how: str) -> DataFrame: ValueError: If left index to join on does not have the same number of levels as the right index. + ValueError: + If columns overlap but no suffix is specified. + ValueError: + If `on` column is not unique. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) @@ -4562,6 +4593,8 @@ def merge( *, left_on: Optional[str] = None, right_on: Optional[str] = None, + left_index: bool = False, + right_index: bool = False, sort: bool = False, suffixes: tuple[str, str] = ("_x", "_y"), ) -> DataFrame: @@ -4580,8 +4613,6 @@ def merge( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Merge DataFrames df1 and df2 by specifying type of merge: @@ -4654,7 +4685,7 @@ def merge( right: Object to merge with. how: - ``{'left', 'right', 'outer', 'inner'}, default 'inner'`` + ``{'left', 'right', 'outer', 'inner', 'cross'}, default 'inner'`` Type of merge to be performed. ``left``: use only keys from left frame, similar to a SQL left outer join; preserve key order. @@ -4676,6 +4707,10 @@ def merge( right_on (label or list of labels): Columns to join on in the right DataFrame. Either on or left_on + right_on must be passed in. + left_index (bool, default False): + Use the index from the left DataFrame as the join key. + right_index (bool, default False): + Use the index from the right DataFrame as the join key. sort: Default False. Sort the join keys lexicographically in the result DataFrame. If False, the order of the join keys depends @@ -4706,6 +4741,162 @@ def merge( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def resample( + self, + rule: str, + *, + closed: Optional[Literal["right", "left"]] = None, + label: Optional[Literal["right", "left"]] = None, + on=None, + level=None, + origin: Union[ + Union[pd.Timestamp, datetime.datetime, np.datetime64, int, float, str], + Literal["epoch", "start", "start_day", "end", "end_day"], + ] = "start_day", + ): + """Resample time-series data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> data = { + ... "timestamp_col": pd.date_range( + ... start="2021-01-01 13:00:00", periods=30, freq="1s" + ... ), + ... "int64_col": range(30), + ... "int64_too": range(10, 40), + ... } + + Resample on a DataFrame with index: + + >>> df = bpd.DataFrame(data).set_index("timestamp_col") + >>> df.resample(rule="7s").min() + int64_col int64_too + 2021-01-01 12:59:55 0 10 + 2021-01-01 13:00:02 2 12 + 2021-01-01 13:00:09 9 19 + 2021-01-01 13:00:16 16 26 + 2021-01-01 13:00:23 23 33 + + [5 rows x 2 columns] + + Resample with column and origin set to 'start': + + >>> df = bpd.DataFrame(data) + >>> df.resample(rule="7s", on = "timestamp_col", origin="start").min() + int64_col int64_too + 2021-01-01 13:00:00 0 10 + 2021-01-01 13:00:07 7 17 + 2021-01-01 13:00:14 14 24 + 2021-01-01 13:00:21 21 31 + 2021-01-01 13:00:28 28 38 + + [5 rows x 2 columns] + + Args: + rule (str): + The offset string representing target conversion. + Offsets 'ME', 'YE', 'QE', 'BME', 'BA', 'BQE', and 'W' are *not* + supported. + closed (Literal['left'] | None): + Which side of bin interval is closed. The default is 'left' for + all supported frequency offsets. + label (Literal['right'] | Literal['left'] | None): + Which bin edge label to label bucket with. The default is 'left' + for all supported frequency offsets. + on (str, default None): + For a DataFrame, column to use instead of index for resampling. Column + must be datetime-like. + level (str or int, default None): + For a MultiIndex, level (name or number) to use for resampling. + level must be datetime-like. + origin(str, default 'start_day'): + The timestamp on which to adjust the grouping. Must be one of the following: + 'epoch': origin is 1970-01-01 + 'start': origin is the first value of the timeseries + 'start_day': origin is the first day at midnight of the timeseries + Origin values 'end' and 'end_day' are *not* supported. + Returns: + DataFrameGroupBy: DataFrameGroupBy object. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def round(self, decimals): + """ + Round a DataFrame to a variable number of decimal places. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> df = bpd.DataFrame([(.21, .32), (.01, .67), (.66, .03), (.21, .18)], + ... columns=['dogs', 'cats']) + >>> df + dogs cats + 0 0.21 0.32 + 1 0.01 0.67 + 2 0.66 0.03 + 3 0.21 0.18 + + [4 rows x 2 columns] + + By providing an integer each column is rounded to the same number + of decimal places + + >>> df.round(1) + dogs cats + 0 0.2 0.3 + 1 0.0 0.7 + 2 0.7 0.0 + 3 0.2 0.2 + + [4 rows x 2 columns] + + With a dict, the number of places for specific columns can be + specified with the column names as key and the number of decimal + places as value + + >>> df.round({'dogs': 1, 'cats': 0}) + dogs cats + 0 0.2 0.0 + 1 0.0 1.0 + 2 0.7 0.0 + 3 0.2 0.0 + + [4 rows x 2 columns] + + Using a Series, the number of places for specific columns can be + specified with the column names as index and the number of + decimal places as value + + >>> decimals = pd.Series([0, 1], index=['cats', 'dogs']) + >>> df.round(decimals) + dogs cats + 0 0.2 0.0 + 1 0.0 1.0 + 2 0.7 0.0 + 3 0.2 0.0 + + [4 rows x 2 columns] + + Args: + decimals (int, dict, Series): + Number of decimal places to round each column to. If an int is + given, round each column to the same number of places. + Otherwise dict and Series round to variable numbers of places. + Column names should be in the keys if `decimals` is a + dict-like, or in the index if `decimals` is a Series. Any + columns not included in `decimals` will be left as is. Elements + of `decimals` which are not columns of the input will be + ignored. + + Returns: + bigframes.pandas.DataFrame: + A DataFrame with the affected columns rounded to the specified + number of decimal places. + + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def apply(self, func, *, axis=0, args=(), **kwargs): """Apply a function along an axis of the DataFrame. @@ -4719,9 +4910,6 @@ def apply(self, func, *, axis=0, args=(), **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df @@ -4747,14 +4935,14 @@ def apply(self, func, *, axis=0, args=(), **kwargs): to select only the necessary columns before calling `apply()`. Note: This feature is currently in **preview**. - >>> @bpd.remote_function(reuse=False) + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def foo(row: pd.Series) -> int: ... result = 1 ... result += row["col1"] ... result += row["col2"]*row["col2"] ... return result - >>> df[["col1", "col2"]].apply(foo, axis=1) + >>> df[["col1", "col2"]].apply(foo, axis=1) # doctest: +SKIP 0 11 1 19 dtype: Int64 @@ -4762,7 +4950,7 @@ def apply(self, func, *, axis=0, args=(), **kwargs): You could return an array output for every input row from the remote function. - >>> @bpd.remote_function(reuse=False) + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def marks_analyzer(marks: pd.Series) -> list[float]: ... import statistics ... average = marks.mean() @@ -4779,8 +4967,8 @@ def apply(self, func, *, axis=0, args=(), **kwargs): ... "chemistry": [88, 56, 72], ... "algebra": [78, 91, 79] ... }, index=["Alice", "Bob", "Charlie"]) - >>> stats = df.apply(marks_analyzer, axis=1) - >>> stats + >>> stats = df.apply(marks_analyzer, axis=1) # doctest: +SKIP + >>> stats # doctest: +SKIP Alice [77.67 78. 77.19 76.71] Bob [75.67 80. 74.15 72.56] Charlie [75.33 75. 75.28 75.22] @@ -4803,14 +4991,14 @@ def apply(self, func, *, axis=0, args=(), **kwargs): [2 rows x 3 columns] - >>> @bpd.remote_function(reuse=False) + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def foo(x: int, y: int, z: int) -> float: ... result = 1 ... result += x ... result += y/z ... return result - >>> df.apply(foo, axis=1) + >>> df.apply(foo, axis=1) # doctest: +SKIP 0 2.6 1 3.8 dtype: Float64 @@ -4870,8 +5058,6 @@ def any(self, *, axis=0, bool_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [True, True], "B": [False, False]}) >>> df @@ -4917,8 +5103,6 @@ def all(self, axis=0, *, bool_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [True, True], "B": [False, False]}) >>> df @@ -4961,8 +5145,6 @@ def prod(self, axis=0, *, numeric_only: bool = False): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [1, 2, 3], "B": [4.5, 5.5, 6.5]}) >>> df A B @@ -5007,8 +5189,6 @@ def min(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5052,8 +5232,6 @@ def max(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5096,8 +5274,6 @@ def sum(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5138,8 +5314,6 @@ def mean(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5181,8 +5355,6 @@ def median(self, *, numeric_only: bool = False, exact: bool = True): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df A B @@ -5219,7 +5391,6 @@ def quantile( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame(np.array([[1, 1], [2, 10], [3, 100], [4, 100]]), ... columns=['a', 'b']) >>> df.quantile(.1) @@ -5256,8 +5427,6 @@ def var(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5301,8 +5470,6 @@ def skew(self, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 2, 3, 4, 5], ... 'B': [5, 4, 3, 2, 1], @@ -5342,8 +5509,6 @@ def kurt(self, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 2, 3, 4, 5], ... "B": [3, 4, 3, 2, 1], @@ -5382,8 +5547,6 @@ def std(self, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 2, 3, 4, 5], ... "B": [3, 4, 3, 2, 1], @@ -5424,8 +5587,6 @@ def count(self, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, None, 3, 4, 5], ... "B": [1, 2, 3, 4, 5], @@ -5478,8 +5639,6 @@ def nlargest(self, n: int, columns, keep: str = "first"): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [1, 1, 3, 3, 5, 5], ... "B": [5, 6, 3, 4, 1, 2], ... "C": ['a', 'b', 'a', 'b', 'a', 'b']}) @@ -5570,8 +5729,6 @@ def nsmallest(self, n: int, columns, keep: str = "first"): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [1, 1, 3, 3, 5, 5], ... "B": [5, 6, 3, 4, 1, 2], ... "C": ['a', 'b', 'a', 'b', 'a', 'b']}) @@ -5651,8 +5808,6 @@ def idxmin(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -5681,8 +5836,6 @@ def idxmax(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -5715,8 +5868,6 @@ def melt(self, id_vars, value_vars, var_name, value_name): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, None, 3, 4, 5], ... "B": [1, 2, 3, 4, 5], @@ -5734,22 +5885,18 @@ def melt(self, id_vars, value_vars, var_name, value_name): Using `melt` without optional arguments: >>> df.melt() - variable value - 0 A 1.0 - 1 A - 2 A 3.0 - 3 A 4.0 - 4 A 5.0 - 5 B 1.0 - 6 B 2.0 - 7 B 3.0 - 8 B 4.0 - 9 B 5.0 - 10 C - 11 C 3.5 - 12 C - 13 C 4.5 - 14 C 5.0 + variable value + 0 A 1.0 + 1 A + 2 A 3.0 + 3 A 4.0 + 4 A 5.0 + 5 B 1.0 + 6 B 2.0 + 7 B 3.0 + 8 B 4.0 + 9 B 5.0 + ... [15 rows x 2 columns] @@ -5794,8 +5941,6 @@ def nunique(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 2]}) >>> df @@ -5823,8 +5968,6 @@ def cummin(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -5855,8 +5998,6 @@ def cummax(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -5887,8 +6028,6 @@ def cumsum(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -5924,8 +6063,6 @@ def cumprod(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -5965,8 +6102,6 @@ def diff( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -6013,8 +6148,6 @@ def agg(self, func): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -6052,7 +6185,7 @@ def agg(self, func): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def describe(self): + def describe(self, include: None | Literal["all"] = None): """ Generate descriptive statistics. @@ -6060,7 +6193,10 @@ def describe(self): tendency, dispersion and shape of a dataset's distribution, excluding ``NaN`` values. - Only supports numeric columns. + Args: + include ("all" or None, optional): + If "all": All columns of the input will be included in the output. + If None: The result will include all numeric columns. .. note:: Percentile values are approximates only. @@ -6075,30 +6211,44 @@ def describe(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - - >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [0, 2, 8]}) + >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [0, 2, 8], "C": ["cat", "cat", "dog"]}) >>> df - A B - 0 3 0 - 1 1 2 - 2 2 8 + A B C + 0 3 0 cat + 1 1 2 cat + 2 2 8 dog - [3 rows x 2 columns] + [3 rows x 3 columns] >>> df.describe() - A B - count 3.0 3.0 - mean 2.0 3.333333 - std 1.0 4.163332 - min 1.0 0.0 - 25% 1.0 0.0 - 50% 2.0 2.0 - 75% 3.0 8.0 - max 3.0 8.0 + A B + count 3.0 3.0 + mean 2.0 3.333333 + std 1.0 4.163332 + min 1.0 0.0 + 25% 1.0 0.0 + 50% 2.0 2.0 + 75% 3.0 8.0 + max 3.0 8.0 [8 rows x 2 columns] + + Using describe with include = "all": + >>> df.describe(include="all") + A B C + count 3.0 3.0 3 + nunique 2 + mean 2.0 3.333333 + std 1.0 4.163332 + min 1.0 0.0 + 25% 1.0 0.0 + 50% 2.0 2.0 + 75% 3.0 8.0 + max 3.0 8.0 + + [9 rows x 3 columns] + Returns: bigframes.pandas.DataFrame: Summary statistics of the Series or Dataframe provided. @@ -6130,8 +6280,6 @@ def pivot(self, *, columns, index=None, values=None): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... "foo": ["one", "one", "one", "two", "two"], @@ -6201,8 +6349,6 @@ def pivot_table(self, values=None, index=None, columns=None, aggfunc="mean"): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({ ... 'Product': ['Product A', 'Product B', 'Product A', 'Product B', 'Product A', 'Product B'], ... 'Region': ['East', 'West', 'East', 'West', 'West', 'East'], @@ -6268,6 +6414,10 @@ def pivot_table(self, values=None, index=None, columns=None, aggfunc="mean"): aggfunc (str, default "mean"): Aggregation function name to compute summary statistics (e.g., 'sum', 'mean'). + fill_value (scalar, default None): + Value to replace missing values with (in the resulting pivot table, after + aggregation). + Returns: bigframes.pandas.DataFrame: An Excel style pivot table. """ @@ -6293,8 +6443,6 @@ def stack(self, level=-1): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 3], 'B': [2, 4]}, index=['foo', 'bar']) >>> df @@ -6332,8 +6480,6 @@ def unstack(self, level=-1): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 3], 'B': [2, 4]}, index=['foo', 'bar']) >>> df @@ -6373,8 +6519,6 @@ def index(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can access the index of a DataFrame via ``index`` property. @@ -6426,8 +6570,6 @@ def columns(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can access the column labels of a DataFrame via ``columns`` property. @@ -6474,11 +6616,9 @@ def value_counts( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'num_legs': [2, 4, 4, 6, 7], - ... 'num_wings': [2, 0, 0, 0, bpd.NA]}, + ... 'num_wings': [2, 0, 0, 0, pd.NA]}, ... index=['falcon', 'dog', 'cat', 'ant', 'octopus'], ... dtype='Int64') >>> df @@ -6555,8 +6695,6 @@ def eval(self, expr: str) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': range(1, 6), 'B': range(10, 0, -2)}) >>> df @@ -6631,8 +6769,6 @@ def query(self, expr: str) -> DataFrame | None: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': range(1, 6), ... 'B': range(10, 0, -2), @@ -6702,12 +6838,10 @@ def query(self, expr: str) -> DataFrame | None: def interpolate(self, method: str = "linear"): """ - Fill NaN values using an interpolation method. + Fill NA (NULL in BigQuery) values using an interpolation method. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3, None, None, 6], @@ -6752,35 +6886,39 @@ def interpolate(self, method: str = "linear"): def fillna(self, value): """ - Fill NA/NaN values using the specified method. + Fill NA (NULL in BigQuery) values using the specified method. - **Examples:** + Note that empty strings ``''``, :attr:`numpy.inf`, and + :attr:`numpy.nan` are ***not*** considered NA values. This NA/NULL + logic differs from numpy, but it is the same as BigQuery and the + :class:`pandas.ArrowDtype`. - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None + **Examples:** - >>> df = bpd.DataFrame([[np.nan, 2, np.nan, 0], - ... [3, 4, np.nan, 1], - ... [np.nan, np.nan, np.nan, np.nan], - ... [np.nan, 3, np.nan, 4]], - ... columns=list("ABCD")).astype("Float64") + >>> df = bpd.DataFrame( + ... [ + ... pa.array([np.nan, 2, None, 0], type=pa.float64()), + ... pa.array([3, np.nan, None, 1], type=pa.float64()), + ... pa.array([None, None, np.nan, None], type=pa.float64()), + ... pa.array([4, 5, None, np.nan], type=pa.float64()), + ... ], columns=list("ABCD"), dtype=pd.ArrowDtype(pa.float64())) >>> df - A B C D - 0 2.0 0.0 - 1 3.0 4.0 1.0 - 2 - 3 3.0 4.0 + A B C D + 0 NaN 2.0 0.0 + 1 3.0 NaN 1.0 + 2 NaN + 3 4.0 5.0 NaN [4 rows x 4 columns] - Replace all NA elements with 0s. + Replace all NA (NULL) elements with 0s. >>> df.fillna(0) A B C D - 0 0.0 2.0 0.0 0.0 - 1 3.0 4.0 0.0 1.0 - 2 0.0 0.0 0.0 0.0 - 3 0.0 3.0 0.0 4.0 + 0 NaN 2.0 0.0 0.0 + 1 3.0 NaN 0.0 1.0 + 2 0.0 0.0 NaN 0.0 + 3 4.0 5.0 0.0 NaN [4 rows x 4 columns] @@ -6796,11 +6934,11 @@ def fillna(self, value): [3 rows x 4 columns] >>> df.fillna(df_fill) - A B C D - 0 0.0 2.0 2.0 0.0 - 1 3.0 4.0 6.0 1.0 - 2 8.0 9.0 10.0 11.0 - 3 3.0 4.0 + A B C D + 0 NaN 2.0 2.0 0.0 + 1 3.0 NaN 6.0 1.0 + 2 8.0 9.0 NaN 11.0 + 3 4.0 5.0 NaN [4 rows x 4 columns] @@ -6834,8 +6972,6 @@ def replace( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({ ... 'int_col': [1, 1, 2, 3], ... 'string_col': ["a", "b", "c", "b"], @@ -6930,8 +7066,6 @@ def iat(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[0, 2, 3], [0, 4, 1], [10, 20, 30]], ... columns=['A', 'B', 'C']) @@ -6964,8 +7098,6 @@ def at(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[0, 2, 3], [0, 4, 1], [10, 20, 30]], ... index=[4, 5, 6], columns=['A', 'B', 'C']) @@ -7013,8 +7145,6 @@ def dot(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> left = bpd.DataFrame([[0, 1, -2, -1], [1, 1, 1, 1]]) >>> left @@ -7107,8 +7237,6 @@ def __matmul__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> left = bpd.DataFrame([[0, 1, -2, -1], [1, 1, 1, 1]]) >>> left @@ -7167,8 +7295,6 @@ def __len__(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, 1, 2], @@ -7179,7 +7305,7 @@ def __len__(self): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def __array__(self): + def __array__(self, dtype=None, copy: Optional[bool] = None): """ Returns the rows as NumPy array. @@ -7190,9 +7316,6 @@ def __array__(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np >>> df = bpd.DataFrame({"a": [1, 2, 3], "b": [11, 22, 33]}) @@ -7210,6 +7333,8 @@ def __array__(self): dtype (str or numpy.dtype, optional): The dtype to use for the resulting NumPy array. By default, the dtype is inferred from the data. + copy (bool or None, optional): + Whether to copy the data, False is not supported. Returns: numpy.ndarray: @@ -7223,8 +7348,6 @@ def __getitem__(self, key): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... "name" : ["alpha", "beta", "gamma"], @@ -7269,7 +7392,6 @@ def __getitem__(self, key): You can specify a pandas Index with desired column labels. - >>> import pandas as pd >>> df[pd.Index(["age", "location"])] age location 0 20 WA @@ -7298,8 +7420,6 @@ def __setitem__(self, key, value): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... "name" : ["alpha", "beta", "gamma"], @@ -7347,11 +7467,43 @@ def __setitem__(self, key, value): [3 rows x 5 columns] + You can assign a scalar to multiple columns. + + >>> df[["age", "new_age"]] = 25 + >>> df + name age location country new_age + 0 alpha 25 WA USA 25 + 1 beta 25 NY USA 25 + 2 gamma 25 CA USA 25 + + [3 rows x 5 columns] + + You can use a sequence of scalars for assignment of multiple columns: + + >>> df[["age", "is_happy"]] = [20, True] + >>> df + name age location country new_age is_happy + 0 alpha 20 WA USA 25 True + 1 beta 20 NY USA 25 True + 2 gamma 20 CA USA 25 True + + [3 rows x 6 columns] + + You can use a dataframe for assignment of multiple columns: + >>> df[["age", "new_age"]] = df[["new_age", "age"]] + >>> df + name age location country new_age is_happy + 0 alpha 25 WA USA 20 True + 1 beta 25 NY USA 20 True + 2 gamma 25 CA USA 20 True + + [3 rows x 6 columns] + Args: key (column index): It can be a new column to be inserted, or an existing column to be modified. - value (scalar or Series): + value (scalar, Sequence, DataFrame, or Series): Value to be assigned to the column """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/pandas/core/generic.py b/third_party/bigframes_vendored/pandas/core/generic.py index 9dae802b6e..63b9f8199b 100644 --- a/third_party/bigframes_vendored/pandas/core/generic.py +++ b/third_party/bigframes_vendored/pandas/core/generic.py @@ -17,6 +17,9 @@ class NDFrame(indexing.IndexingMixin): size-mutable, labeled data structure """ + # Explicitly mark the class as unhashable + __hash__ = None # type: ignore + # ---------------------------------------------------------------------- # Axis @@ -35,8 +38,6 @@ def size(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series({'a': 1, 'b': 2, 'c': 3}) >>> s.size @@ -62,8 +63,6 @@ def __iter__(self) -> Iterator: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -103,9 +102,6 @@ def astype(self, dtype): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Create a DataFrame: >>> d = {'col1': [1, 2], 'col2': [3, 4]} @@ -149,7 +145,7 @@ def astype(self, dtype): Note that this is equivalent of using ``to_datetime`` with ``unit='us'``: - >>> bpd.to_datetime(ser, unit='us', utc=True) + >>> bpd.to_datetime(ser, unit='us', utc=True) # doctest: +SKIP 0 2034-02-08 11:13:20.246789+00:00 1 2021-06-19 17:20:44.123101+00:00 2 2003-06-05 17:30:34.120101+00:00 @@ -223,6 +219,7 @@ def to_json( *, index: bool = True, lines: bool = False, + allow_large_results: Optional[bool] = None, ) -> Optional[str]: """Convert the object to a JSON string, written to Cloud Storage. @@ -278,6 +275,11 @@ def to_json( throw ValueError if incorrect 'orient' since others are not list-like. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. This parameter has + no effect when results are saved to Google Cloud Storage (GCS). + Returns: None or str: If path_or_buf is None, returns the resulting json format as a @@ -289,7 +291,13 @@ def to_json( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_csv(self, path_or_buf, *, index: bool = True) -> Optional[str]: + def to_csv( + self, + path_or_buf, + *, + index: bool = True, + allow_large_results: Optional[bool] = None, + ) -> Optional[str]: """Write object to a comma-separated values (csv) file on Cloud Storage. Args: @@ -313,6 +321,11 @@ def to_csv(self, path_or_buf, *, index: bool = True) -> Optional[str]: index (bool, default True): If True, write row names (index). + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. This parameter has + no effect when results are saved to Google Cloud Storage (GCS). + Returns: None or str: If path_or_buf is None, returns the resulting json format as a string. Otherwise returns None. @@ -330,8 +343,6 @@ def get(self, key, default=None): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame( ... [ @@ -441,8 +452,6 @@ def head(self, n: int = 5): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'animal': ['alligator', 'bee', 'falcon', 'lion', ... 'monkey', 'parrot', 'shark', 'whale', 'zebra']}) @@ -542,8 +551,6 @@ def sample( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({'num_legs': [2, 4, 8, 0], ... 'num_wings': [2, 0, 0, 0], ... 'num_specimen_seen': [10, 2, 1, 8]}, @@ -623,8 +630,6 @@ def dtypes(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'float': [1.0], 'int': [1], 'string': ['foo']}) >>> df.dtypes @@ -648,8 +653,6 @@ def copy(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Modification in the original Series will not affect the copy Series: @@ -721,9 +724,6 @@ def ffill(self, *, limit: Optional[int] = None): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[np.nan, 2, np.nan, 0], ... [3, 4, np.nan, 1], @@ -796,67 +796,80 @@ def bfill(self, *, limit: Optional[int] = None): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) def isna(self) -> NDFrame: - """Detect missing values. + """Detect missing (NULL) values. - Return a boolean same-sized object indicating if the values are NA. - NA values get mapped to True values. Everything else gets mapped to - False values. Characters such as empty strings ``''`` or - :attr:`numpy.inf` are not considered NA values. + Return a boolean same-sized object indicating if the values are NA + (NULL in BigQuery). NA/NULL values get mapped to True values. + Everything else gets mapped to False values. - **Examples:** + Note that empty strings ``''``, :attr:`numpy.inf`, and + :attr:`numpy.nan` are ***not*** considered NA values. This NA/NULL + logic differs from numpy, but it is the same as BigQuery and the + :class:`pandas.ArrowDtype`. - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np + **Examples:** >>> df = bpd.DataFrame(dict( - ... age=[5, 6, np.nan], - ... born=[bpd.NA, "1940-04-25", "1940-04-25"], - ... name=['Alfred', 'Batman', ''], - ... toy=[None, 'Batmobile', 'Joker'], + ... age=pd.Series(pa.array( + ... [5, 6, None, 4], + ... type=pa.int64(), + ... ), dtype=pd.ArrowDtype(pa.int64())), + ... born=pd.to_datetime([pd.NA, "1940-04-25", "1940-04-25", "1941-08-25"]), + ... name=['Alfred', 'Batman', '', 'Plastic Man'], + ... toy=[None, 'Batmobile', 'Joker', 'Play dough'], + ... height=pd.Series(pa.array( + ... [6.1, 5.9, None, np.nan], + ... type=pa.float64(), + ... ), dtype=pd.ArrowDtype(pa.float64())), ... )) >>> df - age born name toy - 0 5.0 Alfred - 1 6.0 1940-04-25 Batman Batmobile - 2 1940-04-25 Joker + age born name toy height + 0 5 Alfred 6.1 + 1 6 1940-04-25 00:00:00 Batman Batmobile 5.9 + 2 1940-04-25 00:00:00 Joker + 3 4 1941-08-25 00:00:00 Plastic Man Play dough NaN - [3 rows x 4 columns] + [4 rows x 5 columns] - Show which entries in a DataFrame are NA: + Show which entries in a DataFrame are NA (NULL in BigQuery): >>> df.isna() - age born name toy - 0 False True False True - 1 False False False False - 2 True False False False + age born name toy height + 0 False True False True False + 1 False False False False False + 2 True False False False True + 3 False False False False False - [3 rows x 4 columns] + [4 rows x 5 columns] >>> df.isnull() - age born name toy - 0 False True False True - 1 False False False False - 2 True False False False + age born name toy height + 0 False True False True False + 1 False False False False False + 2 True False False False True + 3 False False False False False - [3 rows x 4 columns] + [4 rows x 5 columns] - Show which entries in a Series are NA: + Show which entries in a Series are NA (NULL in BigQuery): - >>> ser = bpd.Series([5, None, 6, np.nan, bpd.NA]) + >>> ser = bpd.Series(pa.array( + ... [5, None, 6, np.nan, None], + ... type=pa.float64(), + ... ), dtype=pd.ArrowDtype(pa.float64())) >>> ser - 0 5 + 0 5.0 1 - 2 6 - 3 + 2 6.0 + 3 NaN 4 - dtype: Int64 + dtype: Float64 >>> ser.isna() 0 False 1 True 2 False - 3 True + 3 False 4 True dtype: boolean @@ -864,7 +877,7 @@ def isna(self) -> NDFrame: 0 False 1 True 2 False - 3 True + 3 False 4 True dtype: boolean @@ -893,6 +906,29 @@ def notna(self) -> NDFrame: notnull = notna + def take(self, indices, axis=0, **kwargs) -> NDFrame: + """Return the elements in the given positional indices along an axis. + + This means that we are not indexing according to actual values in the index + attribute of the object. We are indexing according to the actual position of + the element in the object. + + Args: + indices (list-like): + An array of ints indicating which positions to take. + axis ({0 or 'index', 1 or 'columns', None}, default 0): + The axis on which to select elements. 0 means that we are selecting rows, + 1 means that we are selecting columns. For Series this parameter is + unused and defaults to 0. + **kwargs: + For compatibility with numpy.take(). Has no effect on the output. + + Returns: + bigframes.pandas.DataFrame or bigframes.pandas.Series: + Same type as input object. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def filter( self, items=None, @@ -1002,6 +1038,10 @@ def rank( ascending (bool, default True): Whether or not the elements should be ranked in ascending order. + pct (bool, default False): + Whether or not to display the returned rankings in percentile + form. + Returns: bigframes.pandas.DataFrame or bigframes.pandas.Series: Return a Series or DataFrame with data ranks as values. @@ -1012,37 +1052,66 @@ def rolling( self, window, min_periods: int | None = None, + on: str | None = None, + closed: Literal["right", "left", "both", "neither"] = "right", ): """ Provide rolling window calculations. + **Examples:** + + >>> import bigframes.pandas as bpd + >>> s = bpd.Series([0,1,2,3,4]) + >>> s.rolling(window=3).min() + 0 + 1 + 2 0 + 3 1 + 4 2 + dtype: Int64 + + >>> df = bpd.DataFrame({'A': [0,1,2,3], 'B': [0,2,4,6]}) + >>> df.rolling(window=2, on='A', closed='both').sum() + A B + 0 0 + 1 1 2 + 2 2 6 + 3 3 12 + + [4 rows x 2 columns] + Args: - window (int, timedelta, str, offset, or BaseIndexer subclass): + window (int, pandas.Timedelta, numpy.timedelta64, datetime.timedelta, str): Size of the moving window. If an integer, the fixed number of observations used for each window. - If a timedelta, str, or offset, the time period of each window. Each - window will be a variable sized based on the observations included in - the time-period. This is only valid for datetime-like indexes. - To learn more about the offsets & frequency strings, please see `this link - `__. + If a string, the timedelta representation in string. This string + must be parsable by pandas.Timedelta(). - If a BaseIndexer subclass, the window boundaries - based on the defined ``get_window_bounds`` method. Additional rolling - keyword arguments, namely ``min_periods``, ``center``, ``closed`` and - ``step`` will be passed to ``get_window_bounds``. + Otherwise, the time range for each window. min_periods (int, default None): Minimum number of observations in window required to have a value; otherwise, result is ``np.nan``. - For a window that is specified by an offset, ``min_periods`` will default to 1. - For a window that is specified by an integer, ``min_periods`` will default to the size of the window. + For a window that is not spicified by an interger, ``min_periods`` will default + to 1. + + on (str, optional): + For a DataFrame, a column label on which to calculate the rolling window, + rather than the DataFrame’s index. + + closed (str, default 'right'): + If 'right', the first point in the window is excluded from calculations. + If 'left', the last point in the window is excluded from calculations. + If 'both', the no points in the window are excluded from calculations. + If 'neither', the first and last points in the window are excluded from calculations. + Returns: bigframes.core.window.Window: ``Window`` subclass if a ``win_type`` is passed. ``Rolling`` subclass if ``win_type`` is not passed. @@ -1076,9 +1145,6 @@ def pipe( Constructing a income DataFrame from a dictionary. - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> data = [[8000, 1000], [9500, np.nan], [5000, 2000]] >>> df = bpd.DataFrame(data, columns=['Salary', 'Others']) diff --git a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py index 1e30d827ca..01852beb9c 100644 --- a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py +++ b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py @@ -9,6 +9,8 @@ class providing the base-class of operations. """ from __future__ import annotations +from typing import Literal + from bigframes import constants @@ -17,6 +19,62 @@ class GroupBy: Class for grouping and aggregating relational data. """ + def describe(self, include: None | Literal["all"] = None): + """ + Generate descriptive statistics. + + Descriptive statistics include those that summarize the central + tendency, dispersion and shape of a + dataset's distribution, excluding ``NaN`` values. + + Args: + include ("all" or None, optional): + If "all": All columns of the input will be included in the output. + If None: The result will include all numeric columns. + + .. note:: + Percentile values are approximates only. + + .. note:: + For numeric data, the result's index will include ``count``, + ``mean``, ``std``, ``min``, ``max`` as well as lower, ``50`` and + upper percentiles. By default the lower percentile is ``25`` and the + upper percentile is ``75``. The ``50`` percentile is the + same as the median. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> df = bpd.DataFrame({"A": [1, 1, 1, 2, 2], "B": [0, 2, 8, 2, 7], "C": ["cat", "cat", "dog", "mouse", "cat"]}) + >>> df + A B C + 0 1 0 cat + 1 1 2 cat + 2 1 8 dog + 3 2 2 mouse + 4 2 7 cat + + [5 rows x 3 columns] + + >>> df.groupby("A").describe(include="all") + B C + count mean std min 25% 50% 75% max count nunique + A + 1 3 3.333333 4.163332 0 0 2 8 8 3 2 + 2 2 4.5 3.535534 2 2 2 7 7 2 2 + + [2 rows x 10 columns] + + Returns: + bigframes.pandas.DataFrame: + Summary statistics of the Series or Dataframe provided. + + Raises: + ValueError: + If unsupported ``include`` type is provided. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def any(self): """ Return True if any value in the group is true, else False. @@ -25,8 +83,6 @@ def any(self): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([1, 2, 0], index=lst) @@ -64,8 +120,6 @@ def all(self): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([1, 2, 0], index=lst) @@ -103,9 +157,6 @@ def count(self): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([1, 2, np.nan], index=lst) @@ -142,9 +193,6 @@ def mean( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 1, 2, 1, 2], ... 'B': [np.nan, 2, 3, 4, 5], ... 'C': [1, 2, 1, 1, 2]}, columns=['A', 'B', 'C']) @@ -203,9 +251,6 @@ def median( For SeriesGroupBy: >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> lst = ['a', 'a', 'a', 'b', 'b', 'b'] >>> ser = bpd.Series([7, 2, 8, 4, 3, 3], index=lst) >>> ser.groupby(level=0).median() @@ -244,7 +289,6 @@ def quantile(self, q=0.5, *, numeric_only: bool = False): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([ ... ['a', 1], ['a', 2], ['a', 3], ... ['b', 1], ['b', 3], ['b', 5] @@ -283,9 +327,6 @@ def std( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'b', 'b', 'b'] >>> ser = bpd.Series([7, 2, 8, 4, 3, 3], index=lst) @@ -330,9 +371,6 @@ def var( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'b', 'b', 'b'] >>> ser = bpd.Series([7, 2, 8, 4, 3, 3], index=lst) @@ -363,6 +401,76 @@ def var( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def rank( + self, + method: str = "average", + ascending: bool = True, + na_option: str = "keep", + ): + """ + Provide the rank of values within each group. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> df = bpd.DataFrame( + ... { + ... "group": ["a", "a", "a", "a", "a", "b", "b", "b", "b", "b"], + ... "value": [2, 4, 2, 3, 5, 1, 2, 4, 1, 5], + ... } + ... ) + >>> df + group value + 0 a 2 + 1 a 4 + 2 a 2 + 3 a 3 + 4 a 5 + 5 b 1 + 6 b 2 + 7 b 4 + 8 b 1 + 9 b 5 + + [10 rows x 2 columns] + >>> for method in ['average', 'min', 'max', 'dense', 'first']: + ... df[f'{method}_rank'] = df.groupby('group')['value'].rank(method) + >>> df + group value average_rank min_rank max_rank dense_rank first_rank + 0 a 2 1.5 1.0 2.0 1.0 1.0 + 1 a 4 4.0 4.0 4.0 3.0 4.0 + 2 a 2 1.5 1.0 2.0 1.0 2.0 + 3 a 3 3.0 3.0 3.0 2.0 3.0 + 4 a 5 5.0 5.0 5.0 4.0 5.0 + 5 b 1 1.5 1.0 2.0 1.0 1.0 + 6 b 2 3.0 3.0 3.0 2.0 3.0 + 7 b 4 4.0 4.0 4.0 3.0 4.0 + 8 b 1 1.5 1.0 2.0 1.0 2.0 + 9 b 5 5.0 5.0 5.0 4.0 5.0 + + [10 rows x 7 columns] + + Args: + method ({'average', 'min', 'max', 'first', 'dense'}, default 'average'): + * average: average rank of group. + * min: lowest rank in group. + * max: highest rank in group. + * first: ranks assigned in order they appear in the array. + * dense: like 'min', but rank always increases by 1 between groups. + ascending (bool, default True): + False for ranks by high (1) to low (N). + na_option ({'keep', 'top', 'bottom'}, default 'keep'): + * keep: leave NA values where they are. + * top: smallest rank if ascending. + * bottom: smallest rank if descending. + pct (bool, default False): + Compute percentage rank of data within each group + + Returns: + DataFrame with ranking of values within each group + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def skew( self, *, @@ -377,9 +485,6 @@ def skew( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series([390., 350., 357., np.nan, 22., 20., 30.], ... index=['Falcon', 'Falcon', 'Falcon', 'Falcon', @@ -413,8 +518,6 @@ def kurt( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'a', 'b', 'b', 'b', 'b', 'b'] >>> ser = bpd.Series([0, 1, 1, 0, 0, 1, 2, 4, 5], index=lst) @@ -446,8 +549,6 @@ def kurtosis( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'a', 'b', 'b', 'b', 'b', 'b'] >>> ser = bpd.Series([0, 1, 1, 0, 0, 1, 2, 4, 5], index=lst) @@ -466,6 +567,77 @@ def kurtosis( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def first(self, numeric_only: bool = False, min_count: int = -1): + """ + Compute the first entry of each column within each group. + + Defaults to skipping NA elements. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> df = bpd.DataFrame(dict(A=[1, 1, 3], B=[None, 5, 6], C=[1, 2, 3])) + >>> df.groupby("A").first() + B C + A + 1 5.0 1 + 3 6.0 3 + + [2 rows x 2 columns] + + >>> df.groupby("A").first(min_count=2) + B C + A + 1 1 + 3 + + [2 rows x 2 columns] + + Args: + numeric_only (bool, default False): + Include only float, int, boolean columns. If None, will attempt to use + everything, then use only numeric data. + min_count (int, default -1): + The required number of valid values to perform the operation. If fewer + than ``min_count`` valid values are present the result will be NA. + + Returns: + bigframes.pandas.DataFrame or bigframes.pandas.Series: + First of values within each group. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def last(self, numeric_only: bool = False, min_count: int = -1): + """ + Compute the last entry of each column within each group. + + Defaults to skipping NA elements. + + **Examples:** + + >>> df = bpd.DataFrame(dict(A=[1, 1, 3], B=[5, None, 6], C=[1, 2, 3])) + >>> df.groupby("A").last() + B C + A + 1 5.0 2 + 3 6.0 3 + + [2 rows x 2 columns] + + Args: + numeric_only (bool, default False): + Include only float, int, boolean columns. If None, will attempt to use + everything, then use only numeric data. + min_count (int, default -1): + The required number of valid values to perform the operation. If fewer + than ``min_count`` valid values are present the result will be NA. + + Returns: + bigframes.pandas.DataFrame or bigframes.pandas.Series: + Last of values within each group. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def sum( self, numeric_only: bool = False, @@ -478,8 +650,6 @@ def sum( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -523,9 +693,6 @@ def prod(self, numeric_only: bool = False, min_count: int = 0): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -559,9 +726,6 @@ def min( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -608,8 +772,6 @@ def max( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -647,14 +809,11 @@ def max( def cumcount(self, ascending: bool = True): """ Number each item in each group from 0 to the length of that group - 1. - (DataFrameGroupBy functionality is not yet available.) **Examples:** For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b', 'c'] >>> ser = bpd.Series([5, 1, 2, 3, 4], index=lst) @@ -691,9 +850,6 @@ def cumprod(self, *args, **kwargs): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([6, 2, 0], index=lst) @@ -730,9 +886,6 @@ def cumsum(self, *args, **kwargs): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([6, 2, 0], index=lst) @@ -769,9 +922,6 @@ def cummin(self, *args, numeric_only: bool = False, **kwargs): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([6, 2, 0], index=lst) @@ -808,9 +958,6 @@ def cummax(self, *args, numeric_only: bool = False, **kwargs): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([6, 2, 0], index=lst) @@ -849,9 +996,6 @@ def diff(self): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'b', 'b', 'b'] >>> ser = bpd.Series([7, 2, 8, 4, 3, 3], index=lst) @@ -895,9 +1039,6 @@ def shift(self, periods: int = 1): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -939,9 +1080,6 @@ def rolling(self, *args, **kwargs): **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> lst = ['a', 'a', 'a', 'a', 'e'] >>> ser = bpd.Series([1, 0, -2, -1, 2], index=lst) >>> ser.groupby(level=0).rolling(2).min() @@ -954,16 +1092,37 @@ def rolling(self, *args, **kwargs): dtype: Int64 Args: + window (int, pandas.Timedelta, numpy.timedelta64, datetime.timedelta, str): + Size of the moving window. + + If an integer, the fixed number of observations used for + each window. + + If a string, the timedelta representation in string. This string + must be parsable by pandas.Timedelta(). + + Otherwise, the time range for each window. + min_periods (int, default None): Minimum number of observations in window required to have a value; otherwise, result is ``np.nan``. - For a window that is specified by an offset, - ``min_periods`` will default to 1. - For a window that is specified by an integer, ``min_periods`` will default to the size of the window. + For a window that is not spicified by an interger, ``min_periods`` will default + to 1. + + on (str, optional): + For a DataFrame, a column label on which to calculate the rolling window, + rather than the DataFrame’s index. + + closed (str, default 'right'): + If 'right', the first point in the window is excluded from calculations. + If 'left', the last point in the window is excluded from calculations. + If 'both', the no points in the window are excluded from calculations. + If 'neither', the first and last points in the window are excluded from calculations. + Returns: bigframes.pandas.DataFrame or bigframes.pandas.Series: Return a new grouper with our rolling appended. @@ -977,9 +1136,6 @@ def expanding(self, *args, **kwargs): **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> lst = ['a', 'a', 'c', 'c', 'e'] >>> ser = bpd.Series([1, 0, -2, -1, 2], index=lst) >>> ser.groupby(level=0).expanding().min() @@ -1003,8 +1159,6 @@ def head(self, n: int = 5): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[1, 2], [1, 4], [5, 6]], ... columns=['A', 'B']) @@ -1032,10 +1186,8 @@ def size(self): **Examples:** - For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None + For SeriesGroupBy: >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([1, 2, 3], index=lst) @@ -1074,6 +1226,72 @@ def size(self): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def __iter__(self): + r""" + Groupby iterator. + + This method provides an iterator over the groups created by the ``resample`` + or ``groupby`` operation on the object. The method yields tuples where + the first element is the label (group key) corresponding to each group or + resampled bin, and the second element is the subset of the data that falls + within that group or bin. + + **Examples:** + + + For SeriesGroupBy: + + >>> lst = ["a", "a", "b"] + >>> ser = bpd.Series([1, 2, 3], index=lst) + >>> ser + a 1 + a 2 + b 3 + dtype: Int64 + >>> for x, y in ser.groupby(level=0): + ... print(f"{x}\n{y}\n") + a + a 1 + a 2 + dtype: Int64 + b + b 3 + dtype: Int64 + + For DataFrameGroupBy: + + >>> data = [[1, 2, 3], [1, 5, 6], [7, 8, 9]] + >>> df = bpd.DataFrame(data, columns=["a", "b", "c"]) + >>> df + a b c + 0 1 2 3 + 1 1 5 6 + 2 7 8 9 + + [3 rows x 3 columns] + >>> for x, y in df.groupby(by=["a"]): + ... print(f'{x}\n{y}\n') + (1,) + a b c + 0 1 2 3 + 1 1 5 6 + + [2 rows x 3 columns] + (7,) + + a b c + 2 7 8 9 + + [1 rows x 3 columns] + + + Returns: + Iterable[Label | Tuple, bigframes.pandas.Series | bigframes.pandas.DataFrame]: + Generator yielding sequence of (name, subsetted object) + for each group. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + class SeriesGroupBy(GroupBy): def agg(self, func): @@ -1082,9 +1300,6 @@ def agg(self, func): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4], index=[1, 1, 2, 2]) >>> s.groupby(level=0).agg(['min', 'max']) @@ -1115,9 +1330,6 @@ def aggregate(self, func): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4], index=[1, 1, 2, 2]) >>> s.groupby(level=0).aggregate(['min', 'max']) @@ -1148,9 +1360,6 @@ def nunique(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 3], index=lst) @@ -1165,6 +1374,32 @@ def nunique(self): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def value_counts( + self, + normalize: bool = False, + sort: bool = True, + ascending: bool = False, + dropna: bool = True, + ): + """ + Return a Series or DataFrame containing counts of unique rows. + + Args: + normalize (bool, default False): + Return proportions rather than frequencies. + sort (bool, default True): + Sort by frequencies. + ascending (bool, default False): + Sort in ascending order. + dropna (bool, default True): + Don't include counts of rows that contain NA values. + + Returns: + Series or DataFrame: + Series if the groupby as_index is True, otherwise DataFrame. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + class DataFrameGroupBy(GroupBy): def agg(self, func, **kwargs): @@ -1173,9 +1408,6 @@ def agg(self, func, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> data = {"A": [1, 1, 2, 2], ... "B": [1, 2, 3, 4], @@ -1233,9 +1465,6 @@ def aggregate(self, func, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> data = {"A": [1, 1, 2, 2], ... "B": [1, 2, 3, 4], @@ -1287,15 +1516,74 @@ def aggregate(self, func, **kwargs): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def corr( + self, + *, + numeric_only: bool = False, + ): + """ + Compute pairwise correlation of columns, excluding NA/null values. + + **Examples:** + + + >>> df = bpd.DataFrame({'A': [1, 2, 3], + ... 'B': [400, 500, 600], + ... 'C': [0.8, 0.4, 0.9]}) + >>> df.corr(numeric_only=True) + A B C + A 1.0 1.0 0.188982 + B 1.0 1.0 0.188982 + C 0.188982 0.188982 1.0 + + [3 rows x 3 columns] + + Args: + numeric_only(bool, default False): + Include only float, int, boolean, decimal data. + + Returns: + bigframes.pandas.DataFrame: Correlation matrix. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def cov( + self, + *, + numeric_only: bool = False, + ): + """ + Compute pairwise covariance of columns, excluding NA/null values. + + **Examples:** + + + >>> df = bpd.DataFrame({'A': [1, 2, 3], + ... 'B': [400, 500, 600], + ... 'C': [0.8, 0.4, 0.9]}) + >>> df.cov(numeric_only=True) + A B C + A 1.0 100.0 0.05 + B 100.0 10000.0 5.0 + C 0.05 5.0 0.07 + + [3 rows x 3 columns] + + Args: + numeric_only(bool, default False): + Include only float, int, boolean, decimal data. + + Returns: + bigframes.pandas.DataFrame: The covariance matrix of the series of the DataFrame. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def nunique(self): """ Return DataFrame with counts of unique elements in each position. **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'id': ['spam', 'egg', 'egg', 'spam', ... 'ham', 'ham'], @@ -1315,3 +1603,99 @@ def nunique(self): Number of unique values within a BigQuery DataFrame. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def value_counts( + self, + subset=None, + normalize: bool = False, + sort: bool = True, + ascending: bool = False, + dropna: bool = True, + ): + """ + Return a Series or DataFrame containing counts of unique rows. + + **Examples:** + + + >>> df = bpd.DataFrame({ + ... 'gender': ['male', 'male', 'female', 'male', 'female', 'male'], + ... 'education': ['low', 'medium', 'high', 'low', 'high', 'low'], + ... 'country': ['US', 'FR', 'US', 'FR', 'FR', 'FR'] + ... }) + + >>> df + gender education country + 0 male low US + 1 male medium FR + 2 female high US + 3 male low FR + 4 female high FR + 5 male low FR + + [6 rows x 3 columns] + + >>> df.groupby('gender').value_counts() + gender education country + female high FR 1 + US 1 + male low FR 2 + US 1 + medium FR 1 + Name: count, dtype: Int64 + + >>> df.groupby('gender').value_counts(ascending=True) + gender education country + female high FR 1 + US 1 + male low US 1 + medium FR 1 + low FR 2 + Name: count, dtype: Int64 + + >>> df.groupby('gender').value_counts(normalize=True) + gender education country + female high FR 0.5 + US 0.5 + male low FR 0.5 + US 0.25 + medium FR 0.25 + Name: proportion, dtype: Float64 + + >>> df.groupby('gender', as_index=False).value_counts() + gender education country count + 0 female high FR 1 + 1 female high US 1 + 2 male low FR 2 + 3 male low US 1 + 4 male medium FR 1 + + [5 rows x 4 columns] + + >>> df.groupby('gender', as_index=False).value_counts(normalize=True) + gender education country proportion + 0 female high FR 0.5 + 1 female high US 0.5 + 2 male low FR 0.5 + 3 male low US 0.25 + 4 male medium FR 0.25 + + [5 rows x 4 columns] + + Args: + subset (list-like, optional): + Columns to use when counting unique combinations. + normalize (bool, default False): + Return proportions rather than frequencies. + sort (bool, default True): + Sort by frequencies. + ascending (bool, default False): + Sort in ascending order. + dropna (bool, default True): + Don't include counts of rows that contain NA values. + + Returns: + Series or DataFrame: + Series if the groupby as_index is True, otherwise DataFrame. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py index f34612cb11..a0388317be 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py @@ -12,9 +12,6 @@ def day(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="D") ... ) @@ -42,9 +39,6 @@ def dayofweek(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range('2016-12-31', '2017-01-08', freq='D').to_series() ... ) @@ -66,6 +60,144 @@ def dayofweek(self): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property + def day_of_week(self): + """The day of the week with Monday=0, Sunday=6. + + Return the day of the week. It is assumed the week starts on + Monday, which is denoted by 0 and ends on Sunday, which is denoted + by 6. + + **Examples:** + + >>> s = bpd.Series( + ... pd.date_range('2016-12-31', '2017-01-08', freq='D').to_series() + ... ) + >>> s.dt.day_of_week + 2016-12-31 00:00:00 5 + 2017-01-01 00:00:00 6 + 2017-01-02 00:00:00 0 + 2017-01-03 00:00:00 1 + 2017-01-04 00:00:00 2 + 2017-01-05 00:00:00 3 + 2017-01-06 00:00:00 4 + 2017-01-07 00:00:00 5 + 2017-01-08 00:00:00 6 + dtype: Int64 + + Returns: + Series: Containing integers indicating the day number. + """ + + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def weekday(self): + """The day of the week with Monday=0, Sunday=6. + + Return the day of the week. It is assumed the week starts on + Monday, which is denoted by 0 and ends on Sunday, which is denoted + by 6. + + **Examples:** + + >>> s = bpd.Series( + ... pd.date_range('2016-12-31', '2017-01-08', freq='D').to_series() + ... ) + >>> s.dt.weekday + 2016-12-31 00:00:00 5 + 2017-01-01 00:00:00 6 + 2017-01-02 00:00:00 0 + 2017-01-03 00:00:00 1 + 2017-01-04 00:00:00 2 + 2017-01-05 00:00:00 3 + 2017-01-06 00:00:00 4 + 2017-01-07 00:00:00 5 + 2017-01-08 00:00:00 6 + dtype: Int64 + + Returns: + Series: Containing integers indicating the day number. + """ + + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def day_name(self): + """ + Return the day names in english. + + **Examples:** + >>> s = bpd.Series(pd.date_range(start="2018-01-01", freq="D", periods=3)) + >>> s + 0 2018-01-01 00:00:00 + 1 2018-01-02 00:00:00 + 2 2018-01-03 00:00:00 + dtype: timestamp[us][pyarrow] + >>> s.dt.day_name() + 0 Monday + 1 Tuesday + 2 Wednesday + dtype: string + + Returns: + Series: Series of day names. + + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def dayofyear(self): + """The ordinal day of the year. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> s = bpd.Series( + ... pd.date_range('2016-12-28', '2017-01-03', freq='D').to_series() + ... ) + >>> s.dt.dayofyear + 2016-12-28 00:00:00 363 + 2016-12-29 00:00:00 364 + 2016-12-30 00:00:00 365 + 2016-12-31 00:00:00 366 + 2017-01-01 00:00:00 1 + 2017-01-02 00:00:00 2 + 2017-01-03 00:00:00 3 + dtype: Int64 + + Returns: + Series: Containing integers indicating the day number. + """ + + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def day_of_year(self): + """The ordinal day of the year. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> s = bpd.Series( + ... pd.date_range('2016-12-28', '2017-01-03', freq='D').to_series() + ... ) + >>> s.dt.day_of_year + 2016-12-28 00:00:00 363 + 2016-12-29 00:00:00 364 + 2016-12-30 00:00:00 365 + 2016-12-31 00:00:00 366 + 2017-01-01 00:00:00 1 + 2017-01-02 00:00:00 2 + 2017-01-03 00:00:00 3 + dtype: Int64 + + Returns: + Series: Containing integers indicating the day number. + """ + + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property def date(self): """Returns a Series with the date part of Timestamps without time and @@ -78,7 +210,6 @@ def date(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "2/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%d/%m/%Y %H:%M:%S%Ez") >>> s @@ -99,9 +230,7 @@ def hour(self): **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="h") ... ) @@ -125,9 +254,7 @@ def minute(self): **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="min") ... ) @@ -151,9 +278,6 @@ def month(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="M") ... ) @@ -171,15 +295,43 @@ def month(self): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def isocalendar(self): + """ + Calculate year, week, and day according to the ISO 8601 standard. + + **Examples:** + + >>> s = bpd.Series( + ... pd.date_range('2009-12-27', '2010-01-04', freq='d').to_series() + ... ) + >>> s.dt.isocalendar() + year week day + 2009-12-27 00:00:00 2009 52 7 + 2009-12-28 00:00:00 2009 53 1 + 2009-12-29 00:00:00 2009 53 2 + 2009-12-30 00:00:00 2009 53 3 + 2009-12-31 00:00:00 2009 53 4 + 2010-01-01 00:00:00 2009 53 5 + 2010-01-02 00:00:00 2009 53 6 + 2010-01-03 00:00:00 2009 53 7 + 2010-01-04 00:00:00 2010 1 1 + + [9 rows x 3 columns] + + + Returns: DataFrame + With columns year, week and day. + + + """ + @property def second(self): """The seconds of the datetime. **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="s") ... ) @@ -208,7 +360,6 @@ def time(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "2/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%m/%d/%Y %H:%M:%S%Ez") >>> s @@ -230,7 +381,6 @@ def quarter(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "4/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%m/%d/%Y %H:%M:%S%Ez") >>> s @@ -251,9 +401,6 @@ def year(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="Y") ... ) @@ -271,6 +418,65 @@ def year(self): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property + def days(self): + """The numebr of days for each element + + **Examples:** + + >>> s = bpd.Series([pd.Timedelta("4d3m2s1us")]) + >>> s + 0 4 days 00:03:02.000001 + dtype: duration[us][pyarrow] + >>> s.dt.days + 0 4 + dtype: Int64 + """ + + @property + def seconds(self): + """Number of seconds (>= 0 and less than 1 day) for each element. + + **Examples:** + + >>> s = bpd.Series([pd.Timedelta("4d3m2s1us")]) + >>> s + 0 4 days 00:03:02.000001 + dtype: duration[us][pyarrow] + >>> s.dt.seconds + 0 182 + dtype: Int64 + """ + + @property + def microseconds(self): + """Number of microseconds (>= 0 and less than 1 second) for each element. + + **Examples:** + + >>> s = bpd.Series([pd.Timedelta("4d3m2s1us")]) + >>> s + 0 4 days 00:03:02.000001 + dtype: duration[us][pyarrow] + >>> s.dt.microseconds + 0 1 + dtype: Int64 + """ + + def total_seconds(self): + """Return total duration of each element expressed in seconds. + + **Examples:** + + >>> s = bpd.Series([pd.Timedelta("1d1m1s1us")]) + >>> s + 0 1 days 00:01:01.000001 + dtype: duration[us][pyarrow] + >>> s.dt.total_seconds() + 0 86461.000001 + dtype: Float64 + """ + @property def tz(self): """Return the timezone. @@ -278,7 +484,6 @@ def tz(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "2/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%m/%d/%Y %H:%M:%S%Ez") >>> s @@ -301,7 +506,6 @@ def unit(self) -> str: **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "2/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%m/%d/%Y %H:%M:%S%Ez") >>> s diff --git a/third_party/bigframes_vendored/pandas/core/indexes/base.py b/third_party/bigframes_vendored/pandas/core/indexes/base.py index 59504ee68c..d21056a8cf 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/base.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/base.py @@ -1,8 +1,10 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/indexes/base.py from __future__ import annotations +from collections.abc import Hashable import typing +import bigframes from bigframes import constants @@ -30,8 +32,6 @@ def name(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3], name='x') >>> idx @@ -61,8 +61,6 @@ def values(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -84,8 +82,6 @@ def ndim(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -119,8 +115,6 @@ def size(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None For Series: @@ -154,8 +148,6 @@ def is_monotonic_increasing(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bool(bpd.Index([1, 2, 3]).is_monotonic_increasing) True @@ -179,8 +171,6 @@ def is_monotonic_decreasing(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bool(bpd.Index([3, 2, 1]).is_monotonic_decreasing) True @@ -204,8 +194,6 @@ def from_frame(cls, frame) -> Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([['HI', 'Temp'], ['HI', 'Precip'], ... ['NJ', 'Temp'], ['NJ', 'Precip']], @@ -244,8 +232,6 @@ def shape(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -266,8 +252,6 @@ def nlevels(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> mi = bpd.MultiIndex.from_arrays([['a'], ['b'], ['c']]) >>> mi @@ -288,8 +272,6 @@ def is_unique(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 5, 7, 7]) >>> idx.is_unique @@ -311,8 +293,6 @@ def has_duplicates(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 5, 7, 7]) >>> bool(idx.has_duplicates) @@ -334,8 +314,6 @@ def dtype(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -362,8 +340,6 @@ def T(self) -> Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -390,6 +366,36 @@ def T(self) -> Index: """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property + def str(self): + """ + Vectorized string functions for Series and Index. + + NAs stay NA unless handled otherwise by a particular method. Patterned + after Python’s string methods, with some inspiration from R’s stringr package. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> s = bpd.Series(["A_Str_Series"]) + >>> s + 0 A_Str_Series + dtype: string + + >>> s.str.lower() + 0 a_str_series + dtype: string + + >>> s.str.replace("_", "") + 0 AStrSeries + dtype: string + + Returns: + bigframes.operations.strings.StringMethods: + An accessor containing string methods. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def copy( self, name=None, @@ -401,8 +407,6 @@ def copy( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['a', 'b', 'c']) >>> new_idx = idx.copy() @@ -436,8 +440,6 @@ def astype(self, dtype): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -485,8 +487,6 @@ def get_level_values(self, level) -> Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(list('abc')) >>> idx @@ -515,8 +515,6 @@ def to_series(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['Ant', 'Bear', 'Cow'], name='animal') @@ -569,8 +567,6 @@ def isin(self, values): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1,2,3]) >>> idx @@ -609,8 +605,6 @@ def all(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None True, because nonzero integers are considered True. @@ -637,8 +631,6 @@ def any(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> index = bpd.Index([0, 1, 2]) >>> bool(index.any()) @@ -663,8 +655,6 @@ def min(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([3, 2, 1]) >>> int(idx.min()) @@ -685,8 +675,6 @@ def max(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([3, 2, 1]) >>> int(idx.max()) @@ -711,8 +699,6 @@ def argmin(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Consider dataset containing cereal calories @@ -740,6 +726,45 @@ def argmin(self) -> int: """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def get_loc( + self, key: typing.Any + ) -> typing.Union[int, slice, bigframes.series.Series]: + """ + Get integer location, slice or boolean mask for requested label. + + **Examples:** + + + >>> unique_index = bpd.Index(list('abc')) + >>> unique_index.get_loc('b') + 1 + + >>> monotonic_index = bpd.Index(list('abbc')) + >>> monotonic_index.get_loc('b') + slice(1, 3, None) + + >>> non_monotonic_index = bpd.Index(list('abcb')) + >>> non_monotonic_index.get_loc('b') + 0 False + 1 True + 2 False + 3 True + dtype: boolean + + Args: + key: Label to get the location for. + + Returns: + Union[int, slice, bigframes.pandas.Series]: + Integer position of the label for unique indexes. + Slice object for monotonic indexes with duplicates. + Boolean Series mask for non-monotonic indexes with duplicates. + + Raises: + KeyError: If the key is not found in the index. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def argmax(self) -> int: """ Return int position of the largest value in the Series. @@ -751,8 +776,6 @@ def argmax(self) -> int: Consider dataset containing cereal calories - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series({'Corn Flakes': 100.0, 'Almond Delight': 110.0, ... 'Cinnamon Toast Crunch': 120.0, 'Cocoa Puff': 110.0}) @@ -785,8 +808,6 @@ def nunique(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 3, 5, 7, 7]) >>> s @@ -817,8 +838,6 @@ def sort_values( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([10, 100, 1, 1000]) >>> idx @@ -861,9 +880,6 @@ def value_counts( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> index = bpd.Index([3, 1, 2, 3, 4, np.nan]) >>> index.value_counts() @@ -914,17 +930,23 @@ def value_counts( def fillna(self, value) -> Index: """ - Fill NA/NaN values with the specified value. + Fill NA (NULL in BigQuery) values using the specified method. - **Examples:** + Note that empty strings ``''``, :attr:`numpy.inf`, and + :attr:`numpy.nan` are ***not*** considered NA values. This NA/NULL + logic differs from numpy, but it is the same as BigQuery and the + :class:`pandas.ArrowDtype`. - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None + **Examples:** - >>> idx = bpd.Index([np.nan, np.nan, 3]) + >>> idx = bpd.Index( + ... pa.array([None, np.nan, 3, None], type=pa.float64()), + ... dtype=pd.ArrowDtype(pa.float64()), + ... ) + >>> idx + Index([, nan, 3.0, ], dtype='Float64') >>> idx.fillna(0) - Index([0.0, 0.0, 3.0], dtype='Float64') + Index([0.0, nan, 3.0, 0.0], dtype='Float64') Args: value (scalar): @@ -940,7 +962,7 @@ def fillna(self, value) -> Index: """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def rename(self, name) -> Index: + def rename(self, name, *, inplace): """ Alter Index or MultiIndex name. @@ -949,8 +971,6 @@ def rename(self, name) -> Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['A', 'C', 'A', 'B'], name='score') >>> idx.rename('grade') @@ -959,10 +979,13 @@ def rename(self, name) -> Index: Args: name (label or list of labels): Name(s) to set. + inplace (bool): + Default False. Modifies the object directly, instead of + creating a new Index or MultiIndex. Returns: - bigframes.pandas.Index: - The same type as the caller. + bigframes.pandas.Index | None: + The same type as the caller or None if ``inplace=True``. Raises: ValueError: @@ -976,8 +999,6 @@ def drop(self, labels) -> Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['a', 'b', 'c']) >>> idx.drop(['a']) @@ -996,9 +1017,6 @@ def dropna(self, how: typing.Literal["all", "any"] = "any"): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, np.nan, 3]) >>> idx.dropna() @@ -1025,7 +1043,6 @@ def drop_duplicates(self, *, keep: str = "first"): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Generate an pandas.Index with duplicate values. @@ -1061,13 +1078,53 @@ def drop_duplicates(self, *, keep: str = "first"): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_numpy(self, dtype): + def unique(self, level: Hashable | int | None = None): + """ + Returns unique values in the index. + + **Examples:** + + >>> idx = bpd.Index([1, 1, 2, 3, 3]) + >>> idx.unique() + Index([1, 2, 3], dtype='Int64') + + Args: + level (int or hashable, optional): + Only return values from specified level (for MultiIndex). + If int, gets the level by integer position, else by level name. + + Returns: + bigframes.pandas.Index + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def item(self, *args, **kwargs): + """Return the first element of the underlying data as a Python scalar. + + **Examples:** + + >>> s = bpd.Series([1], index=['a']) + >>> s.index.item() + 'a' + + Returns: + scalar: The first element of Index. + + Raises: + ValueError: If the data is not length = 1. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def to_numpy(self, dtype, *, allow_large_results=None): """ A NumPy ndarray representing the values in this Series or Index. Args: dtype: The dtype to pass to :meth:`numpy.asarray`. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. **kwargs: Additional keywords passed through to the ``to_numpy`` method of the underlying array (for extension arrays). diff --git a/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py b/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py new file mode 100644 index 0000000000..f22554e174 --- /dev/null +++ b/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py @@ -0,0 +1,88 @@ +# Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/indexes/datetimes.py + +from __future__ import annotations + +from bigframes_vendored import constants +from bigframes_vendored.pandas.core.indexes import base + + +class DatetimeIndex(base.Index): + """Immutable sequence used for indexing and alignment with datetime-like values""" + + @property + def year(self) -> base.Index: + """The year of the datetime + + **Examples:** + + + >>> idx = bpd.Index([pd.Timestamp("20250215")]) + >>> idx.year + Index([2025], dtype='Int64') + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def month(self) -> base.Index: + """The month as January=1, December=12. + + **Examples:** + + + >>> idx = bpd.Index([pd.Timestamp("20250215")]) + >>> idx.month + Index([2], dtype='Int64') + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def day(self) -> base.Index: + """The day of the datetime. + + **Examples:** + + + >>> idx = bpd.Index([pd.Timestamp("20250215")]) + >>> idx.day + Index([15], dtype='Int64') + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def day_of_week(self) -> base.Index: + """The day of the week with Monday=0, Sunday=6. + + **Examples:** + + + >>> idx = bpd.Index([pd.Timestamp("20250215")]) + >>> idx.day_of_week + Index([5], dtype='Int64') + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def dayofweek(self) -> base.Index: + """The day of the week with Monday=0, Sunday=6. + + **Examples:** + + + >>> idx = bpd.Index([pd.Timestamp("20250215")]) + >>> idx.dayofweek + Index([5], dtype='Int64') + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + @property + def weekday(self) -> base.Index: + """The day of the week with Monday=0, Sunday=6. + + **Examples:** + + + >>> idx = bpd.Index([pd.Timestamp("20250215")]) + >>> idx.weekday + Index([5], dtype='Int64') + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/pandas/core/indexes/multi.py b/third_party/bigframes_vendored/pandas/core/indexes/multi.py index a882aa40e3..018e638de3 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/multi.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/multi.py @@ -25,8 +25,6 @@ def from_tuples( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> tuples = [(1, 'red'), (1, 'blue'), ... (2, 'red'), (2, 'blue')] >>> bpd.MultiIndex.from_tuples(tuples, names=('number', 'color')) @@ -62,8 +60,6 @@ def from_arrays( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> arrays = [[1, 1, 2, 2], ['red', 'blue', 'red', 'blue']] >>> bpd.MultiIndex.from_arrays(arrays, names=('number', 'color')) MultiIndex([(1, 'red'), diff --git a/third_party/bigframes_vendored/pandas/core/reshape/merge.py b/third_party/bigframes_vendored/pandas/core/reshape/merge.py index 66fb2c2160..49ff409c9a 100644 --- a/third_party/bigframes_vendored/pandas/core/reshape/merge.py +++ b/third_party/bigframes_vendored/pandas/core/reshape/merge.py @@ -13,6 +13,8 @@ def merge( *, left_on=None, right_on=None, + left_index: bool = False, + right_index: bool = False, sort=False, suffixes=("_x", "_y"), ): @@ -61,6 +63,10 @@ def merge( right_on (label or list of labels): Columns to join on in the right DataFrame. Either on or left_on + right_on must be passed in. + left_index (bool, default False): + Use the index from the left DataFrame as the join key. + right_index (bool, default False): + Use the index from the right DataFrame as the join key. sort: Default False. Sort the join keys lexicographically in the result DataFrame. If False, the order of the join keys depends diff --git a/third_party/bigframes_vendored/pandas/core/reshape/pivot.py b/third_party/bigframes_vendored/pandas/core/reshape/pivot.py new file mode 100644 index 0000000000..8cc33525a4 --- /dev/null +++ b/third_party/bigframes_vendored/pandas/core/reshape/pivot.py @@ -0,0 +1,57 @@ +# Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/reshape/pivot.py +from __future__ import annotations + +from bigframes import constants + + +def crosstab( + index, + columns, + values=None, + rownames=None, + colnames=None, + aggfunc=None, +): + """ + Compute a simple cross tabulation of two (or more) factors. + + By default, computes a frequency table of the factors unless an + array of values and an aggregation function are passed. + + **Examples:** + >>> a = np.array(["foo", "foo", "foo", "foo", "bar", "bar", + ... "bar", "bar", "foo", "foo", "foo"], dtype=object) + >>> b = np.array(["one", "one", "one", "two", "one", "one", + ... "one", "two", "two", "two", "one"], dtype=object) + >>> c = np.array(["dull", "dull", "shiny", "dull", "dull", "shiny", + ... "shiny", "dull", "shiny", "shiny", "shiny"], + ... dtype=object) + >>> bpd.crosstab(a, [b, c], rownames=['a'], colnames=['b', 'c']) + b one two + c dull shiny dull shiny + a + bar 1 2 1 0 + foo 2 2 1 2 + + [2 rows x 4 columns] + + Args: + index (array-like, Series, or list of arrays/Series): + Values to group by in the rows. + columns (array-like, Series, or list of arrays/Series): + Values to group by in the columns. + values (array-like, optional): + Array of values to aggregate according to the factors. + Requires `aggfunc` be specified. + rownames (sequence, default None): + If passed, must match number of row arrays passed. + colnames (sequence, default None): + If passed, must match number of column arrays passed. + aggfunc (function, optional): + If specified, requires `values` be specified as well. + + Returns: + DataFrame: + Cross tabulation of the data. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/pandas/core/reshape/tile.py b/third_party/bigframes_vendored/pandas/core/reshape/tile.py index 6bda14b025..0f42433384 100644 --- a/third_party/bigframes_vendored/pandas/core/reshape/tile.py +++ b/third_party/bigframes_vendored/pandas/core/reshape/tile.py @@ -4,14 +4,23 @@ """ from __future__ import annotations +import typing + +import pandas as pd + from bigframes import constants def cut( x, - bins, + bins: typing.Union[ + int, + pd.IntervalIndex, + typing.Iterable, + ], *, - labels=None, + right: bool = True, + labels: typing.Union[typing.Iterable[str], bool, None] = None, ): """ Bin values into discrete intervals. @@ -22,13 +31,9 @@ def cut( age ranges. Supports binning into an equal number of bins, or a pre-specified array of bins. - ``labels=False`` implies you just want the bins back. - **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([0, 1, 5, 10]) >>> s 0 0 @@ -46,7 +51,16 @@ def cut( 3 {'left_exclusive': 7.5, 'right_inclusive': 10.0} dtype: struct[pyarrow] - Cut with an integer (equal-width bins) and labels=False: + Cut with the same bins, but assign them specific labels: + + >>> bpd.cut(s, bins=3, labels=["bad", "medium", "good"]) + 0 bad + 1 bad + 2 medium + 3 good + dtype: string + + `labels=False` implies you want the bins back. >>> bpd.cut(s, bins=4, labels=False) 0 0 @@ -57,8 +71,6 @@ def cut( Cut with pd.IntervalIndex, requires importing pandas for IntervalIndex: - >>> import pandas as pd - >>> interval_index = pd.IntervalIndex.from_tuples([(0, 1), (1, 5), (5, 20)]) >>> bpd.cut(s, bins=interval_index) 0 @@ -87,8 +99,18 @@ def cut( 3 {'left_exclusive': 5, 'right_inclusive': 20} dtype: struct[pyarrow] + Cut with an interable of ints, where intervals are left-inclusive and right-exclusive. + + >>> bins_ints = [0, 1, 5, 20] + >>> bpd.cut(s, bins=bins_ints, right=False) + 0 {'left_inclusive': 0, 'right_exclusive': 1} + 1 {'left_inclusive': 1, 'right_exclusive': 5} + 2 {'left_inclusive': 5, 'right_exclusive': 20} + 3 {'left_inclusive': 5, 'right_exclusive': 20} + dtype: struct[pyarrow] + Args: - x (Series): + x (array-like): The input Series to be binned. Must be 1-dimensional. bins (int, pd.IntervalIndex, Iterable): The criteria to bin by. @@ -103,10 +125,16 @@ def cut( Iterable of numerics: Defines the exact bins by using the interval between each item and its following item. The items must be monotonically increasing. - labels (None): + right (bool, default True): + Indicates whether `bins` includes the rightmost edge or not. If + ``right == True`` (the default), then the `bins` ``[1, 2, 3, 4]`` + indicate (1,2], (2,3], (3,4]. This argument is ignored when + `bins` is an IntervalIndex. + labels (bool, Iterable, default None): Specifies the labels for the returned bins. Must be the same length as the resulting bins. If False, returns only integer indicators of the - bins. This affects the type of the output container. + bins. This affects the type of the output container. This argument is + ignored when `bins` is an IntervalIndex. If True, raises an error. Returns: bigframes.pandas.Series: diff --git a/third_party/bigframes_vendored/pandas/core/series.py b/third_party/bigframes_vendored/pandas/core/series.py index 57f7dfbb79..2c0f493d81 100644 --- a/third_party/bigframes_vendored/pandas/core/series.py +++ b/third_party/bigframes_vendored/pandas/core/series.py @@ -3,6 +3,7 @@ """ from __future__ import annotations +import datetime from typing import ( Hashable, IO, @@ -19,8 +20,9 @@ from bigframes_vendored.pandas.core.generic import NDFrame import numpy import numpy as np -from pandas._libs import lib +import pandas as pd from pandas._typing import Axis, FilePath, NaPosition, WriteBuffer +from pandas.api import extensions as pd_ext from bigframes import constants @@ -38,9 +40,6 @@ def dt(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None - >>> seconds_series = bpd.Series(pd.date_range("2000-01-01", periods=3, freq="s")) >>> seconds_series 0 2000-01-01 00:00:00 @@ -110,8 +109,6 @@ def index(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can access the index of a Series via ``index`` property. @@ -161,13 +158,11 @@ def shape(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 4, 9, 16]) >>> s.shape (4,) - >>> s = bpd.Series(['Alice', 'Bob', bpd.NA]) + >>> s = bpd.Series(['Alice', 'Bob', pd.NA]) >>> s.shape (3,) """ @@ -180,8 +175,6 @@ def dtype(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s.dtype @@ -200,8 +193,6 @@ def name(self) -> Hashable: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None For a Series: @@ -248,8 +239,6 @@ def hasnans(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, None]) >>> s @@ -272,8 +261,6 @@ def T(self) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -297,8 +284,6 @@ def transpose(self) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -321,9 +306,12 @@ def transpose(self) -> Series: def reset_index( self, + level=None, *, drop: bool = False, - name=lib.no_default, + name=pd_ext.no_default, + inplace: bool = False, + allow_duplicates: Optional[bool] = None, ) -> DataFrame | Series | None: """ Generate a new DataFrame or Series with the index reset. @@ -334,9 +322,6 @@ def reset_index( **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4], name='foo', ... index=['a', 'b', 'c', 'd']) @@ -399,6 +384,9 @@ def reset_index( [4 rows x 3 columns] Args: + level (int, str, tuple, or list, default optional): + For a Series with a MultiIndex, only remove the specified levels + from the index. Removes all levels by default. drop (bool, default False): Just reset the index, without inserting it as a column in the new DataFrame. @@ -406,6 +394,10 @@ def reset_index( The name to use for the column containing the original Series values. Uses ``self.name`` by default. This argument is ignored when `drop` is True. + inplace (bool, default False): + Modify the Series in place (do not create a new object). + allow_duplicates (bool, optional, default None): + Allow duplicate column labels to be created. Returns: bigframes.pandas.Series or bigframes.pandas.DataFrame or None: @@ -430,8 +422,6 @@ def keys(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3], index=[0, 1, 2]) >>> s.keys() @@ -458,6 +448,8 @@ def to_string( name: bool = False, max_rows: int | None = None, min_rows: int | None = None, + *, + allow_large_results: Optional[bool] = None, ) -> str | None: """ Render a string representation of the Series. @@ -486,6 +478,9 @@ def to_string( min_rows (int, optional): The number of rows to display in a truncated repr (when number of rows is above `max_rows`). + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. Returns: str or None: @@ -498,6 +493,8 @@ def to_markdown( buf: IO[str] | None = None, mode: str = "wt", index: bool = True, + *, + allow_large_results: Optional[bool] = None, **kwargs, ) -> str | None: """ @@ -505,8 +502,6 @@ def to_markdown( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["elk", "pig", "dog", "quetzal"], name="animal") >>> print(s.to_markdown()) @@ -537,6 +532,9 @@ def to_markdown( Buffer to write to. If None, the output is returned as a string. mode (str, optional): Mode in which file is opened, "wt" by default. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. index (bool, optional, default True): Add index (row) labels. @@ -546,15 +544,18 @@ def to_markdown( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_dict(self, into: type[dict] = dict) -> Mapping: + def to_dict( + self, + into: type[dict] = dict, + *, + allow_large_results: Optional[bool] = None, + ) -> Mapping: """ Convert Series to {label -> value} dict or dict-like object. **Examples:** - >>> import bigframes.pandas as bpd >>> from collections import OrderedDict, defaultdict - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4]) >>> s.to_dict() @@ -573,6 +574,9 @@ def to_dict(self, into: type[dict] = dict) -> Mapping: object. Can be the actual class or an empty instance of the mapping type you want. If you want a collections.defaultdict, you must pass it initialized. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. Returns: collections.abc.Mapping: @@ -589,8 +593,6 @@ def to_frame(self, name=None) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["a", "b", "c"], ... name="vals") @@ -611,7 +613,13 @@ def to_frame(self, name=None) -> DataFrame: """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_excel(self, excel_writer, sheet_name): + def to_excel( + self, + excel_writer, + sheet_name, + *, + allow_large_results=None, + ): """ Write Series to an Excel sheet. @@ -630,10 +638,22 @@ def to_excel(self, excel_writer, sheet_name): File path or existing ExcelWriter. sheet_name (str, default 'Sheet1'): Name of sheet to contain Series. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_latex(self, buf=None, columns=None, header=True, index=True, **kwargs): + def to_latex( + self, + buf=None, + columns=None, + header=True, + index=True, + *, + allow_large_results=None, + **kwargs, + ): """ Render object to a LaTeX tabular, longtable, or nested table. @@ -647,6 +667,9 @@ def to_latex(self, buf=None, columns=None, header=True, index=True, **kwargs): it is assumed to be aliases for the column names. index (bool, default True): Write row names (index). + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. Returns: str or None: @@ -655,7 +678,7 @@ def to_latex(self, buf=None, columns=None, header=True, index=True, **kwargs): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def tolist(self) -> list: + def tolist(self, *, allow_large_results: Optional[bool] = None) -> list: """ Return a list of the values. @@ -665,8 +688,6 @@ def tolist(self) -> list: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s @@ -678,6 +699,11 @@ def tolist(self) -> list: >>> s.to_list() [1, 2, 3] + Args: + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. + Returns: list: list of the values. @@ -686,15 +712,14 @@ def tolist(self) -> list: to_list = tolist - def to_numpy(self, dtype, copy=False, na_value=None): + def to_numpy( + self, dtype, copy=False, na_value=pd_ext.no_default, *, allow_large_results=None + ): """ A NumPy ndarray representing the values in this Series or Index. **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(pd.Categorical(['a', 'b', 'a'])) >>> ser.to_numpy() @@ -727,6 +752,9 @@ def to_numpy(self, dtype, copy=False, na_value=None): na_value (Any, optional): The value to use for missing values. The default value depends on `dtype` and the type of the array. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. ``**kwargs``: Additional keywords passed through to the ``to_numpy`` method of the underlying array (for extension arrays). @@ -738,14 +766,12 @@ def to_numpy(self, dtype, copy=False, na_value=None): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_pickle(self, path, **kwargs): + def to_pickle(self, path, *, allow_large_results=None, **kwargs): """ Pickle (serialize) object to file. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> original_df = bpd.DataFrame({"foo": range(5), "bar": range(5, 10)}) >>> original_df @@ -776,13 +802,16 @@ def to_pickle(self, path, **kwargs): String, path object (implementing ``os.PathLike[str]``), or file-like object implementing a binary ``write()`` function. File path where the pickled object will be stored. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow + large query results over the default size limit of 10 GB. Returns: None """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_xarray(self): + def to_xarray(self, *, allow_large_results=None): """ Return an xarray object from the pandas object. @@ -791,6 +820,9 @@ def to_xarray(self): Data in the pandas structure converted to Dataset if the object is a DataFrame, or a DataArray if the object is a Series. + allow_large_results (bool, default None): + If not None, overrides the global setting to allow or disallow large + query results over the default size limit of 10 GB. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) @@ -800,8 +832,6 @@ def agg(self, func): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4]) >>> s @@ -837,10 +867,8 @@ def count(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([0.0, 1.0, bpd.NA]) + >>> s = bpd.Series([0.0, 1.0, pd.NA]) >>> s 0 0.0 1 1.0 @@ -863,8 +891,6 @@ def nunique(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 3, 5, 7, 7]) >>> s @@ -898,8 +924,6 @@ def unique(self, keep_order=True) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, 1, 3, 3], name='A') >>> s @@ -941,8 +965,6 @@ def mode(self) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, 4, 8, 2, 4, None]) >>> s.mode() @@ -966,11 +988,9 @@ def drop_duplicates( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Generate a Series with duplicated entries. + >>> import bigframes.pandas as bpd >>> s = bpd.Series(['llama', 'cow', 'llama', 'beetle', 'llama', 'hippo'], ... name='animal') >>> s @@ -1036,7 +1056,6 @@ def duplicated(self, keep="first") -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None By default, for each set of duplicated values, the first occurrence is set on False and all others on True: @@ -1107,8 +1126,6 @@ def idxmin(self) -> Hashable: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(data=[1, None, 4, 1], ... index=['A', 'B', 'C', 'D']) @@ -1136,8 +1153,6 @@ def idxmax(self) -> Hashable: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(data=[1, None, 4, 3, 4], ... index=['A', 'B', 'C', 'D', 'E']) @@ -1164,8 +1179,6 @@ def round(self, decimals: int = 0) -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([0.1, 1.3, 2.7]) >>> s.round() 0 0.0 @@ -1197,8 +1210,6 @@ def explode(self, *, ignore_index: Optional[bool] = False) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([[1, 2, 3], [], [3, 4]]) >>> s @@ -1236,8 +1247,6 @@ def corr(self, other, method="pearson", min_periods=None) -> float: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series([.2, .0, .6, .2]) >>> s2 = bpd.Series([.3, .6, .0, .1]) @@ -1274,21 +1283,19 @@ def autocorr(self, lag: int = 1) -> float: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0.25, 0.5, 0.2, -0.05]) - >>> s.autocorr() # doctest: +ELLIPSIS - np.float64(0.10355263309024067) + >>> float(s.autocorr()) # doctest: +ELLIPSIS + 0.1035526330902... - >>> s.autocorr(lag=2) - np.float64(-1.0) + >>> float(s.autocorr(lag=2)) + -1.0 If the Pearson correlation is not well defined, then 'NaN' is returned. >>> s = bpd.Series([1, 0, 0, 0]) - >>> s.autocorr() - np.float64(nan) + >>> float(s.autocorr()) + nan Args: lag (int, default 1): @@ -1312,8 +1319,6 @@ def cov( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series([0.90010907, 0.13484424, 0.62036035]) >>> s2 = bpd.Series([0.12528585, 0.26962463, 0.51111198]) @@ -1341,8 +1346,6 @@ def diff(self) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Difference with previous row @@ -1407,8 +1410,6 @@ def dot(self, other) -> Series | np.ndarray: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) >>> other = bpd.Series([-1, 2, -3, 4]) @@ -1451,10 +1452,11 @@ def sort_values( self, *, axis: Axis = 0, + inplace: bool = False, ascending: bool | int | Sequence[bool] | Sequence[int] = True, kind: str = "quicksort", na_position: str = "last", - ) -> Series | None: + ): """ Sort by the values. @@ -1463,9 +1465,6 @@ def sort_values( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([np.nan, 1, 3, 10, 5]) >>> s @@ -1528,6 +1527,8 @@ def sort_values( Args: axis (0 or 'index'): Unused. Parameter needed for compatibility with DataFrame. + inplace (bool, default False): + Whether to modify the Series rather than creating a new one. ascending (bool or list of bools, default True): If True, sort values in ascending order, otherwise descending. kind (str, default to 'quicksort'): @@ -1548,9 +1549,10 @@ def sort_index( self, *, axis: Axis = 0, + inplace: bool = False, ascending: bool | Sequence[bool] = True, na_position: NaPosition = "last", - ) -> Series | None: + ): """ Sort Series by index labels. @@ -1559,9 +1561,6 @@ def sort_index( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['a', 'b', 'c', 'd'], index=[3, 2, 1, 4]) >>> s.sort_index() @@ -1594,6 +1593,8 @@ def sort_index( Args: axis ({0 or 'index'}): Unused. Parameter needed for compatibility with DataFrame. + inplace (bool, default False): + Whether to modify the Series rather than creating a new one. ascending (bool or list-like of bools, default True): Sort ascending vs. descending. When the index is a MultiIndex the sort direction can be controlled for each level individually. @@ -1619,8 +1620,6 @@ def nlargest( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> countries_population = {"Italy": 59000000, "France": 65000000, ... "Malta": 434000, "Maldives": 434000, ... "Brunei": 434000, "Iceland": 337000, @@ -1705,8 +1704,6 @@ def nsmallest(self, n: int = 5, keep: str = "first") -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> countries_population = {"Italy": 59000000, "France": 65000000, ... "Malta": 434000, "Maldives": 434000, ... "Brunei": 434000, "Iceland": 337000, @@ -1792,16 +1789,47 @@ def apply( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None + Simple vectorized functions, lambdas or ufuncs can be applied directly + with `by_row=False`. - For applying arbitrary python function a `remote_function` is recommended. - Let's use ``reuse=False`` flag to make sure a new `remote_function` - is created every time we run the following code, but you can skip it - to potentially reuse a previously deployed `remote_function` from - the same user defined function. + >>> nums = bpd.Series([1, 2, 3, 4]) + >>> nums + 0 1 + 1 2 + 2 3 + 3 4 + dtype: Int64 + >>> nums.apply(lambda x: x*x + 2*x + 1, by_row=False) + 0 4 + 1 9 + 2 16 + 3 25 + dtype: Int64 - >>> @bpd.remote_function(reuse=False) + >>> def is_odd(num): + ... return num % 2 == 1 + >>> nums.apply(is_odd, by_row=False) + 0 True + 1 False + 2 True + 3 False + dtype: boolean + + >>> nums.apply(np.log, by_row=False) + 0 0.0 + 1 0.693147 + 2 1.098612 + 3 1.386294 + dtype: Float64 + + Use `remote_function` to apply an arbitrary Python function. + Set ``reuse=False`` flag to make sure a new `remote_function` + is created every time you run the following code. Omit it + to reuse a previously deployed `remote_function` from + the same user defined function if the hash of the function definition + hasn't changed. + + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def minutes_to_hours(x: int) -> float: ... return x/60 @@ -1814,8 +1842,8 @@ def apply( 4 120 dtype: Int64 - >>> hours = minutes.apply(minutes_to_hours) - >>> hours + >>> hours = minutes.apply(minutes_to_hours) # doctest: +SKIP + >>> hours # doctest: +SKIP 0 0.0 1 0.5 2 1.0 @@ -1827,9 +1855,10 @@ def apply( a `remote_function`, you would provide the names of the packages via `packages` param. - >>> @bpd.remote_function( + >>> @bpd.remote_function( # doctest: +SKIP ... reuse=False, ... packages=["cryptography"], + ... cloud_function_service_account="default" ... ) ... def get_hash(input: str) -> str: ... from cryptography.fernet import Fernet @@ -1843,11 +1872,11 @@ def apply( ... return f.encrypt(input.encode()).decode() >>> names = bpd.Series(["Alice", "Bob"]) - >>> hashes = names.apply(get_hash) + >>> hashes = names.apply(get_hash) # doctest: +SKIP You could return an array output from the remote function. - >>> @bpd.remote_function(reuse=False) + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def text_analyzer(text: str) -> list[int]: ... words = text.count(" ") + 1 ... periods = text.count(".") @@ -1860,46 +1889,13 @@ def apply( ... "I love this product! It's amazing.", ... "Hungry? Wanna eat? Lets go!" ... ]) - >>> features = texts.apply(text_analyzer) - >>> features + >>> features = texts.apply(text_analyzer) # doctest: +SKIP + >>> features # doctest: +SKIP 0 [9 1 0 0] 1 [6 1 1 0] 2 [5 0 1 2] dtype: list[pyarrow] - Simple vectorized functions, lambdas or ufuncs can be applied directly - with `by_row=False`. - - >>> nums = bpd.Series([1, 2, 3, 4]) - >>> nums - 0 1 - 1 2 - 2 3 - 3 4 - dtype: Int64 - >>> nums.apply(lambda x: x*x + 2*x + 1, by_row=False) - 0 4 - 1 9 - 2 16 - 3 25 - dtype: Int64 - - >>> def is_odd(num): - ... return num % 2 == 1 - >>> nums.apply(is_odd, by_row=False) - 0 True - 1 False - 2 True - 3 False - dtype: boolean - - >>> nums.apply(np.log, by_row=False) - 0 0.0 - 1 0.693147 - 2 1.098612 - 3 1.386294 - dtype: Float64 - Args: func (function): BigFrames DataFrames ``remote_function`` to apply. The function @@ -1933,13 +1929,10 @@ def combine( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - Consider 2 Datasets ``s1`` and ``s2`` containing highest clocked speeds of different birds. + >>> import bigframes.pandas as bpd >>> s1 = bpd.Series({'falcon': 330.0, 'eagle': 160.0}) >>> s1 falcon 330.0 @@ -1993,8 +1986,6 @@ def groupby( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can group by a named index level. @@ -2017,7 +2008,6 @@ def groupby( You can also group by more than one index levels. - >>> import pandas as pd >>> s = bpd.Series([380, 370., 24., 26.], ... index=pd.MultiIndex.from_tuples( ... [("Falcon", "Clear"), @@ -2166,8 +2156,6 @@ def drop( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(data=np.arange(3), index=['A', 'B', 'C']) >>> s @@ -2184,7 +2172,6 @@ def drop( Drop 2nd level label in MultiIndex Series: - >>> import pandas as pd >>> midx = pd.MultiIndex(levels=[['llama', 'cow', 'falcon'], ... ['speed', 'weight', 'length']], ... codes=[[0, 0, 0, 1, 1, 1, 2, 2, 2], @@ -2297,9 +2284,6 @@ def interpolate(self, method: str = "linear"): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None Filling in NaN in a Series via linear interpolation. @@ -2338,26 +2322,30 @@ def fillna( value=None, ) -> Series | None: """ - Fill NA/NaN values using the specified method. + Fill NA (NULL in BigQuery) values using the specified method. - **Examples:** + Note that empty strings ``''``, :attr:`numpy.inf`, and + :attr:`numpy.nan` are ***not*** considered NA values. This NA/NULL + logic differs from numpy, but it is the same as BigQuery and the + :class:`pandas.ArrowDtype`. - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None + **Examples:** - >>> s = bpd.Series([np.nan, 2, np.nan, -1]) + >>> s = bpd.Series( + ... pa.array([np.nan, 2, None, -1], type=pa.float64()), + ... dtype=pd.ArrowDtype(pa.float64()), + ... ) >>> s - 0 + 0 NaN 1 2.0 2 3 -1.0 dtype: Float64 - Replace all NA elements with 0s. + Replace all NA (NULL) elements with 0s. >>> s.fillna(0) - 0 0.0 + 0 NaN 1 2.0 2 0.0 3 -1.0 @@ -2367,7 +2355,7 @@ def fillna( >>> s_fill = bpd.Series([11, 22, 33]) >>> s.fillna(s_fill) - 0 11.0 + 0 NaN 1 2.0 2 33.0 3 -1.0 @@ -2398,8 +2386,6 @@ def replace( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([1, 2, 3, 4, 5]) >>> s 0 1 @@ -2518,15 +2504,74 @@ def replace( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def resample( + self, + rule: str, + *, + closed: Optional[Literal["right", "left"]] = None, + label: Optional[Literal["right", "left"]] = None, + level=None, + origin: Union[ + Union[pd.Timestamp, datetime.datetime, numpy.datetime64, int, float, str], + Literal["epoch", "start", "start_day", "end", "end_day"], + ] = "start_day", + ): + """Resample time-series data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> data = { + ... "timestamp_col": pd.date_range( + ... start="2021-01-01 13:00:00", periods=30, freq="1s" + ... ), + ... "int64_col": range(30), + ... } + >>> s = bpd.DataFrame(data).set_index("timestamp_col") + >>> s.resample(rule="7s", origin="epoch").min() + int64_col + 2021-01-01 12:59:56 0 + 2021-01-01 13:00:03 3 + 2021-01-01 13:00:10 10 + 2021-01-01 13:00:17 17 + 2021-01-01 13:00:24 24 + + [5 rows x 1 columns] + + Args: + rule (str): + The offset string representing target conversion. + Offsets 'ME', 'YE', 'QE', 'BME', 'BA', 'BQE', and 'W' are *not* + supported. + closed (Literal['left'] | None): + Which side of bin interval is closed. The default is 'left' for + all supported frequency offsets. + label (Literal['right'] | Literal['left'] | None): + Which bin edge label to label bucket with. The default is 'left' + for all supported frequency offsets. + on (str, default None): + For a DataFrame, column to use instead of index for resampling. Column + must be datetime-like. + level (str or int, default None): + For a MultiIndex, level (name or number) to use for resampling. + level must be datetime-like. + origin(str, default 'start_day'): + The timestamp on which to adjust the grouping. Must be one of the following: + 'epoch': origin is 1970-01-01 + 'start': origin is the first value of the timeseries + 'start_day': origin is the first day at midnight of the timeseries + Origin values 'end' and 'end_day' are *not* supported. + Returns: + SeriesGroupBy: SeriesGroupBy object. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def dropna(self, *, axis=0, inplace: bool = False, how=None) -> Series: """ Return a new Series with missing values removed. **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None Drop NA values from a Series: @@ -2544,7 +2589,7 @@ def dropna(self, *, axis=0, inplace: bool = False, how=None) -> Series: Empty strings are not considered NA values. ``None`` is considered an NA value. - >>> ser = bpd.Series(['2', bpd.NA, '', None, 'I stay'], dtype='object') + >>> ser = bpd.Series(['2', pd.NA, '', None, 'I stay'], dtype='object') >>> ser 0 2 1 @@ -2588,9 +2633,6 @@ def between( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None Boundary values are included by default: @@ -2647,9 +2689,6 @@ def case_when( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> c = bpd.Series([6, 7, 8, 9], name="c") >>> a = bpd.Series([0, 0, 1, 2]) @@ -2717,9 +2756,6 @@ def cumprod(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([2, np.nan, 5, -1, 0]) >>> s 0 2.0 @@ -2754,9 +2790,6 @@ def cumsum(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, np.nan, 5, -1, 0]) >>> s @@ -2797,9 +2830,6 @@ def cummax(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, np.nan, 5, -1, 0]) >>> s @@ -2836,9 +2866,6 @@ def cummin(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, np.nan, 5, -1, 0]) >>> s @@ -2873,9 +2900,6 @@ def eq(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -2918,9 +2942,6 @@ def ne(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -2965,9 +2986,6 @@ def le(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3011,9 +3029,6 @@ def lt(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3058,9 +3073,6 @@ def ge(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3105,9 +3117,6 @@ def gt(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3151,10 +3160,8 @@ def add(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> a = bpd.Series([1, 2, 3, bpd.NA]) + >>> a = bpd.Series([1, 2, 3, pd.NA]) >>> a 0 1 1 2 @@ -3215,8 +3222,6 @@ def __add__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1.5, 2.6], index=['elk', 'moose']) >>> s @@ -3267,9 +3272,6 @@ def radd(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3332,9 +3334,6 @@ def sub( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3377,8 +3376,6 @@ def __sub__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1.5, 2.6], index=['elk', 'moose']) >>> s @@ -3429,9 +3426,6 @@ def rsub(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3491,9 +3485,6 @@ def mul(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3537,8 +3528,6 @@ def __mul__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3577,9 +3566,6 @@ def rmul(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3638,9 +3624,6 @@ def truediv(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3684,8 +3667,6 @@ def __truediv__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3724,9 +3705,6 @@ def rtruediv(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3786,9 +3764,6 @@ def floordiv(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3832,8 +3807,6 @@ def __floordiv__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can divide by a scalar: @@ -3872,9 +3845,6 @@ def rfloordiv(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3934,9 +3904,6 @@ def mod(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3980,8 +3947,6 @@ def __mod__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can modulo with a scalar: @@ -4019,9 +3984,6 @@ def rmod(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -4083,9 +4045,6 @@ def pow(self, other) -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a a 1.0 @@ -4118,6 +4077,7 @@ def pow(self, other) -> Series: The result of the operation. """ + # TODO(b/452366836): adjust sample if needed to match pyarrow semantics. raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) def __pow__(self, other): @@ -4129,8 +4089,6 @@ def __pow__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can exponentiate with a scalar: @@ -4170,9 +4128,6 @@ def rpow(self, other) -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a a 1.0 @@ -4232,9 +4187,6 @@ def divmod(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -4284,9 +4236,6 @@ def rdivmod(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -4339,9 +4288,6 @@ def combine_first(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series([1, np.nan]) >>> s2 = bpd.Series([3, 4, 5]) @@ -4381,10 +4327,6 @@ def update(self, other) -> None: **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s.update(bpd.Series([4, 5, 6])) @@ -4410,7 +4352,7 @@ def update(self, other) -> None: 2 6 dtype: Int64 - If ``other`` contains NaNs the corresponding values are not updated + If ``other`` contains NA (NULL values) the corresponding values are not updated in the original Series. >>> s = bpd.Series([1, 2, 3]) @@ -4475,9 +4417,6 @@ def any( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None For Series input, the output is a scalar indicating whether any element is True. @@ -4511,8 +4450,6 @@ def max( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Calculating the max of a Series: @@ -4527,7 +4464,7 @@ def max( Calculating the max of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, bpd.NA]) + >>> s = bpd.Series([1, 3, pd.NA]) >>> s 0 1 1 3 @@ -4553,8 +4490,6 @@ def min( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Calculating the min of a Series: @@ -4569,7 +4504,7 @@ def min( Calculating the min of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, bpd.NA]) + >>> s = bpd.Series([1, 3, pd.NA]) >>> s 0 1 1 3 @@ -4594,8 +4529,6 @@ def std( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'person_id': [0, 1, 2, 3], ... 'age': [21, 25, 62, 43], @@ -4642,8 +4575,6 @@ def sum(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Calculating the sum of a Series: @@ -4658,7 +4589,7 @@ def sum(self): Calculating the sum of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, bpd.NA]) + >>> s = bpd.Series([1, 3, pd.NA]) >>> s 0 1 1 3 @@ -4678,8 +4609,6 @@ def mean(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Calculating the mean of a Series: @@ -4694,7 +4623,7 @@ def mean(self): Calculating the mean of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, bpd.NA]) + >>> s = bpd.Series([1, 3, pd.NA]) >>> s 0 1 1 3 @@ -4715,8 +4644,6 @@ def median(self, *, exact: bool = True): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([1, 2, 3]) >>> s.median() np.float64(2.0) @@ -4756,8 +4683,6 @@ def quantile( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([1, 2, 3, 4]) >>> s.quantile(.5) np.float64(2.5) @@ -4788,6 +4713,45 @@ def prod(self): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def describe(self): + """ + Generate descriptive statistics. + + Descriptive statistics include those that summarize the central + tendency, dispersion and shape of a + dataset's distribution, excluding ``NaN`` values. + + .. note:: + Percentile values are approximates only. + + .. note:: + For numeric data, the result's index will include ``count``, + ``mean``, ``std``, ``min``, ``max`` as well as lower, ``50`` and + upper percentiles. By default the lower percentile is ``25`` and the + upper percentile is ``75``. The ``50`` percentile is the + same as the median. + + **Examples:** + + + >>> s = bpd.Series(['A', 'A', 'B']) + >>> s + 0 A + 1 A + 2 B + dtype: string + + >>> s.describe() + count 3 + nunique 2 + Name: 0, dtype: Int64 + + Returns: + bigframes.pandas.Series: + Summary statistics of the Series. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def skew(self): """Return unbiased skew over requested axis. @@ -4795,8 +4759,6 @@ def skew(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s.skew() @@ -4833,8 +4795,6 @@ def kurt(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 2, 3], index=['cat', 'dog', 'dog', 'mouse']) >>> s @@ -4871,6 +4831,23 @@ def kurt(self): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def item(self: Series, *args, **kwargs): + """Return the first element of the underlying data as a Python scalar. + + **Examples:** + + >>> s = bpd.Series([1]) + >>> s.item() + np.int64(1) + + Returns: + scalar: The first element of Series. + + Raises: + ValueError: If the data is not length = 1. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def items(self): """ Lazily iterate over (index, value) tuples. @@ -4880,8 +4857,6 @@ def items(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['A', 'B', 'C']) >>> for index, value in s.items(): @@ -4902,8 +4877,6 @@ def where(self, cond, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([10, 11, 12, 13, 14]) >>> s @@ -4970,9 +4943,6 @@ def mask(self, cond, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([10, 11, 12, 13, 14]) >>> s 0 10 @@ -5016,7 +4986,7 @@ def mask(self, cond, other): condition is evaluated based on a complicated business logic which cannot be expressed in form of a Series. - >>> @bpd.remote_function(reuse=False) + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def should_mask(name: str) -> bool: ... hash = 0 ... for char_ in name: @@ -5029,12 +4999,12 @@ def mask(self, cond, other): 1 Bob 2 Caroline dtype: string - >>> s.mask(should_mask) + >>> s.mask(should_mask) # doctest: +SKIP 0 1 Bob 2 Caroline dtype: string - >>> s.mask(should_mask, "REDACTED") + >>> s.mask(should_mask, "REDACTED") # doctest: +SKIP 0 REDACTED 1 Bob 2 Caroline @@ -5128,8 +5098,6 @@ def argmax(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Consider dataset containing cereal calories. @@ -5166,8 +5134,6 @@ def argmin(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Consider dataset containing cereal calories. @@ -5195,7 +5161,7 @@ def argmin(self): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def rename(self, index, **kwargs) -> Series | None: + def rename(self, index, *, inplace, **kwargs): """ Alter Series index labels or name. @@ -5207,8 +5173,6 @@ def rename(self, index, **kwargs) -> Series | None: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s @@ -5239,15 +5203,17 @@ def rename(self, index, **kwargs) -> Series | None: the index. Scalar or hashable sequence-like will alter the ``Series.name`` attribute. + inplace (bool): + Default False. Whether to return a new Series. Returns: - bigframes.pandas.Series: - Series with index labels. + bigframes.pandas.Series | None: + Series with index labels or None if ``inplace=True``. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def rename_axis(self, mapper, **kwargs): + def rename_axis(self, mapper, *, inplace, **kwargs): """ Set the name of the axis for the index or columns. @@ -5257,8 +5223,6 @@ def rename_axis(self, mapper, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Series @@ -5322,10 +5286,8 @@ def value_counts( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([3, 1, 2, 3, 4, bpd.NA], dtype="Int64") + >>> s = bpd.Series([3, 1, 2, 3, 4, pd.NA], dtype="Int64") >>> s 0 3 @@ -5401,8 +5363,6 @@ def str(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(["A_Str_Series"]) >>> s 0 A_Str_Series @@ -5430,8 +5390,6 @@ def plot(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> ser = bpd.Series([1, 2, 3, 3]) >>> plot = ser.plot(kind='hist', title="My plot") >>> plot @@ -5457,8 +5415,6 @@ def isin(self, values): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['llama', 'cow', 'llama', 'beetle', 'llama', ... 'hippo'], name='animal') @@ -5523,8 +5479,6 @@ def is_monotonic_increasing(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 2]) >>> s.is_monotonic_increasing @@ -5547,8 +5501,6 @@ def is_monotonic_decreasing(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([3, 2, 2, 1]) >>> s.is_monotonic_decreasing @@ -5589,10 +5541,7 @@ def map( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - - >>> s = bpd.Series(['cat', 'dog', bpd.NA, 'rabbit']) + >>> s = bpd.Series(['cat', 'dog', pd.NA, 'rabbit']) >>> s 0 cat 1 dog @@ -5612,7 +5561,7 @@ def map( It also accepts a remote function: - >>> @bpd.remote_function() + >>> @bpd.remote_function(cloud_function_service_account="default") # doctest: +SKIP ... def my_mapper(val: str) -> str: ... vowels = ["a", "e", "i", "o", "u"] ... if val: @@ -5621,7 +5570,7 @@ def map( ... ]) ... return "N/A" - >>> s.map(my_mapper) + >>> s.map(my_mapper) # doctest: +SKIP 0 cAt 1 dOg 2 N/A @@ -5655,8 +5604,6 @@ def iloc(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> mydict = [{'a': 1, 'b': 2, 'c': 3, 'd': 4}, ... {'a': 100, 'b': 200, 'c': 300, 'd': 400}, @@ -5735,8 +5682,6 @@ def loc(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[1, 2], [4, 5], [7, 8]], ... index=['cobra', 'viper', 'sidewinder'], @@ -5822,8 +5767,6 @@ def iat(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[0, 2, 3], [0, 4, 1], [10, 20, 30]], ... columns=['A', 'B', 'C']) @@ -5857,8 +5800,6 @@ def at(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[0, 2, 3], [0, 4, 1], [10, 20, 30]], ... index=[4, 5, 6], columns=['A', 'B', 'C']) @@ -5893,8 +5834,6 @@ def values(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.Series([1, 2, 3]).values array([1, 2, 3]) @@ -5915,8 +5854,6 @@ def size(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None For Series: @@ -5941,7 +5878,7 @@ def size(self) -> int: """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def __array__(self, dtype=None) -> numpy.ndarray: + def __array__(self, dtype=None, copy: Optional[bool] = None) -> numpy.ndarray: """ Returns the values as NumPy array. @@ -5952,9 +5889,6 @@ def __array__(self, dtype=None) -> numpy.ndarray: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np >>> ser = bpd.Series([1, 2, 3]) @@ -5965,6 +5899,8 @@ def __array__(self, dtype=None) -> numpy.ndarray: dtype (str or numpy.dtype, optional): The dtype to use for the resulting NumPy array. By default, the dtype is inferred from the data. + copy (bool or None, optional): + Whether to copy the data, False is not supported. Returns: numpy.ndarray: @@ -5978,8 +5914,6 @@ def __len__(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> len(s) @@ -5994,8 +5928,6 @@ def __invert__(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series([True, False, True]) >>> ~ser @@ -6015,8 +5947,6 @@ def __and__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) @@ -6054,8 +5984,6 @@ def __or__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) @@ -6093,8 +6021,6 @@ def __xor__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) @@ -6132,8 +6058,6 @@ def __getitem__(self, indexer): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([15, 30, 45]) >>> s[1] diff --git a/third_party/bigframes_vendored/pandas/core/strings/accessor.py b/third_party/bigframes_vendored/pandas/core/strings/accessor.py index bd5e78f415..9a72b98aee 100644 --- a/third_party/bigframes_vendored/pandas/core/strings/accessor.py +++ b/third_party/bigframes_vendored/pandas/core/strings/accessor.py @@ -20,7 +20,6 @@ def __getitem__(self, key: typing.Union[int, slice]): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Alice', 'Bob', 'Charlie']) >>> s.str[0] @@ -54,7 +53,6 @@ def extract(self, pat: str, flags: int = 0): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None A pattern with two groups will return a DataFrame with two columns. Non-matches will be `NaN`. @@ -115,7 +113,6 @@ def find(self, sub, start: int = 0, end=None): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(["cow_", "duck_", "do_ve"]) >>> ser.str.find("_") @@ -146,11 +143,10 @@ def len(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Returns the length (number of characters) in a string. - >>> s = bpd.Series(['dog', '', bpd.NA]) + >>> s = bpd.Series(['dog', '', pd.NA]) >>> s.str.len() 0 3 1 0 @@ -172,7 +168,6 @@ def lower(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['lower', ... 'CAPITALS', @@ -197,7 +192,6 @@ def slice(self, start=None, stop=None): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["koala", "dog", "chameleon"]) >>> s @@ -239,7 +233,7 @@ def slice(self, start=None, stop=None): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def strip(self): + def strip(self, to_strip: typing.Optional[str] = None): """Remove leading and trailing characters. Strip whitespaces (including newlines) or a set of specified characters @@ -250,23 +244,32 @@ def strip(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['Ant', ' Bee ', '\\tCat\\n', bpd.NA]) - >>> s + >>> s = bpd.Series([ + ... '1. Ant.', + ... ' 2. Bee? ', + ... '\\t3. Cat!\\n', + ... pd.NA, + ... ]) + >>> s.str.strip() + 0 1. Ant. + 1 2. Bee? + 2 3. Cat! + 3 + dtype: string + + >>> s.str.strip('123.!? \\n\\t') 0 Ant - 1 Bee + 1 Bee 2 Cat - - 3 + 3 dtype: string - >>> s.str.strip() - 0 Ant - 1 Bee - 2 Cat - 3 - dtype: string + Args: + to_strip (str, default None): + Specifying the set of characters to be removed. All combinations + of this set of characters will be stripped. If None then + whitespaces are removed. Returns: bigframes.series.Series: Series or Index without leading @@ -283,7 +286,6 @@ def upper(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['lower', ... 'CAPITALS', @@ -312,7 +314,6 @@ def isnumeric(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series(['one', 'one1', '1', '']) >>> s1.str.isnumeric() @@ -339,7 +340,6 @@ def isalpha(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series(['one', 'one1', '1', '']) >>> s1.str.isalpha() @@ -365,7 +365,6 @@ def isdigit(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['23', '1a', '1/5', '']) >>> s.str.isdigit() @@ -391,7 +390,6 @@ def isalnum(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series(['one', 'one1', '1', '']) >>> s1.str.isalnum() @@ -429,7 +427,6 @@ def isspace(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([' ', '\\t\\r\\n ', '']) >>> s.str.isspace() @@ -455,7 +452,6 @@ def islower(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['leopard', 'Golden Eagle', 'SNAKE', '']) >>> s.str.islower() @@ -482,7 +478,6 @@ def isupper(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['leopard', 'Golden Eagle', 'SNAKE', '']) >>> s.str.isupper() @@ -509,7 +504,6 @@ def isdecimal(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None The `isdecimal` method checks for characters used to form numbers in base 10. @@ -529,8 +523,8 @@ def isdecimal(self): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def rstrip(self): - """Remove trailing characters. + def rstrip(self, to_strip: typing.Optional[str] = None): + r"""Remove trailing characters. Strip whitespaces (including newlines) or a set of specified characters from each string in the Series/Index from right side. @@ -540,32 +534,29 @@ def rstrip(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - - >>> s = bpd.Series(['Ant', ' Bee ', '\\tCat\\n', bpd.NA]) - >>> s - 0 Ant - 1 Bee - 2 Cat - - 3 - dtype: string + >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', pd.NA]) >>> s.str.rstrip() 0 Ant 1 Bee - 2 Cat + 2 \tCat 3 dtype: string + Args: + to_strip (str, default None): + Specifying the set of characters to be removed. All combinations + of this set of characters will be stripped. If None then + whitespaces are removed. + Returns: bigframes.series.Series: Series without trailing characters. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def lstrip(self): - """Remove leading characters. + def lstrip(self, to_strip: typing.Optional[str] = None): + r"""Remove leading characters. Strip whitespaces (including newlines) or a set of specified characters from each string in the Series/Index from left side. @@ -575,25 +566,21 @@ def lstrip(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - - >>> s = bpd.Series(['Ant', ' Bee ', '\\tCat\\n', bpd.NA]) - >>> s - 0 Ant - 1 Bee - 2 Cat - - 3 - dtype: string + >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', pd.NA]) >>> s.str.lstrip() - 0 Ant - 1 Bee - 2 Cat - - 3 + 0 Ant + 1 Bee + 2 Cat\n + 3 dtype: string + Args: + to_strip (str, default None): + Specifying the set of characters to be removed. All combinations + of this set of characters will be stripped. If None then + whitespaces are removed. + Returns: bigframes.series.Series: Series without leading characters. """ @@ -606,7 +593,6 @@ def repeat(self, repeats: int): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['a', 'b', 'c']) >>> s @@ -640,7 +626,6 @@ def capitalize(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['lower', ... 'CAPITALS', @@ -668,7 +653,6 @@ def cat(self, others, *, join): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can concatenate each string in a Series to another string. @@ -725,7 +709,6 @@ def contains(self, pat, case: bool = True, flags: int = 0, *, regex: bool = True **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Returning a Series of booleans using only a literal pattern. @@ -829,13 +812,12 @@ def replace( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None When *pat* is a string and *regex* is True, the given *pat* is compiled as a regex. When *repl* is a string, it replaces matching regex patterns as with `re.sub()`. NaN value(s) in the Series are left as is: - >>> s = bpd.Series(['foo', 'fuz', bpd.NA]) + >>> s = bpd.Series(['foo', 'fuz', pd.NA]) >>> s.str.replace('f.', 'ba', regex=True) 0 bao 1 baz @@ -845,7 +827,7 @@ def replace( When *pat* is a string and *regex* is False, every *pat* is replaced with *repl* as with `str.replace()`: - >>> s = bpd.Series(['f.o', 'fuz', bpd.NA]) + >>> s = bpd.Series(['f.o', 'fuz', pd.NA]) >>> s.str.replace('f.', 'ba', regex=False) 0 bao 1 fuz @@ -891,9 +873,8 @@ def startswith( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['bat', 'Bear', 'caT', bpd.NA]) + >>> s = bpd.Series(['bat', 'Bear', 'caT', pd.NA]) >>> s 0 bat 1 Bear @@ -936,9 +917,8 @@ def endswith( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['bat', 'bear', 'caT', bpd.NA]) + >>> s = bpd.Series(['bat', 'bear', 'caT', pd.NA]) >>> s 0 bat 1 bear @@ -982,8 +962,6 @@ def split( **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ @@ -1026,7 +1004,6 @@ def match(self, pat: str, case: bool = True, flags: int = 0): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(["horse", "eagle", "donkey"]) >>> ser.str.match("e") @@ -1055,7 +1032,6 @@ def fullmatch(self, pat: str, case: bool = True, flags: int = 0): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(["cat", "duck", "dove"]) >>> ser.str.fullmatch(r'd.+') @@ -1087,7 +1063,6 @@ def get(self, i: int): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["apple", "banana", "fig"]) >>> s.str.get(3) @@ -1117,7 +1092,6 @@ def pad( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["caribou", "tiger"]) >>> s @@ -1165,7 +1139,6 @@ def ljust( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(['dog', 'bird', 'mouse']) >>> ser.str.ljust(8, fillchar='.') @@ -1197,7 +1170,6 @@ def rjust( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(['dog', 'bird', 'mouse']) >>> ser.str.rjust(8, fillchar='.') @@ -1233,9 +1205,8 @@ def zfill( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['-1', '1', '1000', bpd.NA]) + >>> s = bpd.Series(['-1', '1', '1000', pd.NA]) >>> s 0 -1 1 1 @@ -1273,7 +1244,6 @@ def center( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(['dog', 'bird', 'mouse']) >>> ser.str.center(8, fillchar='.') @@ -1293,3 +1263,41 @@ def center( bigframes.series.Series: Returns Series or Index with minimum number of char in object. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def join(self, sep: str): + """ + Join lists contained as elements in the Series/Index with passed delimiter. + + If the elements of a Series are lists themselves, join the content of these + lists using the delimiter passed to the function. + This function is an equivalent to :meth:`str.join`. + + **Examples:** + + >>> import bigframes.pandas as bpd + + Example with a list that contains non-string elements. + + >>> s = bpd.Series([['lion', 'elephant', 'zebra'], + ... ['dragon'], + ... ['duck', 'swan', 'fish', 'guppy']]) + >>> s + 0 ['lion' 'elephant' 'zebra'] + 1 ['dragon'] + 2 ['duck' 'swan' 'fish' 'guppy'] + dtype: list[pyarrow] + + >>> s.str.join('-') + 0 lion-elephant-zebra + 1 dragon + 2 duck-swan-fish-guppy + dtype: string + + Args: + sep (str): + Delimiter to use between list entries. + + Returns: + bigframes.series.Series: The list entries concatenated by intervening occurrences of the delimiter. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/pandas/core/tools/datetimes.py b/third_party/bigframes_vendored/pandas/core/tools/datetimes.py index d6048d1208..655f801b3d 100644 --- a/third_party/bigframes_vendored/pandas/core/tools/datetimes.py +++ b/third_party/bigframes_vendored/pandas/core/tools/datetimes.py @@ -1,17 +1,22 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/tools/datetimes.py -from datetime import datetime +from datetime import date, datetime from typing import List, Mapping, Tuple, Union import pandas as pd -from bigframes import constants, series +from bigframes import constants, dataframe, series local_iterables = Union[List, Tuple, pd.Series, pd.DataFrame, Mapping] def to_datetime( - arg, + arg: Union[ + Union[int, float, str, datetime, date], + local_iterables, + series.Series, + dataframe.DataFrame, + ], *, utc=False, format=None, @@ -33,7 +38,6 @@ def to_datetime( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Converting a Scalar to datetime: @@ -58,7 +62,7 @@ def to_datetime( dtype: timestamp[us, tz=UTC][pyarrow] Args: - arg (int, float, str, datetime, list, tuple, 1-d array, Series): + arg (int, float, str, datetime, date, list, tuple, 1-d array, Series): The object to convert to a datetime. utc (bool, default False): Control timezone-related parsing, localization and conversion. If True, the diff --git a/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py b/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py index 9442e965fa..4e418af406 100644 --- a/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py +++ b/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py @@ -54,11 +54,9 @@ def to_timedelta( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Converting a Scalar to timedelta + >>> import bigframes.pandas as bpd >>> scalar = 2 >>> bpd.to_timedelta(scalar, unit='s') Timedelta('0 days 00:00:02') diff --git a/third_party/bigframes_vendored/pandas/core/window/rolling.py b/third_party/bigframes_vendored/pandas/core/window/rolling.py index a869c86e72..7ca676fbe6 100644 --- a/third_party/bigframes_vendored/pandas/core/window/rolling.py +++ b/third_party/bigframes_vendored/pandas/core/window/rolling.py @@ -37,3 +37,52 @@ def max(self): def min(self): """Calculate the weighted window minimum.""" raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def agg(self, func): + """ + Aggregate using one or more operations over the specified axis. + + **Examples:** + + >>> import bigframes.pandas as bpd + + >>> df = bpd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6], "C": [7, 8, 9]}) + >>> df + A B C + 0 1 4 7 + 1 2 5 8 + 2 3 6 9 + + [3 rows x 3 columns] + + >>> df.rolling(2).sum() + A B C + 0 + 1 3 9 15 + 2 5 11 17 + + [3 rows x 3 columns] + + >>> df.rolling(2).agg({"A": "sum", "B": "min"}) + A B + 0 + 1 3 4 + 2 5 5 + + [3 rows x 2 columns] + + Args: + func (function, str, list or dict): + Function to use for aggregating the data. + + Accepted combinations are: + + - string function name + - list of function names, e.g. ``['sum', 'mean']`` + - dict of axis labels -> function names or list of such. + + Returns: + Series or DataFrame + + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/pandas/io/gbq.py b/third_party/bigframes_vendored/pandas/io/gbq.py index aa4d862b65..3190c92b92 100644 --- a/third_party/bigframes_vendored/pandas/io/gbq.py +++ b/third_party/bigframes_vendored/pandas/io/gbq.py @@ -25,6 +25,7 @@ def read_gbq( filters: FiltersType = (), use_cache: Optional[bool] = None, col_order: Iterable[str] = (), + allow_large_results: Optional[bool] = None, ): """Loads a DataFrame from BigQuery. @@ -45,7 +46,7 @@ def read_gbq( * (Recommended) Set the ``index_col`` argument to one or more columns. Unique values for the row labels are recommended. Duplicate labels are possible, but note that joins on a non-unique index can duplicate - rows via pandas-like outer join behavior. + rows via pandas-compatible outer join behavior. .. note:: By default, even SQL query inputs with an ORDER BY clause create a @@ -60,13 +61,13 @@ def read_gbq( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None If the input is a table ID: >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") Read table path with wildcard suffix and filters: + >>> df = bpd.read_gbq_table("bigquery-public-data.noaa_gsod.gsod19*", filters=[("_table_suffix", ">=", "30"), ("_table_suffix", "<=", "39")]) Preserve ordering in a query input. @@ -155,6 +156,11 @@ def read_gbq( `configuration` to avoid conflicts. col_order (Iterable[str]): Alias for columns, retained for backwards compatibility. + allow_large_results (bool, optional): + Whether to allow large query results. If ``True``, the query + results can be larger than the maximum response size. This + option is only applicable when ``query_or_table`` is a query. + Defaults to ``bpd.options.compute.allow_large_results``. Raises: bigframes.exceptions.DefaultIndexWarning: diff --git a/third_party/bigframes_vendored/pandas/io/parquet.py b/third_party/bigframes_vendored/pandas/io/parquet.py index aec911d2fe..c02c5e52c5 100644 --- a/third_party/bigframes_vendored/pandas/io/parquet.py +++ b/third_party/bigframes_vendored/pandas/io/parquet.py @@ -27,7 +27,6 @@ def read_parquet( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> gcs_path = "gs://cloud-samples-data/bigquery/us-states/us-states.parquet" >>> df = bpd.read_parquet(path=gcs_path, engine="bigquery") diff --git a/third_party/bigframes_vendored/pandas/io/parsers/readers.py b/third_party/bigframes_vendored/pandas/io/parsers/readers.py index 2b1e3dd70b..5a505c2859 100644 --- a/third_party/bigframes_vendored/pandas/io/parsers/readers.py +++ b/third_party/bigframes_vendored/pandas/io/parsers/readers.py @@ -71,7 +71,6 @@ def read_csv( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> gcs_path = "gs://cloud-samples-data/bigquery/us-states/us-states.csv" >>> df = bpd.read_csv(filepath_or_buffer=gcs_path) @@ -114,7 +113,7 @@ def read_csv( names (default None): a list of column names to use. If the file contains a header row and you want to pass this parameter, then `header=0` should be passed as well so the - first (header) row is ignored. Only to be used with default engine. + first (header) row is ignored. index_col (default None): column(s) to use as the row labels of the DataFrame, either given as string name or column index. `index_col=False` can be used with the default @@ -192,7 +191,6 @@ def read_json( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> gcs_path = "gs://bigframes-dev-testing/sample1.json" >>> df = bpd.read_json(path_or_buf=gcs_path, lines=True, orient="records") diff --git a/third_party/bigframes_vendored/pandas/io/pickle.py b/third_party/bigframes_vendored/pandas/io/pickle.py index 33088dc019..03f1afe35e 100644 --- a/third_party/bigframes_vendored/pandas/io/pickle.py +++ b/third_party/bigframes_vendored/pandas/io/pickle.py @@ -35,7 +35,6 @@ def read_pickle( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> gcs_path = "gs://bigframes-dev-testing/test_pickle.pkl" >>> df = bpd.read_pickle(filepath_or_buffer=gcs_path) diff --git a/third_party/bigframes_vendored/pandas/plotting/_core.py b/third_party/bigframes_vendored/pandas/plotting/_core.py index 4ed5c8eb0b..6c2aed970d 100644 --- a/third_party/bigframes_vendored/pandas/plotting/_core.py +++ b/third_party/bigframes_vendored/pandas/plotting/_core.py @@ -11,7 +11,6 @@ class PlotAccessor: For Series: >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series([1, 2, 3, 3]) >>> plot = ser.plot(kind='hist', title="My plot") @@ -57,9 +56,6 @@ def hist( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame(np.random.randint(1, 7, 6000), columns=['one']) >>> df['two'] = np.random.randint(1, 7, 6000) + np.random.randint(1, 7, 6000) >>> ax = df.plot.hist(bins=12, alpha=0.5) @@ -96,7 +92,6 @@ def line( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame( ... { ... 'one': [1, 2, 3, 4], @@ -164,7 +159,6 @@ def area( Draw an area plot based on basic business metrics: >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame( ... { ... 'sales': [3, 2, 3, 9, 10, 6], @@ -233,7 +227,6 @@ def bar( Basic plot. >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'lab':['A', 'B', 'C'], 'val':[10, 30, 20]}) >>> ax = df.plot.bar(x='lab', y='val', rot=0) @@ -275,6 +268,107 @@ def bar( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def barh( + self, + x: typing.Optional[typing.Hashable] = None, + y: typing.Optional[typing.Hashable] = None, + **kwargs, + ): + """ + Draw a horizontal bar plot. + + This function calls `pandas.plot` to generate a plot with a random sample + of items. For consistent results, the random sampling is reproducible. + Use the `sampling_random_state` parameter to modify the sampling seed. + + **Examples:** + + Basic plot. + + >>> import bigframes.pandas as bpd + >>> df = bpd.DataFrame({'lab':['A', 'B', 'C'], 'val':[10, 30, 20]}) + >>> ax = df.plot.barh(x='lab', y='val', rot=0) + + Plot a whole dataframe to a barh plot. Each column is assigned a distinct color, + and each row is nested in a group along the horizontal axis. + + >>> speed = [0.1, 17.5, 40, 48, 52, 69, 88] + >>> lifespan = [2, 8, 70, 1.5, 25, 12, 28] + >>> index = ['snail', 'pig', 'elephant', + ... 'rabbit', 'giraffe', 'coyote', 'horse'] + >>> df = bpd.DataFrame({'speed': speed, 'lifespan': lifespan}, index=index) + >>> ax = df.plot.barh(rot=0) + + Plot stacked barh charts for the DataFrame. + + >>> ax = df.plot.barh(stacked=True) + + If you don’t like the default colours, you can specify how you’d like each column + to be colored. + + >>> axes = df.plot.barh( + ... rot=0, subplots=True, color={"speed": "red", "lifespan": "green"} + ... ) + + Args: + x (label or position, optional): + Allows plotting of one column versus another. If not specified, the index + of the DataFrame is used. + y (label or position, optional): + Allows plotting of one column versus another. If not specified, all numerical + columns are used. + **kwargs: + Additional keyword arguments are documented in + :meth:`DataFrame.plot`. + + Returns: + matplotlib.axes.Axes or numpy.ndarray: + Area plot, or array of area plots if subplots is True. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def pie( + self, + y: typing.Optional[typing.Hashable] = None, + **kwargs, + ): + """ + Generate a pie plot. + + A pie plot is a proportional representation of the numerical data in a + column. This function wraps :meth:`matplotlib.pyplot.pie` for the + specified column. If no column reference is passed and + ``subplots=True`` a pie plot is drawn for each numerical column + independently. + + **Examples:** + + In the example below we have a DataFrame with the information about + planet's mass and radius. We pass the 'mass' column to the + pie function to get a pie plot. + + >>> import bigframes.pandas as bpd + + >>> df = bpd.DataFrame({'mass': [0.330, 4.87 , 5.97], + ... 'radius': [2439.7, 6051.8, 6378.1]}, + ... index=['Mercury', 'Venus', 'Earth']) + >>> plot = df.plot.pie(y='mass', figsize=(5, 5)) + + >>> plot = df.plot.pie(subplots=True, figsize=(11, 6)) + + Args: + y (int or label, optional): + Label or position of the column to plot. + If not provided, ``subplots=True`` argument must be passed. + **kwargs: + Keyword arguments to pass on to :meth:`DataFrame.plot`. + + Returns: + matplotlib.axes.Axes or np.ndarray: + A NumPy array is returned when `subplots` is True. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def scatter( self, x: typing.Optional[typing.Hashable] = None, @@ -296,7 +390,6 @@ def scatter( in a DataFrame's columns. >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[5.1, 3.5, 0], [4.9, 3.0, 0], [7.0, 3.2, 1], ... [6.4, 3.2, 1], [5.9, 3.0, 2]], ... columns=['length', 'width', 'species']) diff --git a/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py b/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py index a7344d49d4..2b1778eec8 100644 --- a/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py +++ b/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py @@ -30,7 +30,6 @@ class KMeans(_BaseKMeans): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> from bigframes.ml.cluster import KMeans >>> X = bpd.DataFrame({"feat0": [1, 1, 1, 10, 10, 10], "feat1": [2, 4, 0, 2, 4, 0]}) @@ -116,6 +115,26 @@ def predict( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def fit_predict( + self, + X, + y=None, + ): + """Compute cluster centers and predict cluster index for each sample. + + Convenience method; equivalent to calling fit(X) followed by predict(X). + + Args: + X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): + DataFrame of shape (n_samples, n_features). Training data. + y (default None): + Not used, present here for API consistency by convention. + + Returns: + bigframes.dataframe.DataFrame: DataFrame of shape (n_samples, n_input_columns + n_prediction_columns). Returns predicted labels. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def score( self, X, diff --git a/third_party/bigframes_vendored/sklearn/decomposition/_mf.py b/third_party/bigframes_vendored/sklearn/decomposition/_mf.py new file mode 100644 index 0000000000..7dad196237 --- /dev/null +++ b/third_party/bigframes_vendored/sklearn/decomposition/_mf.py @@ -0,0 +1,116 @@ +""" Matrix Factorization. +""" + +# Author: Alexandre Gramfort +# Olivier Grisel +# Mathieu Blondel +# Denis A. Engemann +# Michael Eickenberg +# Giorgio Patrini +# +# License: BSD 3 clause + +from abc import ABCMeta + +from bigframes_vendored.sklearn.base import BaseEstimator + +from bigframes import constants + + +class MatrixFactorization(BaseEstimator, metaclass=ABCMeta): + """Matrix Factorization (MF). + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> from bigframes.ml.decomposition import MatrixFactorization + >>> X = bpd.DataFrame({ + ... "row": [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6], + ... "column": [0,1] * 7, + ... "value": [1, 1, 2, 1, 3, 1.2, 4, 1, 5, 0.8, 6, 1, 2, 3], + ... }) + >>> model = MatrixFactorization(feedback_type='explicit', num_factors=6, user_col='row', item_col='column', rating_col='value', l2_reg=2.06) + >>> W = model.fit(X) + + Args: + feedback_type ('explicit' | 'implicit'): + Specifies the feedback type for the model. The feedback type determines the algorithm that is used during training. + num_factors (int or auto, default auto): + Specifies the number of latent factors to use. + user_col (str): + The user column name. + item_col (str): + The item column name. + l2_reg (float, default 1.0): + A floating point value for L2 regularization. The default value is 1.0. + """ + + def fit(self, X, y=None): + """Fit the model according to the given training data. + + Args: + X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): + Series or DataFrame of shape (n_samples, n_features). Training vector, + where `n_samples` is the number of samples and `n_features` is + the number of features. + + y (default None): + Ignored. + + Returns: + bigframes.ml.decomposition.MatrixFactorization: Fitted estimator. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def score(self, X=None, y=None): + """Calculate evaluation metrics of the model. + + .. note:: + + Output matches that of the BigQuery ML.EVALUATE function. + See: https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-evaluate#matrix_factorization_models + for the outputs relevant to this model type. + + Args: + X (bigframes.dataframe.DataFrame | bigframes.series.Series | None): + DataFrame of shape (n_samples, n_features). Test samples. + + y (bigframes.dataframe.DataFrame | bigframes.series.Series | None): + DataFrame of shape (n_samples,) or (n_samples, n_outputs). True + labels for `X`. + + Returns: + bigframes.dataframe.DataFrame: DataFrame that represents model metrics. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def predict(self, X): + """Generate a predicted rating for every user-item row combination for a matrix factorization model. + + Args: + X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): + Series or a DataFrame to predict. + + Returns: + bigframes.dataframe.DataFrame: Predicted DataFrames.""" + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def fit_predict( + self, + X, + y=None, + ): + """Fit the model with X and generate a predicted rating for every user-item row combination for a matrix factorization model. on X. + + Convenience method; equivalent to calling fit(X) followed by predict(X). + + Args: + X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): + DataFrame of shape (n_samples, n_features). Training data. + y (default None): + Not used, present here for API consistency by convention. + + Returns: + bigframes.dataframe.DataFrame: DataFrame of shape (n_samples, n_input_columns + n_prediction_columns). Returns predicted labels. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/sklearn/decomposition/_pca.py b/third_party/bigframes_vendored/sklearn/decomposition/_pca.py index f13c52bfb6..f90e193064 100644 --- a/third_party/bigframes_vendored/sklearn/decomposition/_pca.py +++ b/third_party/bigframes_vendored/sklearn/decomposition/_pca.py @@ -24,7 +24,6 @@ class PCA(BaseEstimator, metaclass=ABCMeta): >>> import bigframes.pandas as bpd >>> from bigframes.ml.decomposition import PCA - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({"feat0": [-1, -2, -3, 1, 2, 3], "feat1": [-1, -1, -2, 1, 1, 2]}) >>> pca = PCA(n_components=2).fit(X) >>> pca.predict(X) # doctest:+SKIP @@ -102,6 +101,26 @@ def predict(self, X): bigframes.dataframe.DataFrame: Predicted DataFrames.""" raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def fit_predict( + self, + X, + y=None, + ): + """Fit the model with X and apply the dimensionality reduction on X. + + Convenience method; equivalent to calling fit(X) followed by predict(X). + + Args: + X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): + DataFrame of shape (n_samples, n_features). Training data. + y (default None): + Not used, present here for API consistency by convention. + + Returns: + bigframes.dataframe.DataFrame: DataFrame of shape (n_samples, n_input_columns + n_prediction_columns). Returns predicted labels. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property def components_(self): """Principal axes in feature space, representing the directions of maximum variance in the data. diff --git a/third_party/bigframes_vendored/sklearn/impute/_base.py b/third_party/bigframes_vendored/sklearn/impute/_base.py index 42eab24c82..175ad86b21 100644 --- a/third_party/bigframes_vendored/sklearn/impute/_base.py +++ b/third_party/bigframes_vendored/sklearn/impute/_base.py @@ -22,7 +22,6 @@ class SimpleImputer(_BaseImputer): >>> import bigframes.pandas as bpd >>> from bigframes.ml.impute import SimpleImputer - >>> bpd.options.display.progress_bar = None >>> X_train = bpd.DataFrame({"feat0": [7.0, 4.0, 10.0], "feat1": [2.0, None, 5.0], "feat2": [3.0, 6.0, 9.0]}) >>> imp_mean = SimpleImputer().fit(X_train) >>> X_test = bpd.DataFrame({"feat0": [None, 4.0, 10.0], "feat1": [2.0, None, None], "feat2": [3.0, 6.0, 9.0]}) diff --git a/third_party/bigframes_vendored/sklearn/linear_model/_base.py b/third_party/bigframes_vendored/sklearn/linear_model/_base.py index 21ba5a3bf8..7543edd10b 100644 --- a/third_party/bigframes_vendored/sklearn/linear_model/_base.py +++ b/third_party/bigframes_vendored/sklearn/linear_model/_base.py @@ -66,7 +66,6 @@ class LinearRegression(RegressorMixin, LinearModel): >>> from bigframes.ml.linear_model import LinearRegression >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({ \ "feature0": [20, 21, 19, 18], \ "feature1": [0, 1, 1, 0], \ diff --git a/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py b/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py index a85c6fae8d..1efece251f 100644 --- a/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py +++ b/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py @@ -23,36 +23,37 @@ class LogisticRegression(LinearClassifierMixin, BaseEstimator): """Logistic Regression (aka logit, MaxEnt) classifier. - >>> from bigframes.ml.linear_model import LogisticRegression - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> X = bpd.DataFrame({ \ - "feature0": [20, 21, 19, 18], \ - "feature1": [0, 1, 1, 0], \ - "feature2": [0.2, 0.3, 0.4, 0.5]}) - >>> y = bpd.DataFrame({"outcome": [0, 0, 1, 1]}) - >>> # Create the LogisticRegression - >>> model = LogisticRegression() - >>> model.fit(X, y) - LogisticRegression() - >>> model.predict(X) # doctest:+SKIP - predicted_outcome predicted_outcome_probs feature0 feature1 feature2 - 0 0 [{'label': 1, 'prob': 3.1895929877221615e-07} ... 20 0 0.2 - 1 0 [{'label': 1, 'prob': 5.662891265051953e-06} ... 21 1 0.3 - 2 1 [{'label': 1, 'prob': 0.9999917826885262} {'l... 19 1 0.4 - 3 1 [{'label': 1, 'prob': 0.9999999993659574} {'l... 18 0 0.5 - 4 rows × 5 columns - - [4 rows x 5 columns in total] - - >>> # Score the model - >>> score = model.score(X, y) - >>> score # doctest:+SKIP - precision recall accuracy f1_score log_loss roc_auc - 0 1.0 1.0 1.0 1.0 0.000004 1.0 - 1 rows × 6 columns - - [1 rows x 6 columns in total] + **Examples:** + + >>> from bigframes.ml.linear_model import LogisticRegression + >>> import bigframes.pandas as bpd + >>> X = bpd.DataFrame({ \ + "feature0": [20, 21, 19, 18], \ + "feature1": [0, 1, 1, 0], \ + "feature2": [0.2, 0.3, 0.4, 0.5]}) + >>> y = bpd.DataFrame({"outcome": [0, 0, 1, 1]}) + >>> # Create the LogisticRegression + >>> model = LogisticRegression() + >>> model.fit(X, y) + LogisticRegression() + >>> model.predict(X) # doctest:+SKIP + predicted_outcome predicted_outcome_probs feature0 feature1 feature2 + 0 0 [{'label': 1, 'prob': 3.1895929877221615e-07} ... 20 0 0.2 + 1 0 [{'label': 1, 'prob': 5.662891265051953e-06} ... 21 1 0.3 + 2 1 [{'label': 1, 'prob': 0.9999917826885262} {'l... 19 1 0.4 + 3 1 [{'label': 1, 'prob': 0.9999999993659574} {'l... 18 0 0.5 + 4 rows × 5 columns + + [4 rows x 5 columns in total] + + >>> # Score the model + >>> score = model.score(X, y) + >>> score # doctest:+SKIP + precision recall accuracy f1_score log_loss roc_auc + 0 1.0 1.0 1.0 1.0 0.000004 1.0 + 1 rows × 6 columns + + [1 rows x 6 columns in total] Args: optimize_strategy (str, default "auto_strategy"): diff --git a/third_party/bigframes_vendored/sklearn/metrics/_classification.py b/third_party/bigframes_vendored/sklearn/metrics/_classification.py index c1a909e849..e60cc8cec4 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_classification.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_classification.py @@ -30,7 +30,6 @@ def accuracy_score(y_true, y_pred, normalize=True) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 2, 1, 3]) >>> y_pred = bpd.DataFrame([0, 1, 2, 3]) @@ -80,7 +79,6 @@ def confusion_matrix( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([2, 0, 2, 2, 0, 1]) >>> y_pred = bpd.DataFrame([0, 0, 2, 2, 0, 2]) @@ -132,7 +130,6 @@ def recall_score( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 1, 2, 0, 1, 2]) >>> y_pred = bpd.DataFrame([0, 2, 1, 0, 0, 1]) @@ -181,7 +178,6 @@ def precision_score( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 1, 2, 0, 1, 2]) >>> y_pred = bpd.DataFrame([0, 2, 1, 0, 0, 1]) @@ -201,7 +197,7 @@ def precision_score( default='binary' This parameter is required for multiclass/multilabel targets. Possible values are 'None', 'micro', 'macro', 'samples', 'weighted', 'binary'. - Only average=None is supported. + Only None and 'binary' is supported. Returns: precision: float (if average is not None) or Series of float of shape \ @@ -232,7 +228,6 @@ def f1_score( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 1, 2, 0, 1, 2]) >>> y_pred = bpd.DataFrame([0, 2, 1, 0, 0, 1]) diff --git a/third_party/bigframes_vendored/sklearn/metrics/_ranking.py b/third_party/bigframes_vendored/sklearn/metrics/_ranking.py index 7b97526de2..cd5bd2cbcd 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_ranking.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_ranking.py @@ -16,6 +16,8 @@ # Michal Karbownik # License: BSD 3 clause +import numpy as np + from bigframes import constants @@ -31,7 +33,6 @@ def auc(x, y) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> x = bpd.DataFrame([1, 1, 2, 2]) >>> y = bpd.DataFrame([2, 3, 4, 5]) @@ -60,7 +61,23 @@ def auc(x, y) -> float: Returns: float: Area Under the Curve. """ - raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + if len(x) < 2: + raise ValueError( + f"At least 2 points are needed to compute area under curve, but x.shape = {len(x)}" + ) + + if x.is_monotonic_decreasing: + d = -1 + elif x.is_monotonic_increasing: + d = 1 + else: + raise ValueError(f"x is neither increasing nor decreasing : {x}.") + + if hasattr(np, "trapezoid"): + # new in numpy 2.0 + return d * np.trapezoid(y, x) + # np.trapz has been deprecated in 2.0 + return d * np.trapz(y, x) # type: ignore def roc_auc_score(y_true, y_score) -> float: @@ -71,7 +88,6 @@ def roc_auc_score(y_true, y_score) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 0, 1, 1, 0, 1, 0, 1, 1, 1]) >>> y_score = bpd.DataFrame([0.1, 0.4, 0.35, 0.8, 0.65, 0.9, 0.5, 0.3, 0.6, 0.45]) @@ -121,7 +137,6 @@ def roc_curve( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([1, 1, 2, 2]) >>> y_score = bpd.DataFrame([0.1, 0.4, 0.35, 0.8]) diff --git a/third_party/bigframes_vendored/sklearn/metrics/_regression.py b/third_party/bigframes_vendored/sklearn/metrics/_regression.py index 56f78c6d0b..85f0c1ecf9 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_regression.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_regression.py @@ -46,7 +46,6 @@ def r2_score(y_true, y_pred, force_finite=True) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([3, -0.5, 2, 7]) >>> y_pred = bpd.DataFrame([2.5, 0.0, 2, 8]) @@ -73,7 +72,6 @@ def mean_squared_error(y_true, y_pred) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([3, -0.5, 2, 7]) >>> y_pred = bpd.DataFrame([2.5, 0.0, 2, 8]) @@ -91,3 +89,29 @@ def mean_squared_error(y_true, y_pred) -> float: float: Mean squared error. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + +def mean_absolute_error(y_true, y_pred) -> float: + """Mean absolute error regression loss. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.ml.metrics + + >>> y_true = bpd.DataFrame([3, -0.5, 2, 7]) + >>> y_pred = bpd.DataFrame([2.5, 0.0, 2, 8]) + >>> mae = bigframes.ml.metrics.mean_absolute_error(y_true, y_pred) + >>> mae + np.float64(0.5) + + Args: + y_true (Series or DataFrame of shape (n_samples,)): + Ground truth (correct) target values. + y_pred (Series or DataFrame of shape (n_samples,)): + Estimated target values. + + Returns: + float: Mean absolute error. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/sklearn/model_selection/_split.py b/third_party/bigframes_vendored/sklearn/model_selection/_split.py index ec16fa8cf9..326589be7d 100644 --- a/third_party/bigframes_vendored/sklearn/model_selection/_split.py +++ b/third_party/bigframes_vendored/sklearn/model_selection/_split.py @@ -69,7 +69,6 @@ class KFold(_BaseKFold): >>> import bigframes.pandas as bpd >>> from bigframes.ml.model_selection import KFold - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({"feat0": [1, 3, 5], "feat1": [2, 4, 6]}) >>> y = bpd.DataFrame({"label": [1, 2, 3]}) >>> kf = KFold(n_splits=3, random_state=42) @@ -162,7 +161,6 @@ def train_test_split( >>> import bigframes.pandas as bpd >>> from bigframes.ml.model_selection import train_test_split - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({"feat0": [0, 2, 4, 6, 8], "feat1": [1, 3, 5, 7, 9]}) >>> y = bpd.DataFrame({"label": [0, 1, 2, 3, 4]}) >>> X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42) diff --git a/third_party/bigframes_vendored/sklearn/model_selection/_validation.py b/third_party/bigframes_vendored/sklearn/model_selection/_validation.py index b93c47ea04..6f84018853 100644 --- a/third_party/bigframes_vendored/sklearn/model_selection/_validation.py +++ b/third_party/bigframes_vendored/sklearn/model_selection/_validation.py @@ -19,7 +19,6 @@ def cross_validate(estimator, X, y=None, *, cv=None): >>> import bigframes.pandas as bpd >>> from bigframes.ml.model_selection import cross_validate, KFold >>> from bigframes.ml.linear_model import LinearRegression - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({"feat0": [1, 3, 5], "feat1": [2, 4, 6]}) >>> y = bpd.DataFrame({"label": [1, 2, 3]}) >>> model = LinearRegression() diff --git a/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py b/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py index 5476a9fb3c..64a5786f17 100644 --- a/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py +++ b/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py @@ -25,7 +25,6 @@ class OneHotEncoder(BaseEstimator): >>> from bigframes.ml.preprocessing import OneHotEncoder >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> enc = OneHotEncoder() >>> X = bpd.DataFrame({"a": ["Male", "Female", "Female"], "b": ["1", "3", "2"]}) diff --git a/third_party/bigframes_vendored/tpch/queries/q15.py b/third_party/bigframes_vendored/tpch/queries/q15.py index 1cba0ca4bc..0e3460189d 100644 --- a/third_party/bigframes_vendored/tpch/queries/q15.py +++ b/third_party/bigframes_vendored/tpch/queries/q15.py @@ -31,6 +31,11 @@ def q(project_id: str, dataset_id: str, session: bigframes.Session): .agg(TOTAL_REVENUE=bpd.NamedAgg(column="REVENUE", aggfunc="sum")) .rename(columns={"L_SUPPKEY": "SUPPLIER_NO"}) ) + # Round earlier to prevent non-determinism in the later join due to + # differences in distributed floating point operation sort order. + grouped_revenue = grouped_revenue.assign( + TOTAL_REVENUE=grouped_revenue["TOTAL_REVENUE"].round(2) + ) joined_data = bpd.merge( supplier, grouped_revenue, left_on="S_SUPPKEY", right_on="SUPPLIER_NO" @@ -43,10 +48,6 @@ def q(project_id: str, dataset_id: str, session: bigframes.Session): max_revenue_suppliers = joined_data[ joined_data["TOTAL_REVENUE"] == joined_data["MAX_REVENUE"] ] - - max_revenue_suppliers["TOTAL_REVENUE"] = max_revenue_suppliers[ - "TOTAL_REVENUE" - ].round(2) q_final = max_revenue_suppliers[ ["S_SUPPKEY", "S_NAME", "S_ADDRESS", "S_PHONE", "TOTAL_REVENUE"] ].sort_values("S_SUPPKEY") diff --git a/third_party/bigframes_vendored/tpch/queries/q9.py b/third_party/bigframes_vendored/tpch/queries/q9.py index 6af33f7569..5c9ca1e9c3 100644 --- a/third_party/bigframes_vendored/tpch/queries/q9.py +++ b/third_party/bigframes_vendored/tpch/queries/q9.py @@ -33,13 +33,17 @@ def q(project_id: str, dataset_id: str, session: bigframes.Session): ) q_final = ( - part.merge(partsupp, left_on="P_PARTKEY", right_on="PS_PARTKEY") - .merge(supplier, left_on="PS_SUPPKEY", right_on="S_SUPPKEY") - .merge( + part.merge( lineitem, - left_on=["P_PARTKEY", "PS_SUPPKEY"], - right_on=["L_PARTKEY", "L_SUPPKEY"], + left_on="P_PARTKEY", + right_on="L_PARTKEY", + ) + .merge( + partsupp, + left_on=["L_SUPPKEY", "L_PARTKEY"], + right_on=["PS_SUPPKEY", "PS_PARTKEY"], ) + .merge(supplier, left_on="L_SUPPKEY", right_on="S_SUPPKEY") .merge(orders, left_on="L_ORDERKEY", right_on="O_ORDERKEY") .merge(nation, left_on="S_NATIONKEY", right_on="N_NATIONKEY") ) diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 27dfb23603..230dc343ac 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,4 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "1.37.0" +__version__ = "2.31.0" + +# {x-release-please-start-date} +__release_date__ = "2025-12-10" +# {x-release-please-end} diff --git a/third_party/logo/colab-logo.png b/third_party/logo/colab-logo.png new file mode 100644 index 0000000000..75740a2b6a Binary files /dev/null and b/third_party/logo/colab-logo.png differ diff --git a/third_party/logo/github-logo.png b/third_party/logo/github-logo.png new file mode 100644 index 0000000000..8b25551a97 Binary files /dev/null and b/third_party/logo/github-logo.png differ diff --git a/third_party/sphinx/LICENSE.rst b/third_party/sphinx/LICENSE.rst new file mode 100644 index 0000000000..de3688cd2c --- /dev/null +++ b/third_party/sphinx/LICENSE.rst @@ -0,0 +1,31 @@ +License for Sphinx +================== + +Unless otherwise indicated, all code in the Sphinx project is licenced under the +two clause BSD licence below. + +Copyright (c) 2007-2025 by the Sphinx team (see AUTHORS file). +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/sphinx/ext/autosummary/templates/autosummary/class.rst b/third_party/sphinx/ext/autosummary/templates/autosummary/class.rst new file mode 100644 index 0000000000..89550cb386 --- /dev/null +++ b/third_party/sphinx/ext/autosummary/templates/autosummary/class.rst @@ -0,0 +1,31 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :no-members: + + {% block methods %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + :toctree: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + :toctree: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} diff --git a/third_party/sphinx/ext/autosummary/templates/autosummary/module.rst b/third_party/sphinx/ext/autosummary/templates/autosummary/module.rst new file mode 100644 index 0000000000..98d86d1523 --- /dev/null +++ b/third_party/sphinx/ext/autosummary/templates/autosummary/module.rst @@ -0,0 +1,57 @@ +{{ fullname | escape | underline}} + +.. + Originally at + https://github.com/sphinx-doc/sphinx/blob/master/sphinx/ext/autosummary/templates/autosummary/module.rst + with modifications to support recursive generation from + https://github.com/sphinx-doc/sphinx/issues/7912 + +.. automodule:: {{ fullname }} + :no-members: + + {% block functions %} + {%- if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + :toctree: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {%- block classes %} + {%- if classes %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + :toctree: + {% for item in classes %}{% if item not in attributes %} + {{ item }} + {% endif %}{%- endfor %} + {% endif %} + {%- endblock %} + + {%- block exceptions %} + {%- if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + :toctree: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + +{%- block attributes %} +{%- if attributes %} +.. rubric:: {{ _('Module Attributes') }} + +{% for item in attributes %} +.. autoattribute:: {{ fullname }}.{{ item }} + :no-index: +{% endfor %} +{% endif %} +{%- endblock %}